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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user