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.
156 lines
4.7 KiB
TypeScript
156 lines
4.7 KiB
TypeScript
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import Image from "next/image";
|
||
import { createClient } from "@/lib/supabase/server";
|
||
import { getCurrentUser } from "@/lib/dal";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { OfferForm } from "@/components/offers/offer-form";
|
||
import { OfferList } from "@/components/offers/offer-list";
|
||
|
||
export default async function NeedDetailPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ id: string }>;
|
||
}) {
|
||
const { id } = await params;
|
||
const supabase = await createClient();
|
||
const user = await getCurrentUser();
|
||
|
||
const { data: need } = await supabase
|
||
.from("needs")
|
||
.select(
|
||
"id, title, description, city, region, budget_min, budget_max, status, author_id, categories(name), need_images(storage_path, position)"
|
||
)
|
||
.eq("id", id)
|
||
.single();
|
||
|
||
if (!need) {
|
||
notFound();
|
||
}
|
||
|
||
const images = [...(need.need_images ?? [])].sort(
|
||
(a, b) => a.position - b.position
|
||
);
|
||
const isOwner = user?.id === need.author_id;
|
||
const category = Array.isArray(need.categories)
|
||
? need.categories[0]
|
||
: need.categories;
|
||
|
||
const { data: offers } = await supabase
|
||
.from("offers")
|
||
.select(
|
||
"id, title, description, price, status, merchant_id, profiles(full_name), offer_images(storage_path, position)"
|
||
)
|
||
.eq("need_id", id)
|
||
.order("created_at", { ascending: true });
|
||
|
||
const offerList = offers ?? [];
|
||
const myOffer = user ? offerList.find((o) => o.merchant_id === user.id) : undefined;
|
||
|
||
function getOfferImageUrl(path: string) {
|
||
return supabase.storage.from("offer-images").getPublicUrl(path).data
|
||
.publicUrl;
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
|
||
<div className="flex items-center gap-2">
|
||
{category?.name && <Badge variant="secondary">{category.name}</Badge>}
|
||
<Badge
|
||
variant={need.status === "open" ? "default" : "outline"}
|
||
>
|
||
{need.status}
|
||
</Badge>
|
||
</div>
|
||
<h1 className="mt-3 text-2xl font-semibold">{need.title}</h1>
|
||
<p className="mt-1 text-muted-foreground">
|
||
{need.city}
|
||
{need.region ? `, ${need.region}` : ""}
|
||
</p>
|
||
{(need.budget_min || need.budget_max) && (
|
||
<p className="mt-1 text-sm">
|
||
Budget : {need.budget_min ?? "?"}€ – {need.budget_max ?? "?"}€
|
||
</p>
|
||
)}
|
||
|
||
{images.length > 0 && (
|
||
<div className="mt-6 flex flex-wrap gap-3">
|
||
{images.map((img) => {
|
||
const { data } = supabase.storage
|
||
.from("need-images")
|
||
.getPublicUrl(img.storage_path);
|
||
return (
|
||
<div
|
||
key={img.storage_path}
|
||
className="relative h-32 w-32 overflow-hidden rounded-md border"
|
||
>
|
||
<Image
|
||
src={data.publicUrl}
|
||
alt={need.title}
|
||
fill
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<p className="mt-6 whitespace-pre-wrap">{need.description}</p>
|
||
|
||
<div className="mt-10 border-t pt-6">
|
||
{isOwner ? (
|
||
<>
|
||
<h2 className="text-lg font-semibold">Offres reçues</h2>
|
||
<OfferList
|
||
offers={offerList}
|
||
needId={need.id}
|
||
needStatus={need.status}
|
||
isOwner
|
||
getImageUrl={getOfferImageUrl}
|
||
/>
|
||
</>
|
||
) : !user ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
<Link href="/auth/login" className="underline">
|
||
Connecte-toi
|
||
</Link>{" "}
|
||
pour proposer une offre sur ce besoin.
|
||
</p>
|
||
) : myOffer ? (
|
||
<>
|
||
<h2 className="text-lg font-semibold">Ton offre</h2>
|
||
<OfferList
|
||
offers={[myOffer]}
|
||
needId={need.id}
|
||
needStatus={need.status}
|
||
isOwner={false}
|
||
getImageUrl={getOfferImageUrl}
|
||
/>
|
||
</>
|
||
) : need.status === "open" ? (
|
||
<>
|
||
<h2 className="text-lg font-semibold">Proposer une offre</h2>
|
||
<div className="mt-4">
|
||
<OfferForm needId={need.id} />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
Ce besoin n'accepte plus d'offres.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{isOwner && (
|
||
<p className="mt-4 text-xs text-muted-foreground">
|
||
<Button asChild variant="link" className="h-auto p-0 text-xs">
|
||
<Link href="/messages">Voir mes conversations</Link>
|
||
</Button>
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|