commit af81124d4896bb2180d9f8b9a81bf64e58cd6f6a Author: softgrey Date: Thu Jul 30 14:33:17 2026 +0000 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1fafb6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env* +*.md +supabase/sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f657825 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# 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. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a5315e0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/auth/auth-code-error/page.tsx b/app/auth/auth-code-error/page.tsx new file mode 100644 index 0000000..985473d --- /dev/null +++ b/app/auth/auth-code-error/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function AuthCodeErrorPage() { + return ( +
+

Lien invalide ou expiré

+

+ Ce lien de confirmation n'est plus valide. Réessaie de te + connecter ou de t'inscrire. +

+ +
+ ); +} diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 0000000..ff41d47 --- /dev/null +++ b/app/auth/confirm/route.ts @@ -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"); +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..51b415c --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,9 @@ +import { LoginForm } from "@/components/auth/login-form"; + +export default function LoginPage() { + return ( +
+ +
+ ); +} diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx new file mode 100644 index 0000000..5156852 --- /dev/null +++ b/app/auth/signup/page.tsx @@ -0,0 +1,9 @@ +import { SignupForm } from "@/components/auth/signup-form"; + +export default function SignupPage() { + return ( +
+ +
+ ); +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..f6c934b --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,15 @@ +import { verifySession } from "@/lib/dal"; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + await verifySession(); + + return ( +
+ {children} +
+ ); +} diff --git a/app/dashboard/my-needs/page.tsx b/app/dashboard/my-needs/page.tsx new file mode 100644 index 0000000..35ad1d0 --- /dev/null +++ b/app/dashboard/my-needs/page.tsx @@ -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 = { + 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 ( +
+
+

Mes besoins

+ +
+ + {!needs || needs.length === 0 ? ( +

+ Tu n'as pas encore publié de besoin. +

+ ) : ( +
+ {needs.map((need) => { + const category = Array.isArray(need.categories) + ? need.categories[0] + : need.categories; + return ( + + + +
+

{need.title}

+

+ {need.city} + {category?.name ? ` · ${category.name}` : ""} +

+
+ + {STATUS_LABEL[need.status] ?? need.status} + +
+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/app/dashboard/my-offers/page.tsx b/app/dashboard/my-offers/page.tsx new file mode 100644 index 0000000..137f637 --- /dev/null +++ b/app/dashboard/my-offers/page.tsx @@ -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 = { + 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 ( +
+

Mes offres

+ + {!offers || offers.length === 0 ? ( +

+ Tu n'as pas encore envoyé d'offre. +

+ ) : ( +
+ {offers.map((offer) => { + const need = Array.isArray(offer.needs) + ? offer.needs[0] + : offer.needs; + return ( + + + +
+

{offer.title}

+

+ {need?.title} · {need?.city} · {offer.price} € +

+
+ + {STATUS_LABEL[offer.status] ?? offer.status} + +
+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..917a750 --- /dev/null +++ b/app/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..adb6f28 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + +
{children}
+