Initial commit: WaMarket MVP
Marketplace inversée (Next.js 16 App Router + Supabase Cloud) : auth, schéma + RLS, publication de besoins avec upload d'images, feed avec filtres, offres, acceptation via RPC, messagerie temps réel, dashboards, et Dockerfile pour déploiement Coolify.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
*.md
|
||||||
|
supabase/sql
|
||||||
+42
@@ -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
|
||||||
@@ -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 -->
|
||||||
+58
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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'est plus valide. Réessaie de te
|
||||||
|
connecter ou de t'inscrire.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/auth/login">Retour à la connexion</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'as pas encore envoyé d'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+130
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'accepte plus d'offres.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
|
<Button asChild variant="link" className="h-auto p-0 text-xs">
|
||||||
|
<Link href="/messages">Voir mes conversations</Link>
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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'inscrire
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'inscrire</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type NeedCardData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
city: string;
|
||||||
|
region: string | null;
|
||||||
|
budget_min: number | null;
|
||||||
|
budget_max: number | null;
|
||||||
|
categories: { name: string } | { name: string }[] | null;
|
||||||
|
need_images: { storage_path: string; position: number }[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NeedCard({
|
||||||
|
need,
|
||||||
|
getImageUrl,
|
||||||
|
}: {
|
||||||
|
need: NeedCardData;
|
||||||
|
getImageUrl: (path: string) => string;
|
||||||
|
}) {
|
||||||
|
const category = Array.isArray(need.categories)
|
||||||
|
? need.categories[0]
|
||||||
|
: need.categories;
|
||||||
|
const images = [...(need.need_images ?? [])].sort(
|
||||||
|
(a, b) => a.position - b.position
|
||||||
|
);
|
||||||
|
const cover = images[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/needs/${need.id}`}>
|
||||||
|
<Card className="h-full transition-shadow hover:shadow-md">
|
||||||
|
{cover && (
|
||||||
|
<div className="relative h-40 w-full overflow-hidden rounded-t-xl">
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(cover.storage_path)}
|
||||||
|
alt={need.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CardContent className="flex flex-col gap-2 pt-4">
|
||||||
|
{category?.name && (
|
||||||
|
<Badge variant="secondary" className="w-fit">
|
||||||
|
{category.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<h3 className="line-clamp-2 font-medium">{need.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{need.city}
|
||||||
|
{need.region ? `, ${need.region}` : ""}
|
||||||
|
</p>
|
||||||
|
{(need.budget_min || need.budget_max) && (
|
||||||
|
<p className="text-sm">
|
||||||
|
{need.budget_min ?? "?"}€ – {need.budget_max ?? "?"}€
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type Category = { id: string; name: string; slug: string };
|
||||||
|
|
||||||
|
export function NeedFilters({
|
||||||
|
categories,
|
||||||
|
defaultValues,
|
||||||
|
}: {
|
||||||
|
categories: Category[];
|
||||||
|
defaultValues: { category?: string; city?: string; q?: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="flex flex-wrap gap-3" method="get">
|
||||||
|
<Input
|
||||||
|
name="q"
|
||||||
|
placeholder="Rechercher un mot-clé..."
|
||||||
|
defaultValue={defaultValues.q}
|
||||||
|
className="w-full sm:w-56"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="city"
|
||||||
|
placeholder="Ville"
|
||||||
|
defaultValue={defaultValues.city}
|
||||||
|
className="w-full sm:w-40"
|
||||||
|
/>
|
||||||
|
<Select name="category" defaultValue={defaultValues.category}>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue placeholder="Toutes catégories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.slug}>
|
||||||
|
{category.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Filtrer
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { createNeed } from "@/lib/actions/needs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ImageUploader } from "@/components/shared/image-uploader";
|
||||||
|
|
||||||
|
type Category = { id: string; name: string; slug: string };
|
||||||
|
|
||||||
|
export function NeedForm({ categories }: { categories: Category[] }) {
|
||||||
|
const [state, action, pending] = useActionState(createNeed, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="title">Titre</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Ex: Besoin d'un plombier pour une fuite"
|
||||||
|
/>
|
||||||
|
{state?.errors?.title && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.title[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Décris ton besoin en détail : contexte, urgence, contraintes..."
|
||||||
|
/>
|
||||||
|
{state?.errors?.description && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{state.errors.description[0]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="categoryId">Catégorie</Label>
|
||||||
|
<Select name="categoryId">
|
||||||
|
<SelectTrigger id="categoryId" className="w-full">
|
||||||
|
<SelectValue placeholder="Choisis une catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{state?.errors?.categoryId && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{state.errors.categoryId[0]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="city">Ville</Label>
|
||||||
|
<Input id="city" name="city" placeholder="Paris" />
|
||||||
|
{state?.errors?.city && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.city[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="region">Région (optionnel)</Label>
|
||||||
|
<Input id="region" name="region" placeholder="Île-de-France" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="budgetMin">Budget min (optionnel, €)</Label>
|
||||||
|
<Input id="budgetMin" name="budgetMin" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="budgetMax">Budget max (optionnel, €)</Label>
|
||||||
|
<Input id="budgetMax" name="budgetMax" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageUploader name="images" maxFiles={5} />
|
||||||
|
|
||||||
|
{state?.message && (
|
||||||
|
<p className="text-sm text-destructive">{state.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending} className="mt-2 w-fit">
|
||||||
|
{pending ? "Publication..." : "Publier le besoin"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 ?? [];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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!
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Generated
+11992
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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'));
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user