Initial commit: WaMarket MVP

Marketplace inversée (Next.js 16 App Router + Supabase Cloud) : auth,
schéma + RLS, publication de besoins avec upload d'images, feed avec
filtres, offres, acceptation via RPC, messagerie temps réel,
dashboards, et Dockerfile pour déploiement Coolify.
This commit is contained in:
2026-07-30 14:33:17 +00:00
commit af81124d48
82 changed files with 16824 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import {
LoginFormSchema,
SignupFormSchema,
type FormState,
} from "@/lib/validations/auth";
export async function signup(
_state: FormState,
formData: FormData
): Promise<FormState> {
const validatedFields = SignupFormSchema.safeParse({
fullName: formData.get("fullName"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { fullName, email, password } = validatedFields.data;
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { full_name: fullName } },
});
if (error) {
return { message: error.message };
}
redirect("/dashboard/my-needs");
}
export async function login(
_state: FormState,
formData: FormData
): Promise<FormState> {
const validatedFields = LoginFormSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { email, password } = validatedFields.data;
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { message: "Email ou mot de passe incorrect." };
}
redirect("/dashboard/my-needs");
}
export async function logout() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/auth/login");
}
+20
View File
@@ -0,0 +1,20 @@
"use server";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/dal";
export async function sendMessage(conversationId: string, formData: FormData) {
const user = await verifySession();
const body = formData.get("body");
if (typeof body !== "string" || !body.trim()) {
return;
}
const supabase = await createClient();
await supabase.from("messages").insert({
conversation_id: conversationId,
sender_id: user.id,
body: body.trim(),
});
}
+97
View File
@@ -0,0 +1,97 @@
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/dal";
import { NeedFormSchema, type NeedFormState } from "@/lib/validations/needs";
const MAX_IMAGES = 5;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 Mo
export async function createNeed(
_state: NeedFormState,
formData: FormData
): Promise<NeedFormState> {
const user = await verifySession();
const validatedFields = NeedFormSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
categoryId: formData.get("categoryId"),
city: formData.get("city"),
region: formData.get("region") || undefined,
budgetMin: formData.get("budgetMin") || undefined,
budgetMax: formData.get("budgetMax") || undefined,
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { title, description, categoryId, city, region, budgetMin, budgetMax } =
validatedFields.data;
const parsedBudgetMin = budgetMin ? Number(budgetMin) : null;
const parsedBudgetMax = budgetMax ? Number(budgetMax) : null;
if (
(budgetMin && Number.isNaN(parsedBudgetMin)) ||
(budgetMax && Number.isNaN(parsedBudgetMax))
) {
return { message: "Budget invalide." };
}
if (
parsedBudgetMin !== null &&
parsedBudgetMax !== null &&
parsedBudgetMin > parsedBudgetMax
) {
return { message: "Le budget minimum doit être inférieur au maximum." };
}
const images = formData
.getAll("images")
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
if (images.length > MAX_IMAGES) {
return { message: `${MAX_IMAGES} images maximum.` };
}
if (images.some((file) => file.size > MAX_IMAGE_SIZE)) {
return { message: "Chaque image doit faire moins de 5 Mo." };
}
const supabase = await createClient();
const { data: need, error } = await supabase
.from("needs")
.insert({
author_id: user.id,
category_id: categoryId,
title,
description,
city,
region: region || null,
budget_min: parsedBudgetMin,
budget_max: parsedBudgetMax,
})
.select("id")
.single();
if (error || !need) {
return { message: "Une erreur est survenue lors de la création du besoin." };
}
for (const [index, file] of images.entries()) {
const path = `${user.id}/${need.id}/${index}-${file.name}`;
const { error: uploadError } = await supabase.storage
.from("need-images")
.upload(path, file);
if (!uploadError) {
await supabase
.from("need_images")
.insert({ need_id: need.id, storage_path: path, position: index });
}
}
redirect(`/needs/${need.id}`);
}
+115
View File
@@ -0,0 +1,115 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/dal";
import { OfferFormSchema, type OfferFormState } from "@/lib/validations/offers";
const MAX_IMAGES = 5;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 Mo
export async function createOffer(
_state: OfferFormState,
formData: FormData
): Promise<OfferFormState> {
const user = await verifySession();
const needId = formData.get("needId");
if (typeof needId !== "string" || !needId) {
return { message: "Besoin introuvable." };
}
const validatedFields = OfferFormSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
price: formData.get("price"),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { title, description, price } = validatedFields.data;
const parsedPrice = Number(price);
if (Number.isNaN(parsedPrice) || parsedPrice <= 0) {
return { message: "Prix invalide." };
}
const images = formData
.getAll("images")
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
if (images.length > MAX_IMAGES) {
return { message: `${MAX_IMAGES} images maximum.` };
}
if (images.some((file) => file.size > MAX_IMAGE_SIZE)) {
return { message: "Chaque image doit faire moins de 5 Mo." };
}
const supabase = await createClient();
const { data: offer, error } = await supabase
.from("offers")
.insert({
need_id: needId,
merchant_id: user.id,
title,
description,
price: parsedPrice,
})
.select("id")
.single();
if (error || !offer) {
return {
message:
"Impossible de créer l'offre (le besoin n'est peut-être plus ouvert, ou tu as déjà proposé une offre).",
};
}
for (const [index, file] of images.entries()) {
const path = `${user.id}/${offer.id}/${index}-${file.name}`;
const { error: uploadError } = await supabase.storage
.from("offer-images")
.upload(path, file);
if (!uploadError) {
await supabase
.from("offer_images")
.insert({ offer_id: offer.id, storage_path: path, position: index });
}
}
revalidatePath(`/needs/${needId}`);
redirect(`/needs/${needId}`);
}
export async function withdrawOffer(offerId: string, needId: string) {
await verifySession();
const supabase = await createClient();
await supabase
.from("offers")
.update({ status: "withdrawn" })
.eq("id", offerId)
.eq("status", "pending");
revalidatePath(`/needs/${needId}`);
}
export async function acceptOffer(offerId: string, needId: string) {
await verifySession();
const supabase = await createClient();
const { data: conversationId, error } = await supabase.rpc("accept_offer", {
p_offer_id: offerId,
});
if (error || !conversationId) {
revalidatePath(`/needs/${needId}`);
return;
}
redirect(`/messages/${conversationId}`);
}
+18
View File
@@ -0,0 +1,18 @@
import "server-only";
import { cache } from "react";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export const getCurrentUser = cache(async () => {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
if (!claims) return null;
return { id: claims.sub as string, email: claims.email as string | undefined };
});
export const verifySession = cache(async () => {
const user = await getCurrentUser();
if (!user) redirect("/auth/login");
return user;
});
+10
View File
@@ -0,0 +1,10 @@
import { createClient } from "@/lib/supabase/server";
export async function getCategories() {
const supabase = await createClient();
const { data } = await supabase
.from("categories")
.select("id, name, slug")
.order("name");
return data ?? [];
}
+482
View File
@@ -0,0 +1,482 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
}
public: {
Tables: {
categories: {
Row: {
created_at: string
icon: string | null
id: string
name: string
slug: string
}
Insert: {
created_at?: string
icon?: string | null
id?: string
name: string
slug: string
}
Update: {
created_at?: string
icon?: string | null
id?: string
name?: string
slug?: string
}
Relationships: []
}
conversations: {
Row: {
author_id: string
created_at: string
id: string
merchant_id: string
need_id: string
offer_id: string
}
Insert: {
author_id: string
created_at?: string
id?: string
merchant_id: string
need_id: string
offer_id: string
}
Update: {
author_id?: string
created_at?: string
id?: string
merchant_id?: string
need_id?: string
offer_id?: string
}
Relationships: [
{
foreignKeyName: "conversations_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "conversations_merchant_id_fkey"
columns: ["merchant_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "conversations_need_id_fkey"
columns: ["need_id"]
isOneToOne: false
referencedRelation: "needs"
referencedColumns: ["id"]
},
{
foreignKeyName: "conversations_offer_id_fkey"
columns: ["offer_id"]
isOneToOne: true
referencedRelation: "offers"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
body: string
conversation_id: string
created_at: string
id: string
sender_id: string
}
Insert: {
body: string
conversation_id: string
created_at?: string
id?: string
sender_id: string
}
Update: {
body?: string
conversation_id?: string
created_at?: string
id?: string
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
need_images: {
Row: {
created_at: string
id: string
need_id: string
position: number
storage_path: string
}
Insert: {
created_at?: string
id?: string
need_id: string
position?: number
storage_path: string
}
Update: {
created_at?: string
id?: string
need_id?: string
position?: number
storage_path?: string
}
Relationships: [
{
foreignKeyName: "need_images_need_id_fkey"
columns: ["need_id"]
isOneToOne: false
referencedRelation: "needs"
referencedColumns: ["id"]
},
]
}
needs: {
Row: {
accepted_offer_id: string | null
author_id: string
budget_max: number | null
budget_min: number | null
category_id: string | null
city: string
created_at: string
description: string
id: string
region: string | null
status: string
title: string
updated_at: string
}
Insert: {
accepted_offer_id?: string | null
author_id: string
budget_max?: number | null
budget_min?: number | null
category_id?: string | null
city: string
created_at?: string
description: string
id?: string
region?: string | null
status?: string
title: string
updated_at?: string
}
Update: {
accepted_offer_id?: string | null
author_id?: string
budget_max?: number | null
budget_min?: number | null
category_id?: string | null
city?: string
created_at?: string
description?: string
id?: string
region?: string | null
status?: string
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "needs_accepted_offer_fk"
columns: ["accepted_offer_id"]
isOneToOne: false
referencedRelation: "offers"
referencedColumns: ["id"]
},
{
foreignKeyName: "needs_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "needs_category_id_fkey"
columns: ["category_id"]
isOneToOne: false
referencedRelation: "categories"
referencedColumns: ["id"]
},
]
}
offer_images: {
Row: {
id: string
offer_id: string
position: number
storage_path: string
}
Insert: {
id?: string
offer_id: string
position?: number
storage_path: string
}
Update: {
id?: string
offer_id?: string
position?: number
storage_path?: string
}
Relationships: [
{
foreignKeyName: "offer_images_offer_id_fkey"
columns: ["offer_id"]
isOneToOne: false
referencedRelation: "offers"
referencedColumns: ["id"]
},
]
}
offers: {
Row: {
created_at: string
description: string
id: string
merchant_id: string
need_id: string
price: number
status: string
title: string
updated_at: string
}
Insert: {
created_at?: string
description: string
id?: string
merchant_id: string
need_id: string
price: number
status?: string
title: string
updated_at?: string
}
Update: {
created_at?: string
description?: string
id?: string
merchant_id?: string
need_id?: string
price?: number
status?: string
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "offers_merchant_id_fkey"
columns: ["merchant_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "offers_need_id_fkey"
columns: ["need_id"]
isOneToOne: false
referencedRelation: "needs"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
avatar_url: string | null
city: string | null
created_at: string
full_name: string | null
id: string
updated_at: string
}
Insert: {
avatar_url?: string | null
city?: string | null
created_at?: string
full_name?: string | null
id: string
updated_at?: string
}
Update: {
avatar_url?: string | null
city?: string | null
created_at?: string
full_name?: string | null
id?: string
updated_at?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
accept_offer: { Args: { p_offer_id: string }; Returns: string }
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
+19
View File
@@ -0,0 +1,19 @@
import "server-only";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
// Uses the service role key — bypasses RLS entirely. Only import this from
// trusted server-only code (e.g. the signup trigger fallback, admin tooling).
// Never expose this client or its key to the browser.
export function createAdminClient() {
return createSupabaseClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
}
+9
View File
@@ -0,0 +1,9 @@
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
+55
View File
@@ -0,0 +1,55 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PREFIXES = ["/dashboard", "/messages", "/needs/new"];
function isProtectedPath(pathname: string) {
if (PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
// /needs/[id]/edit
return /^\/needs\/[^/]+\/edit(\/|$)/.test(pathname);
}
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
);
},
},
}
);
// Do not run code between createServerClient and getClaims() — this
// refreshes the session token on every request, which is what keeps
// users from being randomly logged out.
const { data } = await supabase.auth.getClaims();
const user = data?.claims;
if (!user && isProtectedPath(request.nextUrl.pathname)) {
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
url.searchParams.set("redirect_to", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
return supabaseResponse;
}
+29
View File
@@ -0,0 +1,29 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component — safe to ignore since the
// proxy already refreshes the session on every request.
}
},
},
}
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+27
View File
@@ -0,0 +1,27 @@
import * as z from "zod";
export const SignupFormSchema = z.object({
fullName: z.string().trim().min(2, { message: "2 caractères minimum." }),
email: z.string().trim().email({ message: "Adresse email invalide." }),
password: z
.string()
.min(8, { message: "8 caractères minimum." })
.regex(/[a-zA-Z]/, { message: "Doit contenir au moins une lettre." })
.regex(/[0-9]/, { message: "Doit contenir au moins un chiffre." }),
});
export const LoginFormSchema = z.object({
email: z.string().trim().email({ message: "Adresse email invalide." }),
password: z.string().min(1, { message: "Mot de passe requis." }),
});
export type FormState =
| {
errors?: {
fullName?: string[];
email?: string[];
password?: string[];
};
message?: string;
}
| undefined;
+34
View File
@@ -0,0 +1,34 @@
import * as z from "zod";
export const NeedFormSchema = z.object({
title: z
.string()
.trim()
.min(5, { message: "5 caractères minimum." })
.max(120, { message: "120 caractères maximum." }),
description: z
.string()
.trim()
.min(20, { message: "20 caractères minimum." })
.max(2000, { message: "2000 caractères maximum." }),
categoryId: z.string().uuid({ message: "Choisis une catégorie." }),
city: z.string().trim().min(2, { message: "Ville requise." }),
region: z.string().trim().max(120).optional(),
budgetMin: z.string().optional(),
budgetMax: z.string().optional(),
});
export type NeedFormState =
| {
errors?: {
title?: string[];
description?: string[];
categoryId?: string[];
city?: string[];
region?: string[];
budgetMin?: string[];
budgetMax?: string[];
};
message?: string;
}
| undefined;
+26
View File
@@ -0,0 +1,26 @@
import * as z from "zod";
export const OfferFormSchema = z.object({
title: z
.string()
.trim()
.min(5, { message: "5 caractères minimum." })
.max(120, { message: "120 caractères maximum." }),
description: z
.string()
.trim()
.min(20, { message: "20 caractères minimum." })
.max(2000, { message: "2000 caractères maximum." }),
price: z.string().min(1, { message: "Prix requis." }),
});
export type OfferFormState =
| {
errors?: {
title?: string[];
description?: string[];
price?: string[];
};
message?: string;
}
| undefined;