feat: Transform HookLab to OBC Maçonnerie showcase site
Complete transformation of the Next.js project into a professional showcase site for OBC Maçonnerie (Benoît Colin, maçon in Nord 59). Key changes: - Remove all HookLab/Sanity/Supabase/Stripe/admin/training infrastructure - Full OBC Maçonnerie identity: logo, colors, contact info, SIREN - Schema.org LocalBusiness structured data for Benoît Colin - SEO metadata for all pages targeting Nord 59 keywords New pages created (23 total): - Home page with 10 sections (hero, services, pillars, partners, zone, realisations, testimonials, FAQ, contact form, footer) - Service pages: construction-maison, renovation, assainissement, creation-acces, demolition, services - Secondary pages: realisations, partenaires, contact - Blog: listing + 6 SEO articles with static content - 8 local SEO pages: Orchies, Douai, Valenciennes, Mouchin, Flines-lès-Raches, Saint-Amand-les-Eaux - Legal pages: mentions-legales, cgv, confidentialite (OBC adapted) Components: - Navbar with OBC branding + mobile menu - Footer with dark navy theme, services + navigation links - ContactForm client component (devis request) - LocalSEOPage reusable component for local SEO pages - CookieBanner updated with OBC cookie key Config: - layout.tsx: OBC metadata, Schema.org, no Sanity CDN - globals.css: stone color variables added - next.config.ts: removed Sanity CDN remotePatterns - sitemap.ts: all 30 OBC pages - robots.ts: allow all except /api/ - api/contact/route.ts: OBC devis email template https://claude.ai/code/session_01Uec4iHjcPwB1pU41idWEdF
This commit is contained in:
@@ -1,186 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
import { stripe } from "@/lib/stripe/client";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// POST /api/admin/candidatures/[id]/approve - Approuver une candidature
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// Récupérer la candidature
|
||||
const { data: candidature, error: fetchError } = await supabase
|
||||
.from("candidatures")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (fetchError || !candidature) {
|
||||
return NextResponse.json({ error: "Candidature introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
const email = (candidature as Record<string, unknown>).email as string;
|
||||
const firstname = (candidature as Record<string, unknown>).firstname as string;
|
||||
const candidatureId = (candidature as Record<string, unknown>).id as string;
|
||||
|
||||
// Mettre à jour le statut
|
||||
const { error: updateError } = await supabase
|
||||
.from("candidatures")
|
||||
.update({ status: "approved" } as never)
|
||||
.eq("id", id);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Générer le lien de paiement Stripe
|
||||
let checkoutUrl: string | null = null;
|
||||
let stripeError: string | null = null;
|
||||
|
||||
if (process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID) {
|
||||
try {
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
const customers = await stripe.customers.list({ email, limit: 1 });
|
||||
let customerId: string;
|
||||
if (customers.data.length > 0) {
|
||||
customerId = customers.data[0].id;
|
||||
} else {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: { candidature_id: candidatureId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
|
||||
metadata: { candidature_id: candidatureId, email },
|
||||
success_url: `${baseUrl}/merci?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/candidature`,
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: "required",
|
||||
});
|
||||
|
||||
checkoutUrl = session.url;
|
||||
} catch (err) {
|
||||
stripeError = err instanceof Error ? err.message : "Erreur Stripe inconnue";
|
||||
console.error("Erreur Stripe:", err);
|
||||
}
|
||||
} else {
|
||||
stripeError = "STRIPE_SECRET_KEY ou STRIPE_PRICE_ID non configuré.";
|
||||
}
|
||||
|
||||
// Envoyer l'email (indépendamment de Stripe)
|
||||
let emailSent = false;
|
||||
let emailError: string | null = null;
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
emailError = "RESEND_API_KEY non configuré sur Vercel.";
|
||||
} else {
|
||||
try {
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL || "HookLab <onboarding@hooklab.eu>";
|
||||
|
||||
const paymentButton = checkoutUrl
|
||||
? `<a href="${checkoutUrl}" style="display:inline-block;background:linear-gradient(135deg,#6D5EF6,#9D8FF9);color:#ffffff;padding:16px 40px;border-radius:12px;text-decoration:none;font-weight:700;font-size:16px;margin:10px 0;">Finaliser mon inscription</a>`
|
||||
: `<p style="color:#F59E0B;font-weight:600;">Le lien de paiement sera envoyé séparément.</p>`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: `${firstname}, ta candidature HookLab est acceptée !`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
|
||||
<body style="margin:0;padding:0;background-color:#0B0F19;font-family:Arial,Helvetica,sans-serif;">
|
||||
<div style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;margin-bottom:40px;">
|
||||
<div style="display:inline-block;background:linear-gradient(135deg,#6D5EF6,#9D8FF9);width:48px;height:48px;border-radius:12px;line-height:48px;color:#fff;font-weight:800;font-size:20px;">H</div>
|
||||
<span style="display:inline-block;vertical-align:top;margin-left:10px;line-height:48px;font-size:24px;font-weight:800;color:#ffffff;">Hook<span style="color:#6D5EF6;">Lab</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div style="background:#1A1F2E;border:1px solid #2A2F3F;border-radius:20px;padding:40px 32px;">
|
||||
<h1 style="color:#ffffff;font-size:24px;margin:0 0 8px 0;">Félicitations ${firstname} !</h1>
|
||||
<p style="color:#10B981;font-size:14px;font-weight:600;margin:0 0 24px 0;">Ta candidature a été acceptée</p>
|
||||
|
||||
<p style="color:#ffffffcc;font-size:15px;line-height:1.6;margin:0 0 16px 0;">
|
||||
On a étudié ton profil et on pense que tu as le potentiel pour réussir sur TikTok Shop.
|
||||
</p>
|
||||
<p style="color:#ffffffcc;font-size:15px;line-height:1.6;margin:0 0 32px 0;">
|
||||
Pour accéder au programme et commencer ta formation, il te reste une dernière étape :
|
||||
</p>
|
||||
|
||||
<!-- CTA -->
|
||||
<div style="text-align:center;margin:32px 0;">
|
||||
${paymentButton}
|
||||
</div>
|
||||
|
||||
<!-- Détails -->
|
||||
<div style="background:#252A3A;border-radius:12px;padding:20px;margin:24px 0;">
|
||||
<p style="color:#ffffff99;font-size:13px;margin:0 0 8px 0;">Ce qui t'attend :</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="color:#ffffffcc;font-size:14px;padding:6px 0;">Programme complet de 8 semaines</td></tr>
|
||||
<tr><td style="color:#ffffffcc;font-size:14px;padding:6px 0;">Accompagnement personnalisé</td></tr>
|
||||
<tr><td style="color:#ffffffcc;font-size:14px;padding:6px 0;">Accès à la communauté HookLab</td></tr>
|
||||
<tr><td style="color:#ffffffcc;font-size:14px;padding:6px 0;">Stratégies TikTok Shop éprouvées</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="color:#ffffff66;font-size:13px;line-height:1.5;margin:24px 0 0 0;">
|
||||
Le paiement est 100% sécurisé via Stripe. Tu peux payer en 2 mensualités de 490€.
|
||||
Si tu as des questions, réponds directement à cet email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align:center;margin-top:32px;">
|
||||
<p style="color:#ffffff40;font-size:12px;margin:0;">HookLab - Programme TikTok Shop</p>
|
||||
<p style="color:#ffffff30;font-size:11px;margin:8px 0 0 0;">Enguerrand Ozano · SIREN 994538932</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
|
||||
emailSent = true;
|
||||
} catch (err) {
|
||||
emailError = err instanceof Error ? err.message : "Erreur envoi email";
|
||||
console.error("Erreur envoi email approbation:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
checkoutUrl,
|
||||
emailSent,
|
||||
emailError,
|
||||
stripeError,
|
||||
message: [
|
||||
"Candidature approuvée.",
|
||||
checkoutUrl ? "Lien de paiement généré." : (stripeError || "Stripe non configuré."),
|
||||
emailSent ? "Email envoyé." : (emailError || "Email non envoyé."),
|
||||
].join(" "),
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// POST /api/admin/candidatures/[id]/reject - Rejeter une candidature
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// Récupérer les infos du candidat avant de rejeter
|
||||
const { data: candidature } = await supabase
|
||||
.from("candidatures")
|
||||
.select("firstname, email")
|
||||
.eq("id", id)
|
||||
.single() as { data: { firstname: string; email: string } | null };
|
||||
|
||||
const { error } = await supabase
|
||||
.from("candidatures")
|
||||
.update({ status: "rejected" } as never)
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Email de rejet au candidat
|
||||
if (candidature && process.env.RESEND_API_KEY) {
|
||||
try {
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const fromEmail =
|
||||
process.env.RESEND_FROM_EMAIL || "HookLab <onboarding@resend.dev>";
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: candidature.email,
|
||||
subject: "Résultat de ta candidature HookLab",
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background-color:#0B0F19;font-family:Arial,Helvetica,sans-serif;">
|
||||
<div style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||
<div style="text-align:center;margin-bottom:40px;">
|
||||
<div style="display:inline-block;background:linear-gradient(135deg,#6D5EF6,#9D8FF9);width:48px;height:48px;border-radius:12px;line-height:48px;color:#fff;font-weight:800;font-size:20px;">H</div>
|
||||
<span style="display:inline-block;vertical-align:top;margin-left:10px;line-height:48px;font-size:24px;font-weight:800;color:#ffffff;">Hook<span style="color:#6D5EF6;">Lab</span></span>
|
||||
</div>
|
||||
<div style="background:#1A1F2E;border:1px solid #2A2F3F;border-radius:20px;padding:40px 32px;">
|
||||
<h1 style="color:#ffffff;font-size:22px;margin:0 0 16px 0;">Salut ${candidature.firstname},</h1>
|
||||
<p style="color:#ffffffcc;font-size:15px;line-height:1.6;margin:0 0 16px 0;">
|
||||
Merci d'avoir pris le temps de candidater au programme HookLab.
|
||||
</p>
|
||||
<p style="color:#ffffffcc;font-size:15px;line-height:1.6;margin:0 0 16px 0;">
|
||||
Après étude de ton dossier, nous ne pouvons pas retenir ta candidature pour le moment.
|
||||
Le programme est très sélectif et nous cherchons des profils très spécifiques.
|
||||
</p>
|
||||
<p style="color:#ffffffcc;font-size:15px;line-height:1.6;margin:0 0 0 0;">
|
||||
Nous te souhaitons le meilleur dans ta progression. N'hésite pas à recandidater dans quelques mois si ta situation évolue.
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:32px;">
|
||||
<p style="color:#ffffff40;font-size:12px;margin:0;">HookLab - Programme TikTok Shop</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error("Erreur envoi email rejet:", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: "Candidature rejetée." });
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/candidatures - Lister toutes les candidatures
|
||||
// Sécurisé par auth Supabase + vérification is_admin
|
||||
export async function GET() {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("candidatures")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Erreur récupération candidatures:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ candidatures: data });
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/modules/[id] - Récupérer un module
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: "Module introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ module: data });
|
||||
}
|
||||
|
||||
// PUT /api/admin/modules/[id] - Mettre à jour un module
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Construire l'objet de mise à jour (seulement les champs fournis)
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
if (body.week_number !== undefined) updates.week_number = body.week_number;
|
||||
if (body.order_index !== undefined) updates.order_index = body.order_index;
|
||||
if (body.content_type !== undefined) updates.content_type = body.content_type;
|
||||
if (body.content_url !== undefined) updates.content_url = body.content_url;
|
||||
if (body.duration_minutes !== undefined) updates.duration_minutes = body.duration_minutes;
|
||||
if (body.is_published !== undefined) updates.is_published = body.is_published;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: "Aucune modification fournie." }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.update(updates as never)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ module: data });
|
||||
}
|
||||
|
||||
// DELETE /api/admin/modules/[id] - Supprimer un module
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// D'abord supprimer les progressions liées
|
||||
await supabase.from("user_progress").delete().eq("module_id", id);
|
||||
|
||||
// Puis supprimer le module
|
||||
const { error } = await supabase
|
||||
.from("modules")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: "Module supprimé." });
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/modules - Lister tous les modules (admin)
|
||||
export async function GET() {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.order("week_number", { ascending: true })
|
||||
.order("order_index", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ modules: data });
|
||||
}
|
||||
|
||||
// POST /api/admin/modules - Créer un nouveau module
|
||||
export async function POST(request: Request) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, week_number, order_index, content_type, content_url, duration_minutes, is_published } = body;
|
||||
|
||||
if (!title || !week_number) {
|
||||
return NextResponse.json({ error: "Titre et semaine obligatoires." }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.insert({
|
||||
title,
|
||||
description: description || null,
|
||||
week_number,
|
||||
order_index: order_index ?? 0,
|
||||
content_type: content_type || null,
|
||||
content_url: content_url || null,
|
||||
duration_minutes: duration_minutes ?? null,
|
||||
is_published: is_published ?? false,
|
||||
} as never)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ module: data }, { status: 201 });
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// POST /api/admin/setup - Créer le premier compte admin
|
||||
// Ne fonctionne QUE s'il n'existe aucun admin dans la base
|
||||
export async function POST(request: Request) {
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// Vérifier qu'aucun admin n'existe
|
||||
const { data: existingAdmins } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("is_admin", true);
|
||||
|
||||
if (existingAdmins && existingAdmins.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Un compte admin existe déjà. Cette route est désactivée." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, password, full_name } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "Email et mot de passe requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: "Le mot de passe doit contenir au moins 8 caractères." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Créer le compte auth Supabase
|
||||
const { data: authUser, error: authError } = await supabase.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: full_name || "Admin" },
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
console.error("Erreur création admin:", authError);
|
||||
return NextResponse.json({ error: authError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!authUser.user) {
|
||||
return NextResponse.json({ error: "Erreur lors de la création du compte." }, { status: 500 });
|
||||
}
|
||||
|
||||
// Mettre à jour le profil en admin
|
||||
// Le profil est normalement créé par un trigger Supabase
|
||||
// On attend un instant puis on le met à jour
|
||||
// Si pas de trigger, on le crée manuellement
|
||||
const { data: existingProfile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("id", authUser.user.id)
|
||||
.single();
|
||||
|
||||
if (existingProfile) {
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
is_admin: true,
|
||||
full_name: full_name || "Admin",
|
||||
subscription_status: "active",
|
||||
} as never)
|
||||
.eq("id", authUser.user.id);
|
||||
} else {
|
||||
// Créer le profil manuellement
|
||||
await supabase.from("profiles").insert({
|
||||
id: authUser.user.id,
|
||||
email,
|
||||
full_name: full_name || "Admin",
|
||||
is_admin: true,
|
||||
subscription_status: "active",
|
||||
} as never);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Compte admin créé avec succès ! Connecte-toi sur /login puis va sur /admin.",
|
||||
});
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient, createAdminClient } from "@/lib/supabase/server";
|
||||
import { DEFAULT_IMAGES, updateSiteImage } from "@/lib/site-images";
|
||||
import type { Profile } from "@/types/database.types";
|
||||
|
||||
/** Pages à invalider selon le préfixe de la clé image */
|
||||
function getPathsToRevalidate(key: string): string[] {
|
||||
if (key.startsWith("macon_")) return ["/macon"];
|
||||
if (key.startsWith("paysagiste_")) return ["/paysagiste"];
|
||||
// Clés de la page d'accueil (hero_portrait, about_photo, process_*, demo_*)
|
||||
return ["/"];
|
||||
}
|
||||
|
||||
interface SiteImageRow {
|
||||
key: string;
|
||||
url: string;
|
||||
label: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const BUCKET = "private-gallery";
|
||||
const SIGNED_URL_TTL = 3600; // 1 heure
|
||||
|
||||
async function checkAdmin() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return false;
|
||||
|
||||
const adminClient = createAdminClient();
|
||||
const { data: profile } = await adminClient
|
||||
.from("profiles")
|
||||
.select("is_admin")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
return (profile as Pick<Profile, "is_admin"> | null)?.is_admin === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une URL de prévisualisation pour l'admin.
|
||||
* Pour les chemins "storage:", crée une Signed URL temporaire.
|
||||
* Pour les URLs externes, retourne l'URL telle quelle.
|
||||
*/
|
||||
async function resolvePreviewUrl(rawUrl: string): Promise<string> {
|
||||
if (!rawUrl.startsWith("storage:")) return rawUrl;
|
||||
const filePath = rawUrl.slice("storage:".length);
|
||||
const adminClient = createAdminClient();
|
||||
const { data } = await adminClient.storage
|
||||
.from(BUCKET)
|
||||
.createSignedUrl(filePath, SIGNED_URL_TTL);
|
||||
return data?.signedUrl ?? rawUrl;
|
||||
}
|
||||
|
||||
// GET - Récupérer toutes les images
|
||||
export async function GET() {
|
||||
const isAdmin = await checkAdmin();
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const adminClient = createAdminClient();
|
||||
const { data } = await adminClient.from("site_images").select("*");
|
||||
const rows = (data ?? []) as unknown as SiteImageRow[];
|
||||
|
||||
// Merge defaults avec les valeurs en base, résoudre les signed URLs en parallèle
|
||||
const images = await Promise.all(
|
||||
Object.entries(DEFAULT_IMAGES).map(async ([key, def]) => {
|
||||
const saved = rows.find((d) => d.key === key);
|
||||
const rawUrl = saved?.url || def.url;
|
||||
const previewUrl = await resolvePreviewUrl(rawUrl);
|
||||
return {
|
||||
key,
|
||||
url: rawUrl, // valeur brute stockée (ex: "storage:hero_portrait/image.jpg")
|
||||
previewUrl, // URL résolvée pour l'affichage dans le navigateur
|
||||
label: def.label,
|
||||
updated_at: saved?.updated_at || null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ images });
|
||||
} catch {
|
||||
// Si la table n'existe pas, retourner les defaults
|
||||
const images = Object.entries(DEFAULT_IMAGES).map(([key, def]) => ({
|
||||
key,
|
||||
url: def.url,
|
||||
previewUrl: def.url,
|
||||
label: def.label,
|
||||
updated_at: null,
|
||||
}));
|
||||
return NextResponse.json({ images });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Mettre à jour une image
|
||||
export async function PUT(request: NextRequest) {
|
||||
const isAdmin = await checkAdmin();
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, url } = body;
|
||||
|
||||
if (!key || !url) {
|
||||
return NextResponse.json({ error: "key et url requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Accepter soit une URL externe (https://...) soit un chemin storage (storage:...)
|
||||
const isStoragePath = url.startsWith("storage:");
|
||||
if (!isStoragePath) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "URL invalide" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const success = await updateSiteImage(key, url);
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la sauvegarde. Vérifiez que la table site_images existe dans Supabase." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Invalider immédiatement le cache Next.js des pages concernées
|
||||
const paths = getPathsToRevalidate(key);
|
||||
for (const path of paths) {
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient, createAdminClient } from "@/lib/supabase/server";
|
||||
import type { Profile } from "@/types/database.types";
|
||||
import sharp from "sharp";
|
||||
|
||||
const BUCKET = "private-gallery";
|
||||
|
||||
// Taille cible : entre 300 Ko et 1 Mo
|
||||
const TARGET_MAX_BYTES = 1_000_000; // 1 Mo
|
||||
const TARGET_MIN_BYTES = 300_000; // 300 Ko (indicatif — on ne force pas l'inflation)
|
||||
|
||||
// Paliers de qualité WebP : on descend jusqu'à rentrer sous 1 Mo
|
||||
const QUALITY_STEPS = [82, 72, 62, 50];
|
||||
|
||||
// ── Signatures magic bytes ──────────────────────────────────────────────────
|
||||
// Permet de détecter le vrai format binaire indépendamment du Content-Type
|
||||
// déclaré par le client (qui peut être forgé).
|
||||
const MAGIC_SIGNATURES: Array<{
|
||||
mime: string;
|
||||
check: (b: Uint8Array) => boolean;
|
||||
}> = [
|
||||
{
|
||||
mime: "image/jpeg",
|
||||
check: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
||||
},
|
||||
{
|
||||
mime: "image/png",
|
||||
check: (b) =>
|
||||
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
||||
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a,
|
||||
},
|
||||
{
|
||||
mime: "image/gif",
|
||||
check: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38,
|
||||
},
|
||||
{
|
||||
mime: "image/webp",
|
||||
// RIFF....WEBP
|
||||
check: (b) =>
|
||||
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
||||
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
|
||||
},
|
||||
{
|
||||
mime: "image/avif",
|
||||
// ftyp box à l'offset 4 (structure ISOBMFF)
|
||||
check: (b) => b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Détecte le MIME réel du fichier via ses magic bytes.
|
||||
* Retourne null si aucune signature connue ne correspond.
|
||||
*/
|
||||
function detectMimeFromBytes(buffer: Uint8Array): string | null {
|
||||
for (const sig of MAGIC_SIGNATURES) {
|
||||
if (buffer.length >= 12 && sig.check(buffer)) return sig.mime;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimise l'image :
|
||||
* – Conversion en WebP (meilleur ratio qualité/poids sur le web)
|
||||
* – Auto-rotation via l'orientation EXIF (corrige les photos de téléphone)
|
||||
* – Strip de toutes les métadonnées (GPS, modèle appareil, EXIF) — les navigateurs assument sRGB
|
||||
* – Compression adaptative : démarre à q82, descend par paliers si > 1 Mo
|
||||
*
|
||||
* Retourne le buffer WebP optimisé et les stats (pour logging).
|
||||
*/
|
||||
async function optimizeToWebP(
|
||||
input: Buffer
|
||||
): Promise<{ buffer: Buffer; quality: number; originalBytes: number; finalBytes: number }> {
|
||||
const originalBytes = input.length;
|
||||
|
||||
for (const quality of QUALITY_STEPS) {
|
||||
const output = await sharp(input)
|
||||
.rotate() // Auto-rotation EXIF (corrige portrait/paysage)
|
||||
// withMetadata() non appelé → Sharp strip tout par défaut :
|
||||
// GPS, modèle appareil, IPTC… supprimés. Navigateurs assument sRGB.
|
||||
.webp({ quality, effort: 4 }) // effort 4 = bon compromis vitesse/compression
|
||||
.toBuffer();
|
||||
|
||||
// On s'arrête dès qu'on passe sous 1 Mo
|
||||
// ou si on est déjà au dernier palier (on prend quoi qu'il en soit)
|
||||
if (output.length <= TARGET_MAX_BYTES || quality === QUALITY_STEPS.at(-1)) {
|
||||
return { buffer: output, quality, originalBytes, finalBytes: output.length };
|
||||
}
|
||||
}
|
||||
|
||||
// Ne devrait jamais être atteint — TypeScript exige un return exhaustif
|
||||
throw new Error("Optimisation impossible");
|
||||
}
|
||||
|
||||
async function checkAdmin() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return false;
|
||||
|
||||
const adminClient = createAdminClient();
|
||||
const { data: profile } = await adminClient
|
||||
.from("profiles")
|
||||
.select("is_admin")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
return (profile as Pick<Profile, "is_admin"> | null)?.is_admin === true;
|
||||
}
|
||||
|
||||
// POST — Upload + optimisation automatique vers Supabase Storage
|
||||
export async function POST(request: NextRequest) {
|
||||
const isAdmin = await checkAdmin();
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps de requête invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file") as File | null;
|
||||
const imageKey = formData.get("key") as string | null;
|
||||
|
||||
if (!file || !imageKey) {
|
||||
return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 1. Limite de taille brute (avant optimisation) ────────────────────────
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "Fichier trop volumineux (max 20 Mo avant optimisation)" }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 2. Lire le contenu binaire ────────────────────────────────────────────
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const rawBuffer = new Uint8Array(arrayBuffer);
|
||||
|
||||
// ── 3. Valider les magic bytes (anti-MIME spoofing) ───────────────────────
|
||||
const detectedMime = detectMimeFromBytes(rawBuffer);
|
||||
if (!detectedMime) {
|
||||
return NextResponse.json(
|
||||
{ error: "Le fichier ne correspond pas à un format image valide (JPEG, PNG, WebP, AVIF, GIF)." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Optimisation : conversion WebP + compression adaptative ────────────
|
||||
let optimized: Awaited<ReturnType<typeof optimizeToWebP>>;
|
||||
try {
|
||||
optimized = await optimizeToWebP(Buffer.from(rawBuffer));
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de l'optimisation de l'image." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { buffer, quality, originalBytes, finalBytes } = optimized;
|
||||
|
||||
// ── 5. Construire le chemin de stockage ───────────────────────────────────
|
||||
// Toujours .webp quelle que soit l'entrée (JPEG, PNG, AVIF…)
|
||||
const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_");
|
||||
const filePath = `${sanitizedKey}/image.webp`;
|
||||
|
||||
// ── 6. Upload vers Supabase Storage ──────────────────────────────────────
|
||||
const adminClient = createAdminClient();
|
||||
const { error } = await adminClient.storage
|
||||
.from(BUCKET)
|
||||
.upload(filePath, buffer, {
|
||||
contentType: "image/webp",
|
||||
upsert: true,
|
||||
cacheControl: "public, max-age=31536000", // 1 an (CDN Supabase)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `Erreur upload Supabase : ${error.message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const storagePath = `storage:${filePath}`;
|
||||
|
||||
// Infos retournées pour le feedback admin
|
||||
const originalKb = Math.round(originalBytes / 1024);
|
||||
const finalKb = Math.round(finalBytes / 1024);
|
||||
const inRange = finalBytes >= TARGET_MIN_BYTES && finalBytes <= TARGET_MAX_BYTES;
|
||||
|
||||
return NextResponse.json({
|
||||
storagePath,
|
||||
filePath,
|
||||
optimization: {
|
||||
originalKb,
|
||||
finalKb,
|
||||
quality,
|
||||
inRange,
|
||||
// Message lisible en fr pour l'UI
|
||||
summary: `${originalKb} Ko → ${finalKb} Ko (WebP q${quality})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import type { CandidatureInsert } from "@/types/database.types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validation des champs requis
|
||||
const requiredFields: (keyof CandidatureInsert)[] = [
|
||||
"email",
|
||||
"firstname",
|
||||
"phone",
|
||||
"persona",
|
||||
"age",
|
||||
"experience",
|
||||
"time_daily",
|
||||
"availability",
|
||||
"start_date",
|
||||
"motivation",
|
||||
"monthly_goal",
|
||||
"biggest_fear",
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field] && body[field] !== 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Le champ "${field}" est requis.` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation email basique
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Adresse email invalide." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validation âge
|
||||
if (body.age < 18 || body.age > 65) {
|
||||
return NextResponse.json(
|
||||
{ error: "L'âge doit être entre 18 et 65 ans." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier que les variables d'environnement Supabase sont configurées
|
||||
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
|
||||
console.error("Variables Supabase manquantes:", {
|
||||
url: !!process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
serviceRole: !!process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Configuration serveur incomplète. Contactez l'administrateur." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// Vérifier si une candidature existe déjà avec cet email
|
||||
const { data: existing } = await supabase
|
||||
.from("candidatures")
|
||||
.select("id")
|
||||
.eq("email", body.email)
|
||||
.single() as { data: { id: string } | null };
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Une candidature avec cet email existe déjà." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Insérer la candidature
|
||||
const candidature: CandidatureInsert = {
|
||||
email: body.email,
|
||||
firstname: body.firstname,
|
||||
phone: body.phone,
|
||||
persona: body.persona,
|
||||
age: body.age,
|
||||
experience: body.experience,
|
||||
time_daily: body.time_daily,
|
||||
availability: body.availability,
|
||||
start_date: body.start_date,
|
||||
motivation: body.motivation,
|
||||
monthly_goal: body.monthly_goal,
|
||||
biggest_fear: body.biggest_fear,
|
||||
tiktok_username: body.tiktok_username || null,
|
||||
};
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from("candidatures")
|
||||
.insert(candidature as never);
|
||||
|
||||
if (insertError) {
|
||||
console.error("Erreur insertion candidature:", JSON.stringify(insertError));
|
||||
return NextResponse.json(
|
||||
{ error: `Erreur base de données : ${insertError.message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Envoi emails (Resend)
|
||||
if (process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== "re_your-api-key") {
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL || "HookLab <onboarding@resend.dev>";
|
||||
|
||||
// Email de confirmation au candidat
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: body.email,
|
||||
subject: "Candidature HookLab reçue !",
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #6D5EF6;">Candidature reçue !</h1>
|
||||
<p>Salut ${body.firstname},</p>
|
||||
<p>Merci pour ta candidature au programme HookLab !</p>
|
||||
<p>Notre équipe va étudier ton profil et te répondre sous <strong>24 heures</strong>.</p>
|
||||
<p>À très vite,<br/>L'équipe HookLab</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error("Erreur envoi email candidat:", emailError);
|
||||
}
|
||||
|
||||
// Notification admin
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "enguerrandbusiness@outlook.com";
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `Nouvelle candidature - ${body.firstname} (${body.persona})`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#f4f4f5;font-family:Arial,Helvetica,sans-serif;">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 16px;">
|
||||
<div style="background:#ffffff;border-radius:16px;padding:32px;border:1px solid #e4e4e7;">
|
||||
<h2 style="margin:0 0 8px 0;color:#111827;font-size:20px;">Nouvelle candidature HookLab</h2>
|
||||
<p style="margin:0 0 24px 0;color:#6b7280;font-size:14px;">À traiter dans les 24h</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;width:45%;">Prénom</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;font-weight:600;">${body.firstname}</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Email</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;font-weight:600;">${body.email}</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Téléphone</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;font-weight:600;">${body.phone}</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Âge</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;">${body.age} ans</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Profil</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;">${body.persona}</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Expérience</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;">${body.experience}</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Temps / jour</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;">${body.time_daily}</td></tr>
|
||||
<tr><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:13px;">Objectif mensuel</td><td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:13px;">${body.monthly_goal}</td></tr>
|
||||
<tr><td style="padding:8px 0;vertical-align:top;color:#6b7280;font-size:13px;">Motivation</td><td style="padding:8px 0;color:#111827;font-size:13px;">${body.motivation}</td></tr>
|
||||
</table>
|
||||
<a href="${process.env.NEXT_PUBLIC_APP_URL || ""}/admin/candidatures" style="display:inline-block;margin-top:24px;background:#6D5EF6;color:#fff;padding:12px 24px;border-radius:10px;text-decoration:none;font-weight:600;font-size:14px;">Voir dans l'admin</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error("Erreur envoi email admin:", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Candidature enregistrée avec succès." },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Erreur serveur candidature:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur. Veuillez réessayer." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,38 +5,39 @@ export const runtime = "nodejs";
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, phone, metier, ville } = body as {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
metier?: string;
|
||||
ville?: string;
|
||||
const { nom, telephone, email, typeProjet, description, budget, zone } = body as {
|
||||
nom?: string;
|
||||
telephone?: string;
|
||||
email?: string;
|
||||
typeProjet?: string;
|
||||
description?: string;
|
||||
budget?: string;
|
||||
zone?: string;
|
||||
};
|
||||
|
||||
if (!name || !phone || !metier || !ville) {
|
||||
if (!nom || !telephone || !typeProjet) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tous les champs sont requis." },
|
||||
{ error: "Nom, téléphone et type de projet sont requis." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "Service email non configuré." },
|
||||
{ status: 500 }
|
||||
);
|
||||
// Pas de clé API — on log simplement et on retourne succès
|
||||
console.log("Nouvelle demande devis OBC Maçonnerie:", { nom, telephone, email, typeProjet, zone });
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
}
|
||||
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const fromEmail =
|
||||
process.env.RESEND_FROM_EMAIL || "HookLab <onboarding@resend.dev>";
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "enguerrandbusiness@outlook.com";
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL || "OBC Maçonnerie <contact@obc-maconnerie.fr>";
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "contact@obc-maconnerie.fr";
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `Nouvelle demande d'audit - ${name} (${metier})`,
|
||||
subject: `Nouvelle demande de devis — ${nom} (${typeProjet})`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -44,26 +45,43 @@ export async function POST(request: Request) {
|
||||
<body style="margin:0;padding:0;background:#f4f4f5;font-family:Arial,Helvetica,sans-serif;">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 16px;">
|
||||
<div style="background:#ffffff;border-radius:16px;padding:32px;border:1px solid #e4e4e7;">
|
||||
<h2 style="margin:0 0 24px 0;color:#111827;font-size:20px;">Nouvelle demande d'audit gratuit</h2>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:24px;">
|
||||
<div style="width:40px;height:40px;background:#1B2A4A;border-radius:8px;display:flex;align-items:center;justify-content:center;">
|
||||
<span style="color:#E8772E;font-weight:bold;font-size:11px;">OBC</span>
|
||||
</div>
|
||||
<h2 style="margin:0;color:#111827;font-size:18px;">Nouvelle demande de devis</h2>
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;width:40%;">Nom</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;font-weight:600;">${name}</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;font-weight:600;">${nom}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;">Téléphone</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;font-weight:600;">${phone}</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#E8772E;font-size:14px;font-weight:600;">${telephone}</td>
|
||||
</tr>
|
||||
${email ? `<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;">Email</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;">${email}</td>
|
||||
</tr>` : ""}
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;">Métier</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;font-weight:600;">${metier}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#6b7280;font-size:14px;">Ville / Zone</td>
|
||||
<td style="padding:10px 0;color:#111827;font-size:14px;font-weight:600;">${ville}</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;">Type de projet</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;font-weight:600;">${typeProjet}</td>
|
||||
</tr>
|
||||
${zone ? `<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;">Zone</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;">${zone}</td>
|
||||
</tr>` : ""}
|
||||
${budget ? `<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:14px;">Budget</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;">${budget}</td>
|
||||
</tr>` : ""}
|
||||
${description ? `<tr>
|
||||
<td style="padding:10px 0;color:#6b7280;font-size:14px;vertical-align:top;">Description</td>
|
||||
<td style="padding:10px 0;color:#111827;font-size:14px;">${description}</td>
|
||||
</tr>` : ""}
|
||||
</table>
|
||||
<p style="margin:24px 0 0 0;color:#6b7280;font-size:13px;">Reçu le ${new Date().toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
|
||||
<p style="margin:24px 0 0 0;color:#9ca3af;font-size:12px;">Reçu le ${new Date().toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -73,9 +91,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error("Erreur API contact:", err);
|
||||
console.error("Erreur API contact OBC:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur. Veuillez réessayer." },
|
||||
{ error: "Erreur serveur. Appelez le 06 74 45 30 89." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { Module, UserProgress } from "@/types/database.types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/formations/[moduleId] - Récupérer un module
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ moduleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { moduleId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
// Vérifier l'authentification
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Non authentifie." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier l'abonnement actif
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("subscription_status")
|
||||
.eq("id", user.id)
|
||||
.single() as { data: { subscription_status: string } | null };
|
||||
|
||||
if (!profile || profile.subscription_status !== "active") {
|
||||
return NextResponse.json(
|
||||
{ error: "Abonnement inactif." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer le module
|
||||
const { data: module, error } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("id", moduleId)
|
||||
.eq("is_published", true)
|
||||
.single() as { data: Module | null; error: unknown };
|
||||
|
||||
if (error || !module) {
|
||||
return NextResponse.json(
|
||||
{ error: "Module non trouve." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer la progression
|
||||
const { data: progress } = await supabase
|
||||
.from("user_progress")
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.eq("module_id", moduleId)
|
||||
.single() as { data: UserProgress | null };
|
||||
|
||||
return NextResponse.json({ module, progress });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { DEFAULT_IMAGES } from "@/lib/site-images";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BUCKET = "private-gallery";
|
||||
// Signed URL valide 1h côté Supabase (sert uniquement pour le fetch interne)
|
||||
const SIGNED_URL_TTL = 3600;
|
||||
// Le navigateur/CDN met en cache la réponse 55 min
|
||||
const PROXY_CACHE_TTL = 3300;
|
||||
|
||||
async function proxyImage(
|
||||
url: string,
|
||||
cacheMaxAge: number
|
||||
): Promise<NextResponse> {
|
||||
const upstream = await fetch(url, { redirect: "follow" });
|
||||
|
||||
if (!upstream.ok) {
|
||||
return new NextResponse(null, { status: 502 });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("content-type") ?? "application/octet-stream";
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}, stale-while-revalidate=60`,
|
||||
// Empêche Google d'indexer cette route technique
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
const { key } = await params;
|
||||
|
||||
// Valider la clé (alphanumérique + underscores uniquement)
|
||||
if (!/^[a-z0-9_]+$/.test(key)) {
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
const adminClient = createAdminClient();
|
||||
|
||||
// Valeur par défaut
|
||||
let rawUrl: string = DEFAULT_IMAGES[key]?.url ?? "";
|
||||
|
||||
// Valeur en BDD (prioritaire)
|
||||
try {
|
||||
const res = await adminClient
|
||||
.from("site_images")
|
||||
.select("url")
|
||||
.eq("key", key)
|
||||
.single();
|
||||
const row = res.data as { url: string } | null;
|
||||
if (row?.url) rawUrl = row.url;
|
||||
} catch {
|
||||
// Aucune ligne trouvée ou table absente → on garde le default
|
||||
}
|
||||
|
||||
// Aucune image configurée (clé inconnue ou default vide)
|
||||
if (!rawUrl) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// ── URL externe (Unsplash, etc.) → proxy direct ───────────────────────────
|
||||
if (!rawUrl.startsWith("storage:")) {
|
||||
return proxyImage(rawUrl, 86400);
|
||||
}
|
||||
|
||||
// ── Chemin bucket privé → générer une Signed URL puis proxifier ───────────
|
||||
const filePath = rawUrl.slice("storage:".length);
|
||||
const { data, error } = await adminClient.storage
|
||||
.from(BUCKET)
|
||||
.createSignedUrl(filePath, SIGNED_URL_TTL);
|
||||
|
||||
if (error || !data?.signedUrl) {
|
||||
// Fallback sur l'image par défaut si la génération échoue
|
||||
const fallback = DEFAULT_IMAGES[key]?.url;
|
||||
if (fallback) {
|
||||
return proxyImage(fallback, 60);
|
||||
}
|
||||
return new NextResponse(null, { status: 503 });
|
||||
}
|
||||
|
||||
return proxyImage(data.signedUrl, PROXY_CACHE_TTL);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { stripe } from "@/lib/stripe/client";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, candidatureId } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email requis." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Créer ou récupérer le customer Stripe
|
||||
const customers = await stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
let customerId: string;
|
||||
if (customers.data.length > 0) {
|
||||
customerId = customers.data[0].id;
|
||||
} else {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: { candidature_id: candidatureId || "" },
|
||||
});
|
||||
customerId = customer.id;
|
||||
}
|
||||
|
||||
// Créer la session Checkout
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRICE_ID!,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
candidature_id: candidatureId || "",
|
||||
email,
|
||||
},
|
||||
success_url: `${baseUrl}/merci?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/candidature`,
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: "required",
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (err) {
|
||||
console.error("Erreur creation session Stripe:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la creation de la session de paiement." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { stripe } from "@/lib/stripe/client";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import Stripe from "stripe";
|
||||
|
||||
// Désactiver le body parser pour les webhooks Stripe
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
|
||||
if (!signature) {
|
||||
return NextResponse.json(
|
||||
{ error: "Signature manquante." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Erreur verification webhook:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Signature invalide." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
// Paiement initial réussi
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const email = session.metadata?.email || session.customer_email;
|
||||
const customerId = session.customer as string;
|
||||
|
||||
if (!email) {
|
||||
console.error("Email manquant dans la session Stripe");
|
||||
break;
|
||||
}
|
||||
|
||||
// Générer un mot de passe temporaire
|
||||
const tempPassword = generatePassword();
|
||||
|
||||
// Créer le compte utilisateur Supabase
|
||||
const { data: authUser, error: authError } =
|
||||
await supabase.auth.admin.createUser({
|
||||
email,
|
||||
password: tempPassword,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
full_name: email.split("@")[0],
|
||||
},
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
// L'utilisateur existe peut-être déjà
|
||||
console.error("Erreur creation user:", authError);
|
||||
|
||||
// Mettre à jour le profil existant si l'utilisateur existe
|
||||
const { data: existingProfile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("email", email)
|
||||
.single() as { data: { id: string } | null };
|
||||
|
||||
if (existingProfile) {
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
subscription_status: "active",
|
||||
stripe_customer_id: customerId,
|
||||
subscription_end_date: new Date(
|
||||
Date.now() + 60 * 24 * 60 * 60 * 1000 // 60 jours
|
||||
).toISOString(),
|
||||
} as never)
|
||||
.eq("id", existingProfile.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Mettre à jour le profil avec les infos Stripe
|
||||
if (authUser.user) {
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
subscription_status: "active",
|
||||
stripe_customer_id: customerId,
|
||||
subscription_end_date: new Date(
|
||||
Date.now() + 60 * 24 * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
} as never)
|
||||
.eq("id", authUser.user.id);
|
||||
|
||||
// Log du paiement
|
||||
await supabase.from("payments").insert({
|
||||
user_id: authUser.user.id,
|
||||
stripe_payment_intent_id:
|
||||
(session.payment_intent as string) || session.id,
|
||||
amount: session.amount_total || 49000,
|
||||
currency: session.currency || "eur",
|
||||
status: "succeeded",
|
||||
metadata: {
|
||||
checkout_session_id: session.id,
|
||||
candidature_id: session.metadata?.candidature_id,
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
// Envoyer email de bienvenue avec credentials
|
||||
if (
|
||||
process.env.RESEND_API_KEY &&
|
||||
process.env.RESEND_API_KEY !== "re_your-api-key"
|
||||
) {
|
||||
try {
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || "HookLab <onboarding@resend.dev>",
|
||||
to: email,
|
||||
subject: "Bienvenue dans HookLab ! Tes accès sont prêts",
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #6D5EF6;">Bienvenue dans HookLab !</h1>
|
||||
<p>Ton paiement a été confirmé. Voici tes accès :</p>
|
||||
<div style="background: #1A1F2E; padding: 20px; border-radius: 12px; margin: 20px 0;">
|
||||
<p style="color: #fff; margin: 5px 0;"><strong>Email :</strong> ${email}</p>
|
||||
<p style="color: #fff; margin: 5px 0;"><strong>Mot de passe :</strong> ${tempPassword}</p>
|
||||
</div>
|
||||
<p>Connecte-toi sur <a href="${process.env.NEXT_PUBLIC_APP_URL}/login" style="color: #6D5EF6;">hooklab.fr/login</a> pour commencer.</p>
|
||||
<p><strong>Pense à changer ton mot de passe après ta première connexion !</strong></p>
|
||||
<p>À très vite,<br/>L'équipe HookLab</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error("Erreur envoi email welcome:", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Renouvellement mensuel réussi
|
||||
case "invoice.paid": {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const customerId = invoice.customer as string;
|
||||
|
||||
// Mettre à jour la date de fin d'abonnement
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("stripe_customer_id", customerId)
|
||||
.single() as { data: { id: string } | null };
|
||||
|
||||
if (profile) {
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
subscription_status: "active",
|
||||
subscription_end_date: new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
} as never)
|
||||
.eq("id", profile.id);
|
||||
|
||||
// Log du paiement
|
||||
const invoicePI = (invoice as unknown as Record<string, unknown>).payment_intent;
|
||||
await supabase.from("payments").insert({
|
||||
user_id: profile.id,
|
||||
stripe_payment_intent_id:
|
||||
(invoicePI as string) || invoice.id,
|
||||
amount: invoice.amount_paid,
|
||||
currency: invoice.currency,
|
||||
status: "succeeded",
|
||||
metadata: { invoice_id: invoice.id },
|
||||
} as never);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Abonnement annulé
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const customerId = subscription.customer as string;
|
||||
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({ subscription_status: "cancelled" } as never)
|
||||
.eq("stripe_customer_id", customerId);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Webhook non géré: ${event.type}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (err) {
|
||||
console.error("Erreur traitement webhook:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur traitement webhook." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Générateur de mot de passe temporaire — crypto.getRandomValues() uniquement
|
||||
// (cryptographiquement sûr, contrairement à Math.random())
|
||||
function generatePassword(): string {
|
||||
const chars =
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
|
||||
const randomBytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
return Array.from(randomBytes.slice(0, 12))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user