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.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|