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.
119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
import Link from "next/link";
|
|
import { createClient } from "@/lib/supabase/server";
|
|
import { getCategories } from "@/lib/data/categories";
|
|
import { NeedFilters } from "@/components/needs/need-filters";
|
|
import { NeedCard } from "@/components/needs/need-card";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
export default async function NeedsFeedPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{
|
|
q?: string;
|
|
city?: string;
|
|
category?: string;
|
|
page?: string;
|
|
}>;
|
|
}) {
|
|
const { q, city, category, page } = await searchParams;
|
|
const currentPage = Math.max(1, Number(page) || 1);
|
|
const from = (currentPage - 1) * PAGE_SIZE;
|
|
const to = from + PAGE_SIZE - 1;
|
|
|
|
const supabase = await createClient();
|
|
const categories = await getCategories();
|
|
|
|
let query = supabase
|
|
.from("needs")
|
|
.select(
|
|
"id, title, city, region, budget_min, budget_max, created_at, categories(name), need_images(storage_path, position)",
|
|
{ count: "exact" }
|
|
)
|
|
.eq("status", "open")
|
|
.order("created_at", { ascending: false })
|
|
.range(from, to);
|
|
|
|
if (q) {
|
|
query = query.ilike("title", `%${q}%`);
|
|
}
|
|
if (city) {
|
|
query = query.ilike("city", `%${city}%`);
|
|
}
|
|
if (category) {
|
|
const matchedCategory = categories.find((c) => c.slug === category);
|
|
if (matchedCategory) {
|
|
query = query.eq("category_id", matchedCategory.id);
|
|
}
|
|
}
|
|
|
|
const { data: needs, count } = await query;
|
|
const total = count ?? 0;
|
|
const hasNextPage = to + 1 < total;
|
|
|
|
function getImageUrl(path: string) {
|
|
return supabase.storage.from("need-images").getPublicUrl(path).data
|
|
.publicUrl;
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-10">
|
|
<div className="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
|
<h1 className="text-2xl font-semibold">Besoins</h1>
|
|
<Button asChild>
|
|
<Link href="/needs/new">Publier un besoin</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
<NeedFilters
|
|
categories={categories}
|
|
defaultValues={{ category, city, q }}
|
|
/>
|
|
</div>
|
|
|
|
{needs && needs.length > 0 ? (
|
|
<div className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{needs.map((need) => (
|
|
<NeedCard key={need.id} need={need} getImageUrl={getImageUrl} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="mt-12 text-center text-muted-foreground">
|
|
Aucun besoin ne correspond à ta recherche.
|
|
</p>
|
|
)}
|
|
|
|
{(currentPage > 1 || hasNextPage) && (
|
|
<div className="mt-8 flex justify-center gap-2">
|
|
{currentPage > 1 && (
|
|
<Button asChild variant="outline">
|
|
<Link
|
|
href={{
|
|
pathname: "/needs",
|
|
query: { q, city, category, page: currentPage - 1 },
|
|
}}
|
|
>
|
|
Précédent
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
{hasNextPage && (
|
|
<Button asChild variant="outline">
|
|
<Link
|
|
href={{
|
|
pathname: "/needs",
|
|
query: { q, city, category, page: currentPage + 1 },
|
|
}}
|
|
>
|
|
Suivant
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|