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
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.git
.env*
*.md
supabase/sql
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.mcp.json
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+58
View File
@@ -0,0 +1,58 @@
# ============================================
# Stage 1: Dependencies
# ============================================
ARG NODE_VERSION=24.13.0-slim
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
# ============================================
# Stage 2: Build (standalone output)
# ============================================
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
# Variables publiques inlinées dans le bundle JS au build — à fournir comme
# "Build Variables" dans Coolify (Application > Environment Variables).
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
RUN npm run build
# ============================================
# Stage 3: Run
# ============================================
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+17
View File
@@ -0,0 +1,17 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function AuthCodeErrorPage() {
return (
<div className="flex flex-col items-center justify-center gap-4 px-4 py-20 text-center sm:px-6 sm:py-32">
<h1 className="text-2xl font-semibold">Lien invalide ou expiré</h1>
<p className="max-w-sm text-muted-foreground">
Ce lien de confirmation n&apos;est plus valide. Réessaie de te
connecter ou de t&apos;inscrire.
</p>
<Button asChild>
<Link href="/auth/login">Retour à la connexion</Link>
</Button>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { type EmailOtpType } from "@supabase/supabase-js";
import { type NextRequest } from "next/server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/dashboard/my-needs";
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({ type, token_hash });
if (!error) {
redirect(next);
}
}
redirect("/auth/auth-code-error");
}
+9
View File
@@ -0,0 +1,9 @@
import { LoginForm } from "@/components/auth/login-form";
export default function LoginPage() {
return (
<div className="flex items-center justify-center px-4 py-16 sm:px-6 sm:py-24">
<LoginForm />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { SignupForm } from "@/components/auth/signup-form";
export default function SignupPage() {
return (
<div className="flex items-center justify-center px-4 py-16 sm:px-6 sm:py-24">
<SignupForm />
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { verifySession } from "@/lib/dal";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
await verifySession();
return (
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-10">
{children}
</main>
);
}
+69
View File
@@ -0,0 +1,69 @@
import Link from "next/link";
import { verifySession } from "@/lib/dal";
import { createClient } from "@/lib/supabase/server";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
const STATUS_LABEL: Record<string, string> = {
open: "Ouvert",
closed: "Fermé",
fulfilled: "Pourvu",
};
export default async function MyNeedsPage() {
const user = await verifySession();
const supabase = await createClient();
const { data: needs } = await supabase
.from("needs")
.select("id, title, city, status, created_at, categories(name)")
.eq("author_id", user.id)
.order("created_at", { ascending: false });
return (
<div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Mes besoins</h1>
<Button asChild>
<Link href="/needs/new">Nouveau besoin</Link>
</Button>
</div>
{!needs || needs.length === 0 ? (
<p className="mt-6 text-muted-foreground">
Tu n&apos;as pas encore publié de besoin.
</p>
) : (
<div className="mt-6 flex flex-col gap-3">
{needs.map((need) => {
const category = Array.isArray(need.categories)
? need.categories[0]
: need.categories;
return (
<Link key={need.id} href={`/needs/${need.id}`}>
<Card className="transition-shadow hover:shadow-md">
<CardContent className="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">{need.title}</p>
<p className="text-sm text-muted-foreground">
{need.city}
{category?.name ? ` · ${category.name}` : ""}
</p>
</div>
<Badge
className="w-fit"
variant={need.status === "open" ? "default" : "outline"}
>
{STATUS_LABEL[need.status] ?? need.status}
</Badge>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
import { verifySession } from "@/lib/dal";
import { createClient } from "@/lib/supabase/server";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
const STATUS_LABEL: Record<string, string> = {
pending: "En attente",
accepted: "Acceptée",
rejected: "Refusée",
withdrawn: "Retirée",
};
export default async function MyOffersPage() {
const user = await verifySession();
const supabase = await createClient();
const { data: offers } = await supabase
.from("offers")
.select("id, title, price, status, created_at, need_id, needs(title, city)")
.eq("merchant_id", user.id)
.order("created_at", { ascending: false });
return (
<div>
<h1 className="text-2xl font-semibold">Mes offres</h1>
{!offers || offers.length === 0 ? (
<p className="mt-6 text-muted-foreground">
Tu n&apos;as pas encore envoyé d&apos;offre.
</p>
) : (
<div className="mt-6 flex flex-col gap-3">
{offers.map((offer) => {
const need = Array.isArray(offer.needs)
? offer.needs[0]
: offer.needs;
return (
<Link key={offer.id} href={`/needs/${offer.need_id}`}>
<Card className="transition-shadow hover:shadow-md">
<CardContent className="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">{offer.title}</p>
<p className="text-sm text-muted-foreground">
{need?.title} · {need?.city} · {offer.price}
</p>
</div>
<Badge
className="w-fit"
variant={
offer.status === "accepted" ? "default" : "outline"
}
>
{STATUS_LABEL[offer.status] ?? offer.status}
</Badge>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-geist-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { getCurrentUser } from "@/lib/dal";
import { Navbar } from "@/components/layout/navbar";
import { Footer } from "@/components/layout/footer";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "WaMarket",
description: "Postez un besoin, recevez des offres de commerçants près de chez vous.",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const user = await getCurrentUser();
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col">
<Navbar user={user} />
<div className="flex-1">{children}</div>
<Footer />
<Toaster />
</body>
</html>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { verifySession } from "@/lib/dal";
import { createClient } from "@/lib/supabase/server";
import { ChatWindow } from "@/components/messages/chat-window";
export default async function ConversationPage({
params,
}: {
params: Promise<{ conversationId: string }>;
}) {
const user = await verifySession();
const { conversationId } = await params;
const supabase = await createClient();
const { data: conversation } = await supabase
.from("conversations")
.select(
"id, author_id, merchant_id, needs(title), author:profiles!conversations_author_id_fkey(full_name), merchant:profiles!conversations_merchant_id_fkey(full_name)"
)
.eq("id", conversationId)
.single();
if (!conversation) {
notFound();
}
const { data: messages } = await supabase
.from("messages")
.select("id, conversation_id, sender_id, body, created_at")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: true });
const isAuthor = conversation.author_id === user.id;
const other = isAuthor ? conversation.merchant : conversation.author;
const otherName = Array.isArray(other) ? other[0]?.full_name : other?.full_name;
const need = Array.isArray(conversation.needs)
? conversation.needs[0]
: conversation.needs;
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<Link href="/messages" className="text-sm text-muted-foreground underline">
Toutes les conversations
</Link>
<h1 className="mt-2 text-2xl font-semibold">{otherName ?? "Utilisateur"}</h1>
<p className="text-sm text-muted-foreground">{need?.title}</p>
<div className="mt-6">
<ChatWindow
conversationId={conversationId}
currentUserId={user.id}
initialMessages={messages ?? []}
/>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import { verifySession } from "@/lib/dal";
import { createClient } from "@/lib/supabase/server";
import { Card, CardContent } from "@/components/ui/card";
export default async function MessagesPage() {
const user = await verifySession();
const supabase = await createClient();
const { data: conversations } = await supabase
.from("conversations")
.select(
"id, created_at, author_id, merchant_id, needs(title), author:profiles!conversations_author_id_fkey(full_name), merchant:profiles!conversations_merchant_id_fkey(full_name)"
)
.order("created_at", { ascending: false });
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<h1 className="text-2xl font-semibold">Messages</h1>
{!conversations || conversations.length === 0 ? (
<p className="mt-6 text-muted-foreground">
Aucune conversation pour le moment. Elles apparaissent ici une fois
qu&apos;une offre est acceptée.
</p>
) : (
<div className="mt-6 flex flex-col gap-3">
{conversations.map((conv) => {
const isAuthor = conv.author_id === user.id;
const other = isAuthor ? conv.merchant : conv.author;
const otherName = Array.isArray(other)
? other[0]?.full_name
: other?.full_name;
const need = Array.isArray(conv.needs) ? conv.needs[0] : conv.needs;
return (
<Link key={conv.id} href={`/messages/${conv.id}`}>
<Card className="transition-shadow hover:shadow-md">
<CardContent className="min-w-0 py-4">
<p className="truncate font-medium">
{otherName ?? "Utilisateur"}
</p>
<p className="truncate text-sm text-muted-foreground">
{need?.title}
</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function Loading() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<div className="h-5 w-24 animate-pulse rounded-md bg-muted" />
<div className="mt-3 h-8 w-3/4 animate-pulse rounded-md bg-muted" />
<div className="mt-2 h-5 w-32 animate-pulse rounded-md bg-muted" />
<div className="mt-6 h-32 w-32 animate-pulse rounded-md bg-muted" />
<div className="mt-6 h-24 w-full animate-pulse rounded-md bg-muted" />
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
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&apos;accepte plus d&apos;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>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function Loading() {
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-10">
<div className="h-8 w-40 animate-pulse rounded-md bg-muted" />
<div className="mt-6 h-10 w-full animate-pulse rounded-md bg-muted" />
<div className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-56 animate-pulse rounded-xl bg-muted" />
))}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function Loading() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<div className="h-8 w-56 animate-pulse rounded-md bg-muted" />
<div className="mt-2 h-5 w-72 animate-pulse rounded-md bg-muted" />
<div className="mt-8 flex flex-col gap-5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-10 w-full animate-pulse rounded-md bg-muted" />
))}
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { verifySession } from "@/lib/dal";
import { getCategories } from "@/lib/data/categories";
import { NeedForm } from "@/components/needs/need-form";
export default async function NewNeedPage() {
await verifySession();
const categories = await getCategories();
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<h1 className="text-2xl font-semibold">Publier un besoin</h1>
<p className="mt-1 text-muted-foreground">
Décris ce que tu cherches, les commerçants pourront te faire des
offres.
</p>
<div className="mt-8">
<NeedForm categories={categories} />
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function Home() {
return (
<div className="flex flex-col items-center justify-center gap-6 bg-muted/30 px-4 py-20 text-center sm:px-6 sm:py-32">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
WaMarket
</h1>
<p className="max-w-md text-base text-muted-foreground sm:text-lg">
Postez un besoin, recevez des offres de commerçants près de chez vous.
</p>
<div className="flex w-full max-w-xs flex-col gap-3 sm:w-auto sm:flex-row">
<Button asChild size="lg" className="w-full sm:w-auto">
<Link href="/needs/new">Publier un besoin</Link>
</Button>
<Button asChild size="lg" variant="outline" className="w-full sm:w-auto">
<Link href="/needs">Voir les besoins</Link>
</Button>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { login } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function LoginForm() {
const [state, action, pending] = useActionState(login, undefined);
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Se connecter</CardTitle>
<CardDescription>Accède à tes besoins et tes offres.</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="vous@exemple.com" />
{state?.errors?.email && (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" name="password" type="password" />
{state?.errors?.password && (
<p className="text-sm text-destructive">
{state.errors.password[0]}
</p>
)}
</div>
{state?.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={pending} className="mt-2">
{pending ? "Connexion..." : "Se connecter"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<Link href="/auth/signup" className="underline">
S&apos;inscrire
</Link>
</p>
</CardContent>
</Card>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { signup } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function SignupForm() {
const [state, action, pending] = useActionState(signup, undefined);
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Créer un compte</CardTitle>
<CardDescription>
Postez un besoin ou répondez aux besoins des autres.
</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="fullName">Nom complet</Label>
<Input id="fullName" name="fullName" placeholder="Jean Dupont" />
{state?.errors?.fullName && (
<p className="text-sm text-destructive">
{state.errors.fullName[0]}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="vous@exemple.com" />
{state?.errors?.email && (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" name="password" type="password" />
{state?.errors?.password && (
<ul className="text-sm text-destructive">
{state.errors.password.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
{state?.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={pending} className="mt-2">
{pending ? "Création..." : "Créer mon compte"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Déjà un compte ?{" "}
<Link href="/auth/login" className="underline">
Se connecter
</Link>
</p>
</CardContent>
</Card>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function Footer() {
return (
<footer className="border-t py-6">
<div className="mx-auto max-w-5xl px-4 text-center text-sm text-muted-foreground sm:px-6">
© {new Date().getFullYear()} WaMarket Postez un besoin, recevez des
offres.
</div>
</footer>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X } from "lucide-react";
import { logout } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type NavUser = { id: string; email?: string } | null;
const loggedInLinks = [
{ href: "/needs", label: "Besoins" },
{ href: "/dashboard/my-needs", label: "Mes besoins" },
{ href: "/dashboard/my-offers", label: "Mes offres" },
{ href: "/messages", label: "Messages" },
];
const loggedOutLinks = [{ href: "/needs", label: "Besoins" }];
export function Navbar({ user }: { user: NavUser }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const links = user ? loggedInLinks : loggedOutLinks;
return (
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4 sm:px-6">
<Link href="/" className="font-semibold tracking-tight" onClick={() => setOpen(false)}>
WaMarket
</Link>
<nav className="hidden items-center gap-6 text-sm font-medium md:flex">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"text-muted-foreground transition-colors hover:text-foreground",
pathname === link.href && "text-foreground"
)}
>
{link.label}
</Link>
))}
</nav>
<div className="hidden items-center gap-2 md:flex">
{user ? (
<>
<Button asChild size="sm">
<Link href="/needs/new">Publier un besoin</Link>
</Button>
<form action={logout}>
<Button variant="ghost" size="sm" type="submit">
Se déconnecter
</Button>
</form>
</>
) : (
<>
<Button asChild variant="ghost" size="sm">
<Link href="/auth/login">Se connecter</Link>
</Button>
<Button asChild size="sm">
<Link href="/auth/signup">S&apos;inscrire</Link>
</Button>
</>
)}
</div>
<button
type="button"
aria-label="Ouvrir le menu"
className="flex h-9 w-9 items-center justify-center rounded-md md:hidden"
onClick={() => setOpen((v) => !v)}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{open && (
<div className="border-t px-4 py-4 md:hidden">
<nav className="flex flex-col gap-3 text-sm font-medium">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
>
{link.label}
</Link>
))}
</nav>
<div className="mt-4 flex flex-col gap-2">
{user ? (
<>
<Button asChild size="sm" onClick={() => setOpen(false)}>
<Link href="/needs/new">Publier un besoin</Link>
</Button>
<form action={logout}>
<Button variant="ghost" size="sm" type="submit" className="w-full">
Se déconnecter
</Button>
</form>
</>
) : (
<>
<Button asChild variant="ghost" size="sm" onClick={() => setOpen(false)}>
<Link href="/auth/login">Se connecter</Link>
</Button>
<Button asChild size="sm" onClick={() => setOpen(false)}>
<Link href="/auth/signup">S&apos;inscrire</Link>
</Button>
</>
)}
</div>
</div>
)}
</header>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { sendMessage } from "@/lib/actions/messages";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Message = {
id: string;
conversation_id: string;
sender_id: string;
body: string;
created_at: string;
};
export function ChatWindow({
conversationId,
currentUserId,
initialMessages,
}: {
conversationId: string;
currentUserId: string;
initialMessages: Message[];
}) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel(`conversation:${conversationId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `conversation_id=eq.${conversationId}`,
},
(payload) => {
const newMessage = payload.new as Message;
setMessages((current) => {
if (current.some((m) => m.id === newMessage.id)) return current;
return [...current, newMessage];
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [conversationId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessageWithConversation = sendMessage.bind(null, conversationId);
return (
<div className="flex h-[60vh] flex-col rounded-lg border sm:h-[70vh]">
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((message) => {
const isMine = message.sender_id === currentUserId;
return (
<div
key={message.id}
className={cn("flex", isMine ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[75%] rounded-lg px-3 py-2 text-sm",
isMine
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.body}
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
<form
action={async (formData) => {
await sendMessageWithConversation(formData);
(document.getElementById("chat-input") as HTMLInputElement).value =
"";
}}
className="flex gap-2 border-t p-3"
>
<Input
id="chat-input"
name="body"
placeholder="Écris un message..."
autoComplete="off"
className="flex-1"
/>
<Button type="submit">Envoyer</Button>
</form>
</div>
);
}
+65
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+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>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export function ImageUploader({
name,
maxFiles = 5,
}: {
name: string;
maxFiles?: number;
}) {
const [previews, setPreviews] = useState<string[]>([]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []).slice(0, maxFiles);
setPreviews((old) => {
old.forEach((url) => URL.revokeObjectURL(url));
return files.map((file) => URL.createObjectURL(file));
});
}
return (
<div className="flex flex-col gap-2">
<Label htmlFor={name}>Photos (optionnel, {maxFiles} max)</Label>
<Input
id={name}
name={name}
type="file"
accept="image/*"
multiple
onChange={handleChange}
/>
{previews.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{previews.map((src, i) => (
<div
key={src}
className="relative h-20 w-20 overflow-hidden rounded-md border"
>
<Image
src={src}
alt={`Aperçu ${i + 1}`}
fill
className="object-cover"
unoptimized
/>
</div>
))}
</div>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+167
View File
@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type { Label as LabelPrimitive } from "radix-ui"
import { Slot } from "radix-ui"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot.Root
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-sm text-destructive", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+40
View File
@@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import {
LoginFormSchema,
SignupFormSchema,
type FormState,
} from "@/lib/validations/auth";
export async function signup(
_state: FormState,
formData: FormData
): Promise<FormState> {
const validatedFields = SignupFormSchema.safeParse({
fullName: formData.get("fullName"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { fullName, email, password } = validatedFields.data;
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { full_name: fullName } },
});
if (error) {
return { message: error.message };
}
redirect("/dashboard/my-needs");
}
export async function login(
_state: FormState,
formData: FormData
): Promise<FormState> {
const validatedFields = LoginFormSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { email, password } = validatedFields.data;
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { message: "Email ou mot de passe incorrect." };
}
redirect("/dashboard/my-needs");
}
export async function logout() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/auth/login");
}
+20
View File
@@ -0,0 +1,20 @@
"use server";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/dal";
export async function sendMessage(conversationId: string, formData: FormData) {
const user = await verifySession();
const body = formData.get("body");
if (typeof body !== "string" || !body.trim()) {
return;
}
const supabase = await createClient();
await supabase.from("messages").insert({
conversation_id: conversationId,
sender_id: user.id,
body: body.trim(),
});
}
+97
View File
@@ -0,0 +1,97 @@
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/dal";
import { NeedFormSchema, type NeedFormState } from "@/lib/validations/needs";
const MAX_IMAGES = 5;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 Mo
export async function createNeed(
_state: NeedFormState,
formData: FormData
): Promise<NeedFormState> {
const user = await verifySession();
const validatedFields = NeedFormSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
categoryId: formData.get("categoryId"),
city: formData.get("city"),
region: formData.get("region") || undefined,
budgetMin: formData.get("budgetMin") || undefined,
budgetMax: formData.get("budgetMax") || undefined,
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { title, description, categoryId, city, region, budgetMin, budgetMax } =
validatedFields.data;
const parsedBudgetMin = budgetMin ? Number(budgetMin) : null;
const parsedBudgetMax = budgetMax ? Number(budgetMax) : null;
if (
(budgetMin && Number.isNaN(parsedBudgetMin)) ||
(budgetMax && Number.isNaN(parsedBudgetMax))
) {
return { message: "Budget invalide." };
}
if (
parsedBudgetMin !== null &&
parsedBudgetMax !== null &&
parsedBudgetMin > parsedBudgetMax
) {
return { message: "Le budget minimum doit être inférieur au maximum." };
}
const images = formData
.getAll("images")
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
if (images.length > MAX_IMAGES) {
return { message: `${MAX_IMAGES} images maximum.` };
}
if (images.some((file) => file.size > MAX_IMAGE_SIZE)) {
return { message: "Chaque image doit faire moins de 5 Mo." };
}
const supabase = await createClient();
const { data: need, error } = await supabase
.from("needs")
.insert({
author_id: user.id,
category_id: categoryId,
title,
description,
city,
region: region || null,
budget_min: parsedBudgetMin,
budget_max: parsedBudgetMax,
})
.select("id")
.single();
if (error || !need) {
return { message: "Une erreur est survenue lors de la création du besoin." };
}
for (const [index, file] of images.entries()) {
const path = `${user.id}/${need.id}/${index}-${file.name}`;
const { error: uploadError } = await supabase.storage
.from("need-images")
.upload(path, file);
if (!uploadError) {
await supabase
.from("need_images")
.insert({ need_id: need.id, storage_path: path, position: index });
}
}
redirect(`/needs/${need.id}`);
}
+115
View File
@@ -0,0 +1,115 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/dal";
import { OfferFormSchema, type OfferFormState } from "@/lib/validations/offers";
const MAX_IMAGES = 5;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 Mo
export async function createOffer(
_state: OfferFormState,
formData: FormData
): Promise<OfferFormState> {
const user = await verifySession();
const needId = formData.get("needId");
if (typeof needId !== "string" || !needId) {
return { message: "Besoin introuvable." };
}
const validatedFields = OfferFormSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
price: formData.get("price"),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { title, description, price } = validatedFields.data;
const parsedPrice = Number(price);
if (Number.isNaN(parsedPrice) || parsedPrice <= 0) {
return { message: "Prix invalide." };
}
const images = formData
.getAll("images")
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
if (images.length > MAX_IMAGES) {
return { message: `${MAX_IMAGES} images maximum.` };
}
if (images.some((file) => file.size > MAX_IMAGE_SIZE)) {
return { message: "Chaque image doit faire moins de 5 Mo." };
}
const supabase = await createClient();
const { data: offer, error } = await supabase
.from("offers")
.insert({
need_id: needId,
merchant_id: user.id,
title,
description,
price: parsedPrice,
})
.select("id")
.single();
if (error || !offer) {
return {
message:
"Impossible de créer l'offre (le besoin n'est peut-être plus ouvert, ou tu as déjà proposé une offre).",
};
}
for (const [index, file] of images.entries()) {
const path = `${user.id}/${offer.id}/${index}-${file.name}`;
const { error: uploadError } = await supabase.storage
.from("offer-images")
.upload(path, file);
if (!uploadError) {
await supabase
.from("offer_images")
.insert({ offer_id: offer.id, storage_path: path, position: index });
}
}
revalidatePath(`/needs/${needId}`);
redirect(`/needs/${needId}`);
}
export async function withdrawOffer(offerId: string, needId: string) {
await verifySession();
const supabase = await createClient();
await supabase
.from("offers")
.update({ status: "withdrawn" })
.eq("id", offerId)
.eq("status", "pending");
revalidatePath(`/needs/${needId}`);
}
export async function acceptOffer(offerId: string, needId: string) {
await verifySession();
const supabase = await createClient();
const { data: conversationId, error } = await supabase.rpc("accept_offer", {
p_offer_id: offerId,
});
if (error || !conversationId) {
revalidatePath(`/needs/${needId}`);
return;
}
redirect(`/messages/${conversationId}`);
}
+18
View File
@@ -0,0 +1,18 @@
import "server-only";
import { cache } from "react";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export const getCurrentUser = cache(async () => {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
if (!claims) return null;
return { id: claims.sub as string, email: claims.email as string | undefined };
});
export const verifySession = cache(async () => {
const user = await getCurrentUser();
if (!user) redirect("/auth/login");
return user;
});
+10
View File
@@ -0,0 +1,10 @@
import { createClient } from "@/lib/supabase/server";
export async function getCategories() {
const supabase = await createClient();
const { data } = await supabase
.from("categories")
.select("id, name, slug")
.order("name");
return data ?? [];
}
+482
View File
@@ -0,0 +1,482 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
}
public: {
Tables: {
categories: {
Row: {
created_at: string
icon: string | null
id: string
name: string
slug: string
}
Insert: {
created_at?: string
icon?: string | null
id?: string
name: string
slug: string
}
Update: {
created_at?: string
icon?: string | null
id?: string
name?: string
slug?: string
}
Relationships: []
}
conversations: {
Row: {
author_id: string
created_at: string
id: string
merchant_id: string
need_id: string
offer_id: string
}
Insert: {
author_id: string
created_at?: string
id?: string
merchant_id: string
need_id: string
offer_id: string
}
Update: {
author_id?: string
created_at?: string
id?: string
merchant_id?: string
need_id?: string
offer_id?: string
}
Relationships: [
{
foreignKeyName: "conversations_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "conversations_merchant_id_fkey"
columns: ["merchant_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "conversations_need_id_fkey"
columns: ["need_id"]
isOneToOne: false
referencedRelation: "needs"
referencedColumns: ["id"]
},
{
foreignKeyName: "conversations_offer_id_fkey"
columns: ["offer_id"]
isOneToOne: true
referencedRelation: "offers"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
body: string
conversation_id: string
created_at: string
id: string
sender_id: string
}
Insert: {
body: string
conversation_id: string
created_at?: string
id?: string
sender_id: string
}
Update: {
body?: string
conversation_id?: string
created_at?: string
id?: string
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
need_images: {
Row: {
created_at: string
id: string
need_id: string
position: number
storage_path: string
}
Insert: {
created_at?: string
id?: string
need_id: string
position?: number
storage_path: string
}
Update: {
created_at?: string
id?: string
need_id?: string
position?: number
storage_path?: string
}
Relationships: [
{
foreignKeyName: "need_images_need_id_fkey"
columns: ["need_id"]
isOneToOne: false
referencedRelation: "needs"
referencedColumns: ["id"]
},
]
}
needs: {
Row: {
accepted_offer_id: string | null
author_id: string
budget_max: number | null
budget_min: number | null
category_id: string | null
city: string
created_at: string
description: string
id: string
region: string | null
status: string
title: string
updated_at: string
}
Insert: {
accepted_offer_id?: string | null
author_id: string
budget_max?: number | null
budget_min?: number | null
category_id?: string | null
city: string
created_at?: string
description: string
id?: string
region?: string | null
status?: string
title: string
updated_at?: string
}
Update: {
accepted_offer_id?: string | null
author_id?: string
budget_max?: number | null
budget_min?: number | null
category_id?: string | null
city?: string
created_at?: string
description?: string
id?: string
region?: string | null
status?: string
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "needs_accepted_offer_fk"
columns: ["accepted_offer_id"]
isOneToOne: false
referencedRelation: "offers"
referencedColumns: ["id"]
},
{
foreignKeyName: "needs_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "needs_category_id_fkey"
columns: ["category_id"]
isOneToOne: false
referencedRelation: "categories"
referencedColumns: ["id"]
},
]
}
offer_images: {
Row: {
id: string
offer_id: string
position: number
storage_path: string
}
Insert: {
id?: string
offer_id: string
position?: number
storage_path: string
}
Update: {
id?: string
offer_id?: string
position?: number
storage_path?: string
}
Relationships: [
{
foreignKeyName: "offer_images_offer_id_fkey"
columns: ["offer_id"]
isOneToOne: false
referencedRelation: "offers"
referencedColumns: ["id"]
},
]
}
offers: {
Row: {
created_at: string
description: string
id: string
merchant_id: string
need_id: string
price: number
status: string
title: string
updated_at: string
}
Insert: {
created_at?: string
description: string
id?: string
merchant_id: string
need_id: string
price: number
status?: string
title: string
updated_at?: string
}
Update: {
created_at?: string
description?: string
id?: string
merchant_id?: string
need_id?: string
price?: number
status?: string
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "offers_merchant_id_fkey"
columns: ["merchant_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "offers_need_id_fkey"
columns: ["need_id"]
isOneToOne: false
referencedRelation: "needs"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
avatar_url: string | null
city: string | null
created_at: string
full_name: string | null
id: string
updated_at: string
}
Insert: {
avatar_url?: string | null
city?: string | null
created_at?: string
full_name?: string | null
id: string
updated_at?: string
}
Update: {
avatar_url?: string | null
city?: string | null
created_at?: string
full_name?: string | null
id?: string
updated_at?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
accept_offer: { Args: { p_offer_id: string }; Returns: string }
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
+19
View File
@@ -0,0 +1,19 @@
import "server-only";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
// Uses the service role key — bypasses RLS entirely. Only import this from
// trusted server-only code (e.g. the signup trigger fallback, admin tooling).
// Never expose this client or its key to the browser.
export function createAdminClient() {
return createSupabaseClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
}
+9
View File
@@ -0,0 +1,9 @@
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
+55
View File
@@ -0,0 +1,55 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PREFIXES = ["/dashboard", "/messages", "/needs/new"];
function isProtectedPath(pathname: string) {
if (PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
// /needs/[id]/edit
return /^\/needs\/[^/]+\/edit(\/|$)/.test(pathname);
}
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
);
},
},
}
);
// Do not run code between createServerClient and getClaims() — this
// refreshes the session token on every request, which is what keeps
// users from being randomly logged out.
const { data } = await supabase.auth.getClaims();
const user = data?.claims;
if (!user && isProtectedPath(request.nextUrl.pathname)) {
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
url.searchParams.set("redirect_to", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
return supabaseResponse;
}
+29
View File
@@ -0,0 +1,29 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component — safe to ignore since the
// proxy already refreshes the session on every request.
}
},
},
}
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+27
View File
@@ -0,0 +1,27 @@
import * as z from "zod";
export const SignupFormSchema = z.object({
fullName: z.string().trim().min(2, { message: "2 caractères minimum." }),
email: z.string().trim().email({ message: "Adresse email invalide." }),
password: z
.string()
.min(8, { message: "8 caractères minimum." })
.regex(/[a-zA-Z]/, { message: "Doit contenir au moins une lettre." })
.regex(/[0-9]/, { message: "Doit contenir au moins un chiffre." }),
});
export const LoginFormSchema = z.object({
email: z.string().trim().email({ message: "Adresse email invalide." }),
password: z.string().min(1, { message: "Mot de passe requis." }),
});
export type FormState =
| {
errors?: {
fullName?: string[];
email?: string[];
password?: string[];
};
message?: string;
}
| undefined;
+34
View File
@@ -0,0 +1,34 @@
import * as z from "zod";
export const NeedFormSchema = z.object({
title: z
.string()
.trim()
.min(5, { message: "5 caractères minimum." })
.max(120, { message: "120 caractères maximum." }),
description: z
.string()
.trim()
.min(20, { message: "20 caractères minimum." })
.max(2000, { message: "2000 caractères maximum." }),
categoryId: z.string().uuid({ message: "Choisis une catégorie." }),
city: z.string().trim().min(2, { message: "Ville requise." }),
region: z.string().trim().max(120).optional(),
budgetMin: z.string().optional(),
budgetMax: z.string().optional(),
});
export type NeedFormState =
| {
errors?: {
title?: string[];
description?: string[];
categoryId?: string[];
city?: string[];
region?: string[];
budgetMin?: string[];
budgetMax?: string[];
};
message?: string;
}
| undefined;
+26
View File
@@ -0,0 +1,26 @@
import * as z from "zod";
export const OfferFormSchema = z.object({
title: z
.string()
.trim()
.min(5, { message: "5 caractères minimum." })
.max(120, { message: "120 caractères maximum." }),
description: z
.string()
.trim()
.min(20, { message: "20 caractères minimum." })
.max(2000, { message: "2000 caractères maximum." }),
price: z.string().min(1, { message: "Prix requis." }),
});
export type OfferFormState =
| {
errors?: {
title?: string[];
description?: string[];
price?: string[];
};
message?: string;
}
| undefined;
+16
View File
@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
pathname: "/storage/v1/object/public/**",
},
],
},
};
export default nextConfig;
+11992
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "wamarket",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "^5.5.7",
"@supabase/ssr": "^0.12.4",
"@supabase/supabase-js": "^2.111.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
"next": "16.2.12",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.83.0",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"shadcn": "^4.16.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+12
View File
@@ -0,0 +1,12 @@
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";
export async function proxy(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+45
View File
@@ -0,0 +1,45 @@
-- Étape 2 (auth) — table profiles + trigger de création automatique.
-- À coller dans Supabase Studio > SQL Editor et exécuter une fois.
create table if not exists public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
full_name text,
avatar_url text,
city text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
drop policy if exists "profiles_select_authenticated" on public.profiles;
create policy "profiles_select_authenticated"
on public.profiles for select
to authenticated
using (true);
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
-- Crée automatiquement une ligne profiles à chaque inscription (auth.users).
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, full_name)
values (new.id, new.raw_user_meta_data ->> 'full_name');
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
+334
View File
@@ -0,0 +1,334 @@
-- Étape 3 — schéma complet : catégories, besoins, offres, conversations, messages,
-- storage, RLS, et la fonction accept_offer(). Appliqué via le MCP Supabase.
-- ============ Catégories ============
create table if not exists public.categories (
id uuid primary key default gen_random_uuid(),
name text not null unique,
slug text not null unique,
icon text,
created_at timestamptz not null default now()
);
alter table public.categories enable row level security;
drop policy if exists "categories_select_all" on public.categories;
create policy "categories_select_all"
on public.categories for select
using (true);
insert into public.categories (name, slug) values
('Bricolage', 'bricolage'),
('Jardinage', 'jardinage'),
('Ménage', 'menage'),
('Déménagement', 'demenagement'),
('Informatique', 'informatique'),
('Cours particuliers', 'cours-particuliers'),
('Beauté & bien-être', 'beaute-bien-etre'),
('Événementiel', 'evenementiel'),
('Automobile', 'automobile'),
('Autre', 'autre')
on conflict (slug) do nothing;
-- ============ Besoins (needs) ============
create table if not exists public.needs (
id uuid primary key default gen_random_uuid(),
author_id uuid not null references public.profiles (id) on delete cascade,
category_id uuid references public.categories (id),
title text not null,
description text not null,
city text not null,
region text,
budget_min numeric,
budget_max numeric,
status text not null default 'open' check (status in ('open', 'closed', 'fulfilled')),
accepted_offer_id uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.need_images (
id uuid primary key default gen_random_uuid(),
need_id uuid not null references public.needs (id) on delete cascade,
storage_path text not null,
position int not null default 0,
created_at timestamptz not null default now()
);
-- ============ Offres ============
create table if not exists public.offers (
id uuid primary key default gen_random_uuid(),
need_id uuid not null references public.needs (id) on delete cascade,
merchant_id uuid not null references public.profiles (id) on delete cascade,
title text not null,
description text not null,
price numeric not null,
status text not null default 'pending' check (status in ('pending', 'accepted', 'rejected', 'withdrawn')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (need_id, merchant_id)
);
create table if not exists public.offer_images (
id uuid primary key default gen_random_uuid(),
offer_id uuid not null references public.offers (id) on delete cascade,
storage_path text not null,
position int not null default 0
);
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'needs_accepted_offer_fk'
) then
alter table public.needs
add constraint needs_accepted_offer_fk
foreign key (accepted_offer_id) references public.offers (id);
end if;
end;
$$;
-- ============ Conversations & messages ============
create table if not exists public.conversations (
id uuid primary key default gen_random_uuid(),
need_id uuid not null references public.needs (id) on delete cascade,
offer_id uuid not null unique references public.offers (id) on delete cascade,
author_id uuid not null references public.profiles (id),
merchant_id uuid not null references public.profiles (id),
created_at timestamptz not null default now()
);
create table if not exists public.messages (
id uuid primary key default gen_random_uuid(),
conversation_id uuid not null references public.conversations (id) on delete cascade,
sender_id uuid not null references public.profiles (id),
body text not null,
created_at timestamptz not null default now()
);
-- ============ RLS ============
alter table public.needs enable row level security;
alter table public.need_images enable row level security;
alter table public.offers enable row level security;
alter table public.offer_images enable row level security;
alter table public.conversations enable row level security;
alter table public.messages enable row level security;
-- needs: lecture publique si open, ou toujours par l'auteur ; écriture par l'auteur uniquement
drop policy if exists "needs_select" on public.needs;
create policy "needs_select"
on public.needs for select
using (status = 'open' or auth.uid() = author_id);
drop policy if exists "needs_insert_own" on public.needs;
create policy "needs_insert_own"
on public.needs for insert
to authenticated
with check (auth.uid() = author_id);
drop policy if exists "needs_update_own" on public.needs;
create policy "needs_update_own"
on public.needs for update
to authenticated
using (auth.uid() = author_id)
with check (auth.uid() = author_id);
-- need_images: miroir de la visibilité du besoin parent
drop policy if exists "need_images_select" on public.need_images;
create policy "need_images_select"
on public.need_images for select
using (
exists (
select 1 from public.needs n
where n.id = need_id and (n.status = 'open' or auth.uid() = n.author_id)
)
);
drop policy if exists "need_images_insert_own" on public.need_images;
create policy "need_images_insert_own"
on public.need_images for insert
to authenticated
with check (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = auth.uid())
);
drop policy if exists "need_images_delete_own" on public.need_images;
create policy "need_images_delete_own"
on public.need_images for delete
to authenticated
using (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = auth.uid())
);
-- offers: visibles uniquement par le marchand auteur et l'auteur du besoin (jamais entre marchands concurrents)
drop policy if exists "offers_select" on public.offers;
create policy "offers_select"
on public.offers for select
to authenticated
using (
auth.uid() = merchant_id
or exists (select 1 from public.needs n where n.id = need_id and n.author_id = auth.uid())
);
drop policy if exists "offers_insert" on public.offers;
create policy "offers_insert"
on public.offers for insert
to authenticated
with check (
merchant_id = auth.uid()
and exists (
select 1 from public.needs n
where n.id = need_id and n.status = 'open' and n.author_id <> auth.uid()
)
);
drop policy if exists "offers_update_own_pending" on public.offers;
create policy "offers_update_own_pending"
on public.offers for update
to authenticated
using (auth.uid() = merchant_id and status = 'pending')
with check (auth.uid() = merchant_id);
-- offer_images: miroir de la visibilité de l'offre parente
drop policy if exists "offer_images_select" on public.offer_images;
create policy "offer_images_select"
on public.offer_images for select
to authenticated
using (
exists (
select 1 from public.offers o
where o.id = offer_id
and (
o.merchant_id = auth.uid()
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = auth.uid())
)
)
);
drop policy if exists "offer_images_insert_own" on public.offer_images;
create policy "offer_images_insert_own"
on public.offer_images for insert
to authenticated
with check (
exists (select 1 from public.offers o where o.id = offer_id and o.merchant_id = auth.uid())
);
-- conversations: lecture par les deux participants ; aucune insertion côté client
drop policy if exists "conversations_select" on public.conversations;
create policy "conversations_select"
on public.conversations for select
to authenticated
using (auth.uid() = author_id or auth.uid() = merchant_id);
-- messages: lecture/écriture réservées aux participants de la conversation
drop policy if exists "messages_select" on public.messages;
create policy "messages_select"
on public.messages for select
to authenticated
using (
exists (
select 1 from public.conversations c
where c.id = conversation_id and (auth.uid() = c.author_id or auth.uid() = c.merchant_id)
)
);
drop policy if exists "messages_insert" on public.messages;
create policy "messages_insert"
on public.messages for insert
to authenticated
with check (
sender_id = auth.uid()
and exists (
select 1 from public.conversations c
where c.id = conversation_id and (auth.uid() = c.author_id or auth.uid() = c.merchant_id)
)
);
-- ============ Fonction accept_offer ============
create or replace function public.accept_offer(p_offer_id uuid)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_need_id uuid;
v_author_id uuid;
v_merchant_id uuid;
v_conversation_id uuid;
begin
select o.need_id, n.author_id, o.merchant_id
into v_need_id, v_author_id, v_merchant_id
from public.offers o
join public.needs n on n.id = o.need_id
where o.id = p_offer_id and o.status = 'pending' and n.status = 'open';
if v_need_id is null then
raise exception 'Offer not eligible for acceptance';
end if;
if auth.uid() <> v_author_id then
raise exception 'Not authorized';
end if;
update public.offers set status = 'rejected', updated_at = now()
where need_id = v_need_id and id <> p_offer_id and status = 'pending';
update public.offers set status = 'accepted', updated_at = now()
where id = p_offer_id;
update public.needs set status = 'fulfilled', accepted_offer_id = p_offer_id, updated_at = now()
where id = v_need_id;
insert into public.conversations (need_id, offer_id, author_id, merchant_id)
values (v_need_id, p_offer_id, v_author_id, v_merchant_id)
returning id into v_conversation_id;
return v_conversation_id;
end;
$$;
-- ============ Storage buckets ============
insert into storage.buckets (id, name, public)
values ('need-images', 'need-images', true)
on conflict (id) do nothing;
insert into storage.buckets (id, name, public)
values ('offer-images', 'offer-images', false)
on conflict (id) do nothing;
drop policy if exists "need_images_bucket_read" on storage.objects;
create policy "need_images_bucket_read"
on storage.objects for select
using (bucket_id = 'need-images');
-- Convention de chemin : {auth.uid()}/{needId}/{filename} — chacun ne peut
-- écrire que dans son propre dossier.
drop policy if exists "need_images_bucket_write" on storage.objects;
create policy "need_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'need-images'
and (storage.foldername(name))[1] = auth.uid()::text
);
drop policy if exists "offer_images_bucket_read" on storage.objects;
create policy "offer_images_bucket_read"
on storage.objects for select
to authenticated
using (bucket_id = 'offer-images');
-- Convention de chemin : {auth.uid()}/{offerId}/{filename}.
drop policy if exists "offer_images_bucket_write" on storage.objects;
create policy "offer_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'offer-images'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- ============ Realtime ============
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.conversations;
+33
View File
@@ -0,0 +1,33 @@
-- Corrections suite au linter de sécurité Supabase (get_advisors) après 0002.
-- 1. Bucket public : pas besoin d'une policy SELECT (elle permet le listing
-- complet du bucket, superflu — l'URL publique fonctionne sans RLS).
drop policy if exists "need_images_bucket_read" on storage.objects;
-- 2. handle_new_user() ne doit être invocable que par le trigger, pas en RPC public.
revoke execute on function public.handle_new_user() from public, anon, authenticated;
-- 3. accept_offer() : anon n'a aucune raison de l'appeler (auth.uid() sera
-- toujours null pour lui, mais autant réduire la surface d'API exposée).
revoke execute on function public.accept_offer(uuid) from anon;
-- 4. offer-images : resserrer la lecture aux seuls participants (marchand
-- auteur de l'offre, ou auteur du besoin concerné) au lieu de "tout
-- utilisateur connecté".
drop policy if exists "offer_images_bucket_read" on storage.objects;
create policy "offer_images_bucket_read"
on storage.objects for select
to authenticated
using (
bucket_id = 'offer-images'
and exists (
select 1
from public.offer_images oi
join public.offers o on o.id = oi.offer_id
where oi.storage_path = storage.objects.name
and (
o.merchant_id = auth.uid()
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = auth.uid())
)
)
);
+192
View File
@@ -0,0 +1,192 @@
-- Corrections suite au linter de performance Supabase (get_advisors) :
-- 1. Wrap auth.uid() en (select auth.uid()) dans toutes les policies RLS
-- (évite une réévaluation par ligne — recommandation officielle Supabase).
-- 2. Index sur toutes les clés étrangères signalées.
-- ---- profiles ----
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
-- ---- needs ----
drop policy if exists "needs_select" on public.needs;
create policy "needs_select"
on public.needs for select
using (status = 'open' or (select auth.uid()) = author_id);
drop policy if exists "needs_insert_own" on public.needs;
create policy "needs_insert_own"
on public.needs for insert
to authenticated
with check ((select auth.uid()) = author_id);
drop policy if exists "needs_update_own" on public.needs;
create policy "needs_update_own"
on public.needs for update
to authenticated
using ((select auth.uid()) = author_id)
with check ((select auth.uid()) = author_id);
-- ---- need_images ----
drop policy if exists "need_images_select" on public.need_images;
create policy "need_images_select"
on public.need_images for select
using (
exists (
select 1 from public.needs n
where n.id = need_id and (n.status = 'open' or (select auth.uid()) = n.author_id)
)
);
drop policy if exists "need_images_insert_own" on public.need_images;
create policy "need_images_insert_own"
on public.need_images for insert
to authenticated
with check (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = (select auth.uid()))
);
drop policy if exists "need_images_delete_own" on public.need_images;
create policy "need_images_delete_own"
on public.need_images for delete
to authenticated
using (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = (select auth.uid()))
);
-- ---- offers ----
drop policy if exists "offers_select" on public.offers;
create policy "offers_select"
on public.offers for select
to authenticated
using (
(select auth.uid()) = merchant_id
or exists (select 1 from public.needs n where n.id = need_id and n.author_id = (select auth.uid()))
);
drop policy if exists "offers_insert" on public.offers;
create policy "offers_insert"
on public.offers for insert
to authenticated
with check (
merchant_id = (select auth.uid())
and exists (
select 1 from public.needs n
where n.id = need_id and n.status = 'open' and n.author_id <> (select auth.uid())
)
);
drop policy if exists "offers_update_own_pending" on public.offers;
create policy "offers_update_own_pending"
on public.offers for update
to authenticated
using ((select auth.uid()) = merchant_id and status = 'pending')
with check ((select auth.uid()) = merchant_id);
-- ---- offer_images ----
drop policy if exists "offer_images_select" on public.offer_images;
create policy "offer_images_select"
on public.offer_images for select
to authenticated
using (
exists (
select 1 from public.offers o
where o.id = offer_id
and (
o.merchant_id = (select auth.uid())
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = (select auth.uid()))
)
)
);
drop policy if exists "offer_images_insert_own" on public.offer_images;
create policy "offer_images_insert_own"
on public.offer_images for insert
to authenticated
with check (
exists (select 1 from public.offers o where o.id = offer_id and o.merchant_id = (select auth.uid()))
);
-- ---- conversations ----
drop policy if exists "conversations_select" on public.conversations;
create policy "conversations_select"
on public.conversations for select
to authenticated
using ((select auth.uid()) = author_id or (select auth.uid()) = merchant_id);
-- ---- messages ----
drop policy if exists "messages_select" on public.messages;
create policy "messages_select"
on public.messages for select
to authenticated
using (
exists (
select 1 from public.conversations c
where c.id = conversation_id and ((select auth.uid()) = c.author_id or (select auth.uid()) = c.merchant_id)
)
);
drop policy if exists "messages_insert" on public.messages;
create policy "messages_insert"
on public.messages for insert
to authenticated
with check (
sender_id = (select auth.uid())
and exists (
select 1 from public.conversations c
where c.id = conversation_id and ((select auth.uid()) = c.author_id or (select auth.uid()) = c.merchant_id)
)
);
-- ---- storage.objects (offer-images) ----
drop policy if exists "offer_images_bucket_read" on storage.objects;
create policy "offer_images_bucket_read"
on storage.objects for select
to authenticated
using (
bucket_id = 'offer-images'
and exists (
select 1
from public.offer_images oi
join public.offers o on o.id = oi.offer_id
where oi.storage_path = storage.objects.name
and (
o.merchant_id = (select auth.uid())
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = (select auth.uid()))
)
)
);
drop policy if exists "need_images_bucket_write" on storage.objects;
create policy "need_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'need-images'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
drop policy if exists "offer_images_bucket_write" on storage.objects;
create policy "offer_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'offer-images'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- ---- Index sur les clés étrangères ----
create index if not exists idx_needs_author_id on public.needs (author_id);
create index if not exists idx_needs_category_id on public.needs (category_id);
create index if not exists idx_needs_accepted_offer_id on public.needs (accepted_offer_id);
create index if not exists idx_need_images_need_id on public.need_images (need_id);
create index if not exists idx_offers_merchant_id on public.offers (merchant_id);
create index if not exists idx_offer_images_offer_id on public.offer_images (offer_id);
create index if not exists idx_conversations_need_id on public.conversations (need_id);
create index if not exists idx_conversations_author_id on public.conversations (author_id);
create index if not exists idx_conversations_merchant_id on public.conversations (merchant_id);
create index if not exists idx_messages_conversation_id on public.messages (conversation_id);
create index if not exists idx_messages_sender_id on public.messages (sender_id);
@@ -0,0 +1,13 @@
-- Faille corrigée : la policy offers_update_own_pending ne limitait pas la
-- valeur cible de `status`, permettant à un marchand de passer directement sa
-- propre offre à 'accepted' via un simple PATCH, en contournant accept_offer().
-- Désormais, un marchand ne peut que rester en 'pending' (édition) ou passer
-- en 'withdrawn' (retrait volontaire) ; 'accepted'/'rejected' restent
-- exclusivement gérés par la fonction security definer accept_offer().
drop policy if exists "offers_update_own_pending" on public.offers;
create policy "offers_update_own_pending"
on public.offers for update
to authenticated
using ((select auth.uid()) = merchant_id and status = 'pending')
with check ((select auth.uid()) = merchant_id and status in ('pending', 'withdrawn'));
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}