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
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
type NeedCardData = {
id: string;
title: string;
city: string;
region: string | null;
budget_min: number | null;
budget_max: number | null;
categories: { name: string } | { name: string }[] | null;
need_images: { storage_path: string; position: number }[] | null;
};
export function NeedCard({
need,
getImageUrl,
}: {
need: NeedCardData;
getImageUrl: (path: string) => string;
}) {
const category = Array.isArray(need.categories)
? need.categories[0]
: need.categories;
const images = [...(need.need_images ?? [])].sort(
(a, b) => a.position - b.position
);
const cover = images[0];
return (
<Link href={`/needs/${need.id}`}>
<Card className="h-full transition-shadow hover:shadow-md">
{cover && (
<div className="relative h-40 w-full overflow-hidden rounded-t-xl">
<Image
src={getImageUrl(cover.storage_path)}
alt={need.title}
fill
className="object-cover"
/>
</div>
)}
<CardContent className="flex flex-col gap-2 pt-4">
{category?.name && (
<Badge variant="secondary" className="w-fit">
{category.name}
</Badge>
)}
<h3 className="line-clamp-2 font-medium">{need.title}</h3>
<p className="text-sm text-muted-foreground">
{need.city}
{need.region ? `, ${need.region}` : ""}
</p>
{(need.budget_min || need.budget_max) && (
<p className="text-sm">
{need.budget_min ?? "?"} {need.budget_max ?? "?"}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Category = { id: string; name: string; slug: string };
export function NeedFilters({
categories,
defaultValues,
}: {
categories: Category[];
defaultValues: { category?: string; city?: string; q?: string };
}) {
return (
<form className="flex flex-wrap gap-3" method="get">
<Input
name="q"
placeholder="Rechercher un mot-clé..."
defaultValue={defaultValues.q}
className="w-full sm:w-56"
/>
<Input
name="city"
placeholder="Ville"
defaultValue={defaultValues.city}
className="w-full sm:w-40"
/>
<Select name="category" defaultValue={defaultValues.category}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Toutes catégories" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.slug}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit" variant="secondary">
Filtrer
</Button>
</form>
);
}
+109
View File
@@ -0,0 +1,109 @@
"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>
);
}