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