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
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useActionState } from "react";
import { createOffer } from "@/lib/actions/offers";
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 { ImageUploader } from "@/components/shared/image-uploader";
export function OfferForm({ needId }: { needId: string }) {
const [state, action, pending] = useActionState(createOffer, undefined);
return (
<form action={action} className="flex flex-col gap-4">
<input type="hidden" name="needId" value={needId} />
<div className="flex flex-col gap-2">
<Label htmlFor="offer-title">Titre de l&apos;offre</Label>
<Input
id="offer-title"
name="title"
placeholder="Ex: Intervention sous 48h avec garantie"
/>
{state?.errors?.title && (
<p className="text-sm text-destructive">{state.errors.title[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="offer-description">Description</Label>
<Textarea
id="offer-description"
name="description"
rows={4}
placeholder="Détaille ce que tu proposes, tes conditions, ton expérience..."
/>
{state?.errors?.description && (
<p className="text-sm text-destructive">
{state.errors.description[0]}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="offer-price">Prix ()</Label>
<Input id="offer-price" name="price" type="number" min="0" step="0.01" />
{state?.errors?.price && (
<p className="text-sm text-destructive">{state.errors.price[0]}</p>
)}
</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 ? "Envoi..." : "Envoyer l'offre"}
</Button>
</form>
);
}
+109
View File
@@ -0,0 +1,109 @@
import Image from "next/image";
import { acceptOffer } from "@/lib/actions/offers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Offer = {
id: string;
title: string;
description: string;
price: number;
status: string;
merchant_id: string;
profiles: { full_name: string | null } | { full_name: string | null }[] | null;
offer_images: { storage_path: string; position: number }[] | null;
};
const STATUS_LABEL: Record<string, string> = {
pending: "En attente",
accepted: "Acceptée",
rejected: "Refusée",
withdrawn: "Retirée",
};
export function OfferList({
offers,
needId,
needStatus,
isOwner,
getImageUrl,
}: {
offers: Offer[];
needId: string;
needStatus: string;
isOwner: boolean;
getImageUrl: (path: string) => string;
}) {
if (offers.length === 0) {
return (
<p className="mt-4 text-sm text-muted-foreground">
Aucune offre reçue pour le moment.
</p>
);
}
return (
<div className="mt-4 flex flex-col gap-4">
{offers.map((offer) => {
const merchant = Array.isArray(offer.profiles)
? offer.profiles[0]
: offer.profiles;
const images = [...(offer.offer_images ?? [])].sort(
(a, b) => a.position - b.position
);
return (
<Card key={offer.id}>
<CardContent className="flex flex-col gap-2 pt-4">
<div className="flex items-center justify-between">
<span className="font-medium">
{merchant?.full_name ?? "Commerçant"}
</span>
<Badge
variant={offer.status === "accepted" ? "default" : "outline"}
>
{STATUS_LABEL[offer.status] ?? offer.status}
</Badge>
</div>
<h4 className="font-medium">{offer.title}</h4>
<p className="text-sm text-muted-foreground">
{offer.description}
</p>
<p className="font-semibold">{offer.price} </p>
{images.length > 0 && (
<div className="mt-1 flex flex-wrap gap-2">
{images.map((img) => (
<div
key={img.storage_path}
className="relative h-16 w-16 overflow-hidden rounded-md border"
>
<Image
src={getImageUrl(img.storage_path)}
alt={offer.title}
fill
className="object-cover"
/>
</div>
))}
</div>
)}
{isOwner && needStatus === "open" && offer.status === "pending" && (
<form
action={acceptOffer.bind(null, offer.id, needId)}
className="mt-2"
>
<Button type="submit" size="sm">
Accepter cette offre
</Button>
</form>
)}
</CardContent>
</Card>
);
})}
</div>
);
}