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.
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useActionState } from "react";
|
|
import { createNeed } from "@/lib/actions/needs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ImageUploader } from "@/components/shared/image-uploader";
|
|
|
|
type Category = { id: string; name: string; slug: string };
|
|
|
|
export function NeedForm({ categories }: { categories: Category[] }) {
|
|
const [state, action, pending] = useActionState(createNeed, undefined);
|
|
|
|
return (
|
|
<form action={action} className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="title">Titre</Label>
|
|
<Input
|
|
id="title"
|
|
name="title"
|
|
placeholder="Ex: Besoin d'un plombier pour une fuite"
|
|
/>
|
|
{state?.errors?.title && (
|
|
<p className="text-sm text-destructive">{state.errors.title[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
name="description"
|
|
rows={5}
|
|
placeholder="Décris ton besoin en détail : contexte, urgence, contraintes..."
|
|
/>
|
|
{state?.errors?.description && (
|
|
<p className="text-sm text-destructive">
|
|
{state.errors.description[0]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="categoryId">Catégorie</Label>
|
|
<Select name="categoryId">
|
|
<SelectTrigger id="categoryId" className="w-full">
|
|
<SelectValue placeholder="Choisis une catégorie" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categories.map((category) => (
|
|
<SelectItem key={category.id} value={category.id}>
|
|
{category.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{state?.errors?.categoryId && (
|
|
<p className="text-sm text-destructive">
|
|
{state.errors.categoryId[0]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="city">Ville</Label>
|
|
<Input id="city" name="city" placeholder="Paris" />
|
|
{state?.errors?.city && (
|
|
<p className="text-sm text-destructive">{state.errors.city[0]}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="region">Région (optionnel)</Label>
|
|
<Input id="region" name="region" placeholder="Île-de-France" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="budgetMin">Budget min (optionnel, €)</Label>
|
|
<Input id="budgetMin" name="budgetMin" type="number" min="0" />
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="budgetMax">Budget max (optionnel, €)</Label>
|
|
<Input id="budgetMax" name="budgetMax" type="number" min="0" />
|
|
</div>
|
|
</div>
|
|
|
|
<ImageUploader name="images" maxFiles={5} />
|
|
|
|
{state?.message && (
|
|
<p className="text-sm text-destructive">{state.message}</p>
|
|
)}
|
|
|
|
<Button type="submit" disabled={pending} className="mt-2 w-fit">
|
|
{pending ? "Publication..." : "Publier le besoin"}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|