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:
Claude
2026-02-27 09:05:03 +00:00
parent 45d080197a
commit 3adcec00b7
113 changed files with 3134 additions and 11663 deletions

View File

@@ -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(" "),
});
}

View File

@@ -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." });
}

View File

@@ -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 });
}

View File

@@ -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é." });
}

View File

@@ -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 });
}

View File

@@ -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.",
});
}

View File

@@ -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 });
}

View File

@@ -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})`,
},
});
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}

View File

@@ -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 }
);
}
}

View File

@@ -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);
}

View File

@@ -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 }
);
}
}

View File

@@ -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("");
}