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,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;
|
||||
Reference in New Issue
Block a user