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.
116 lines
3.0 KiB
TypeScript
116 lines
3.0 KiB
TypeScript
"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}`);
|
|
}
|