- {form.content_type === "video"
- ? "Utilise l'URL d'intégration YouTube (embed). Ex : https://www.youtube.com/embed/VIDEO_ID"
- : "Lien direct vers le fichier ou la page."}
-
- Uploadez vos fichiers directement dans le bucket privé Supabase. Les images sont automatiquement converties en WebP et optimisées entre 300 Ko et 1 Mo.
-
-
-
- {globalMessage && (
-
- {globalMessage.text}
-
- )}
-
- {/* Info SQL */}
-
-
-
- 1. Créer la table site_images dans Supabase (SQL Editor) :
-
-
-{`CREATE TABLE site_images (
- key TEXT PRIMARY KEY,
- url TEXT NOT NULL,
- label TEXT,
- updated_at TIMESTAMPTZ DEFAULT NOW()
-);`}
-
-
-
-
- 2. Créer le bucket private-gallery (Storage → New bucket, décocher "Public") puis appliquer cette policy :
-
-
-{`-- Autoriser le service role à tout faire (uploads serveur)
-CREATE POLICY "service_role_full_access"
- ON storage.objects FOR ALL
- TO service_role
- USING (bucket_id = 'private-gallery')
- WITH CHECK (bucket_id = 'private-gallery');`}
-
- Merci d'avoir pris le temps de candidater au programme HookLab.
-
-
- 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.
-
-
- Nous te souhaitons le meilleur dans ta progression. N'hésite pas à recandidater dans quelques mois si ta situation évolue.
-
-
-
-
HookLab - Programme TikTok Shop
-
-
-
-
- `,
- });
- } catch (emailError) {
- console.error("Erreur envoi email rejet:", emailError);
- }
- }
-
- return NextResponse.json({ success: true, message: "Candidature rejetée." });
-}
diff --git a/app/api/admin/candidatures/route.ts b/app/api/admin/candidatures/route.ts
deleted file mode 100644
index 6b147f4..0000000
--- a/app/api/admin/candidatures/route.ts
+++ /dev/null
@@ -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 });
-}
diff --git a/app/api/admin/modules/[id]/route.ts b/app/api/admin/modules/[id]/route.ts
deleted file mode 100644
index 271dfff..0000000
--- a/app/api/admin/modules/[id]/route.ts
+++ /dev/null
@@ -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 = {};
- 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é." });
-}
diff --git a/app/api/admin/modules/route.ts b/app/api/admin/modules/route.ts
deleted file mode 100644
index 31f9ad0..0000000
--- a/app/api/admin/modules/route.ts
+++ /dev/null
@@ -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 });
-}
diff --git a/app/api/admin/setup/route.ts b/app/api/admin/setup/route.ts
deleted file mode 100644
index 267201e..0000000
--- a/app/api/admin/setup/route.ts
+++ /dev/null
@@ -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.",
- });
-}
diff --git a/app/api/admin/site-images/route.ts b/app/api/admin/site-images/route.ts
deleted file mode 100644
index ac0cbc6..0000000
--- a/app/api/admin/site-images/route.ts
+++ /dev/null
@@ -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 | 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 {
- 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 });
-}
diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts
deleted file mode 100644
index 73702f8..0000000
--- a/app/api/admin/upload/route.ts
+++ /dev/null
@@ -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 | 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>;
- 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})`,
- },
- });
-}
diff --git a/app/api/candidature/route.ts b/app/api/candidature/route.ts
deleted file mode 100644
index 211605c..0000000
--- a/app/api/candidature/route.ts
+++ /dev/null
@@ -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 ";
-
- // Email de confirmation au candidat
- try {
- await resend.emails.send({
- from: fromEmail,
- to: body.email,
- subject: "Candidature HookLab reçue !",
- html: `
-
-
Candidature reçue !
-
Salut ${body.firstname},
-
Merci pour ta candidature au programme HookLab !
-
Notre équipe va étudier ton profil et te répondre sous 24 heures.
+ OBC Maçonnerie réalise vos travaux d'assainissement non collectif dans le Nord — fosse toutes eaux, micro-station, épandage, réhabilitation. Benoît Colin vous accompagne de l'étude de votre terrain jusqu'à la réception des travaux.
+
+
+ Que vous ayez besoin d'une mise aux normes suite à un contrôle SPANC, d'une nouvelle installation pour une construction neuve ou d'une réhabilitation de l'existant, OBC Maçonnerie intervient à Orchies, Douai, Valenciennes, Mouchin et dans toutes les communes avoisinantes.
+
+
+
+
+
+
+
+
+
+
Votre projet assainissement
+
Devis gratuit — Réponse sous 24h
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx
new file mode 100644
index 0000000..5ba7af0
--- /dev/null
+++ b/app/blog/[slug]/page.tsx
@@ -0,0 +1,221 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import Navbar from "@/components/marketing/Navbar";
+import Footer from "@/components/marketing/Footer";
+import ScrollReveal from "@/components/animations/ScrollReveal";
+
+type Props = { params: Promise<{ slug: string }> };
+
+const articles: Record<
+ string,
+ {
+ titre: string;
+ description: string;
+ cat: string;
+ date: string;
+ readTime: string;
+ contenu: string[];
+ }
+> = {
+ "combien-coute-construction-maison-nord": {
+ titre: "Combien coûte la construction d'une maison dans le Nord en 2025 ?",
+ description:
+ "Budget, matériaux, terrain, main-d'œuvre — tout ce qu'il faut savoir pour estimer le coût de votre construction neuve dans le Nord.",
+ cat: "Construction",
+ date: "15 février 2025",
+ readTime: "6 min",
+ contenu: [
+ "La construction d'une maison individuelle dans le Nord représente un investissement significatif. En 2025, le coût moyen se situe entre 1 200 € et 1 800 € par m² hors terrain et hors raccordements, selon les matériaux choisis et la complexité du projet.",
+ "**Le prix du terrain** est souvent la première variable. Dans le secteur d'Orchies et de Mouchin, comptez entre 50 000 € et 120 000 € pour une parcelle constructible de 400 à 600 m².",
+ "**Le gros œuvre** (fondations, murs, dalle, toiture) représente environ 40 à 50% du budget total. C'est là qu'intervient OBC Maçonnerie avec son savoir-faire et son réseau de partenaires pour optimiser les coûts sans sacrifier la qualité.",
+ "**Les finitions et corps de métier** (électricité, plomberie, chauffage, isolation, menuiserie, carrelage, peinture) représentent les 50 à 60% restants.",
+ "Pour un projet de 100 m² habitable à Orchies ou Douai, un budget total entre 180 000 € et 280 000 € (hors terrain) est réaliste selon le niveau de finition souhaité.",
+ "Le meilleur conseil : contactez Benoît Colin pour une évaluation gratuite de votre projet. Il vous donnera une estimation précise adaptée à votre terrain et vos envies.",
+ ],
+ },
+ "etapes-renovation-maison-ancienne": {
+ titre: "Les étapes clés d'une rénovation de maison ancienne",
+ description:
+ "Vous avez acheté une maison ancienne dans le Nord et vous voulez la rénover ? Voici les étapes indispensables pour réussir votre projet.",
+ cat: "Rénovation",
+ date: "8 janvier 2025",
+ readTime: "5 min",
+ contenu: [
+ "Rénover une maison ancienne dans le Nord demande une méthodologie rigoureuse. Voici les grandes étapes pour mener votre projet à bien.",
+ "**1. Le diagnostic** : Avant tout, il faut évaluer l'état du bâtiment. Charpente, toiture, murs porteurs, fondations, réseaux électriques et plomberie — tout doit être passé en revue. Un maçon expérimenté comme Benoît Colin peut repérer les problèmes invisibles à l'œil nu.",
+ "**2. La démolition et le curage** : On enlève ce qui est vétuste ou inadapté — cloisons obsolètes, chapes abîmées, enduits défaillants — pour repartir sur de bonnes bases.",
+ "**3. Le gros œuvre** : Reprises de fondations si nécessaire, traitement des murs humides, création d'ouvertures, modification de la structure. C'est le cœur du métier d'OBC Maçonnerie.",
+ "**4. Les corps de métier** : Électricité, plomberie, chauffage, isolation. Grâce à son réseau de partenaires, Benoît coordonne chaque intervention dans le bon ordre.",
+ "**5. Les finitions** : Menuiseries, carrelage, peinture, revêtements de sol. La touche finale qui donne tout son caractère à votre maison rénovée.",
+ "Chaque rénovation est unique. Contactez OBC Maçonnerie pour une évaluation gratuite de votre projet.",
+ ],
+ },
+ "assainissement-non-collectif-obligations": {
+ titre: "Assainissement non collectif : vos obligations légales",
+ description:
+ "Contrôle SPANC, mise aux normes, vente immobilière — tout ce que vous devez savoir sur l'assainissement non collectif.",
+ cat: "Assainissement",
+ date: "20 décembre 2024",
+ readTime: "4 min",
+ contenu: [
+ "En France, environ 5 millions de logements sont équipés d'un assainissement non collectif (ANC). Si votre maison n'est pas raccordée au réseau public, vous êtes soumis à des obligations précises.",
+ "**Le contrôle SPANC** : Le Service Public d'Assainissement Non Collectif peut contrôler votre installation. En cas de non-conformité, vous avez en principe 4 ans pour mettre aux normes, ou moins si vous vendez le bien.",
+ "**La vente immobilière** : Depuis 2011, un diagnostic d'assainissement est obligatoire lors de toute vente. S'il révèle une non-conformité, l'acheteur doit réaliser les travaux dans l'année suivant l'acte de vente.",
+ "**Les principales normes** : Votre installation doit traiter correctement les eaux usées avant rejet dans le sol. Les normes imposent une fosse toutes eaux (ou une micro-station) et un dispositif d'épandage adapté à la surface disponible.",
+ "**OBC Maçonnerie vous accompagne** dans la mise aux normes ou la création de votre système d'assainissement non collectif. Nous intervenons sur Orchies, Douai, Valenciennes et toute la zone.",
+ ],
+ },
+ "ossature-bois-avantages": {
+ titre: "Ossature bois : pourquoi choisir ce mode constructif ?",
+ description:
+ "Légèreté, performance thermique, rapidité de construction — l'ossature bois a de nombreux avantages. OBC Maçonnerie vous explique.",
+ cat: "Construction",
+ date: "5 novembre 2024",
+ readTime: "5 min",
+ contenu: [
+ "La construction en ossature bois connaît un vrai succès dans le Nord. Et pour cause : ce mode constructif présente de nombreux avantages techniques et économiques.",
+ "**Performance thermique** : Le bois est un excellent isolant naturel. Une construction ossature bois bien conçue atteint facilement les exigences RE2020.",
+ "**Rapidité de chantier** : Les éléments préfabriqués permettent de monter les murs en quelques jours. Le clos-couvert est obtenu très rapidement.",
+ "**Légèreté** : L'ossature bois pèse 5 à 8 fois moins qu'une construction maçonnée, ce qui allège les fondations — un avantage sur les terrains argileux fréquents dans le Nord.",
+ "**Polyvalence architecturale** : L'ossature bois permet des formes architecturales variées, des larges baies vitrées et une grande liberté de conception.",
+ "**La combinaison idéale** : OBC Maçonnerie maîtrise la construction ossature bois et la maçonnerie traditionnelle. Benoît vous conseille sur la solution la plus adaptée à votre terrain et vos envies.",
+ ],
+ },
+ "travaux-renovation-sans-permis-construction": {
+ titre: "Quels travaux de rénovation ne nécessitent pas de permis ?",
+ description:
+ "Permis de construire, déclaration préalable, simple déclaration — on vous explique les règles selon la nature de vos travaux.",
+ cat: "Rénovation",
+ date: "18 octobre 2024",
+ readTime: "4 min",
+ contenu: [
+ "Avant de démarrer des travaux de rénovation, il est important de savoir si vous avez besoin d'une autorisation administrative.",
+ "**Aucune démarche requise** : Les travaux purement intérieurs (peinture, revêtements, redistribution de cloisons non porteuses, remplacement de fenêtres à l'identique) ne nécessitent généralement aucune démarche.",
+ "**Déclaration préalable** : Pour les extensions jusqu'à 40 m² (en zone urbaine PLU), les changements de façade, les travaux modifiant l'aspect extérieur.",
+ "**Permis de construire** : Obligatoire pour les extensions de plus de 40 m², la création d'une surface de plancher supérieure à 20 m² en dehors des zones PLU, ou les changements de destination.",
+ "**Cas des zones protégées** : Si votre maison est en zone ABF (Architecte des Bâtiments de France), les règles sont plus strictes. Renseignez-vous en mairie.",
+ "En cas de doute, OBC Maçonnerie vous accompagne dans vos démarches administratives. Benoît connaît bien les règles locales dans le secteur d'Orchies, Douai et Valenciennes.",
+ ],
+ },
+ "fondations-maison-quels-types": {
+ titre: "Les différents types de fondations pour une maison",
+ description:
+ "Semelles filantes, radier, pieux — quelles fondations choisir selon votre terrain et votre projet de construction ?",
+ cat: "Construction",
+ date: "2 septembre 2024",
+ readTime: "5 min",
+ contenu: [
+ "Les fondations sont la base de toute construction. Mal dimensionnées ou inadaptées au sol, elles peuvent entraîner des désordres graves. Voici les principaux types.",
+ "**Les semelles filantes** : Le type le plus courant pour les maisons individuelles. Elles répartissent les charges des murs porteurs sur une bande de terrain. Adaptées aux sols stables et homogènes.",
+ "**Le radier** : Une dalle béton armé qui couvre toute la surface de la maison. Recommandé sur les terrains argileux, instables ou avec présence d'eau. Fréquent dans certains secteurs du Nord.",
+ "**Les pieux** : Utilisés quand le sol de surface est insuffisant pour porter la maison. Des pieux sont enfoncés jusqu'à une couche de sol plus résistante.",
+ "**L'étude de sol** : Avant toute construction, une étude géotechnique (étude de sol) est vivement recommandée — et même obligatoire dans certains cas (zones argileuses). Elle permet de choisir le bon type de fondations.",
+ "OBC Maçonnerie réalise vos fondations avec rigueur, après analyse du terrain. Benoît vous conseille sur la solution la plus adaptée à votre projet dans le Nord.",
+ ],
+ },
+};
+
+export async function generateStaticParams() {
+ return Object.keys(articles).map((slug) => ({ slug }));
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { slug } = await params;
+ const article = articles[slug];
+ if (!article) return { title: "Article introuvable" };
+ return {
+ title: article.titre,
+ description: article.description,
+ alternates: { canonical: `https://obc-maconnerie.fr/blog/${slug}` },
+ };
+}
+
+export default async function BlogArticlePage({ params }: Props) {
+ const { slug } = await params;
+ const article = articles[slug];
+ if (!article) notFound();
+
+ return (
+
+
+
+
+
+ Benoît se déplace gratuitement pour évaluer votre chantier et vous donner un devis précis.
+
+
+
+ Devis gratuit
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/blog/page.tsx b/app/blog/page.tsx
new file mode 100644
index 0000000..7dff019
--- /dev/null
+++ b/app/blog/page.tsx
@@ -0,0 +1,167 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import Navbar from "@/components/marketing/Navbar";
+import Footer from "@/components/marketing/Footer";
+import ScrollReveal from "@/components/animations/ScrollReveal";
+
+export const metadata: Metadata = {
+ title: "Blog Maçonnerie & Construction | Conseils OBC Maçonnerie",
+ description:
+ "Conseils, guides et actualités sur la construction de maison, la rénovation et le gros œuvre dans le Nord (59). Blog OBC Maçonnerie par Benoît Colin.",
+ alternates: { canonical: "https://obc-maconnerie.fr/blog" },
+};
+
+const articles = [
+ {
+ slug: "combien-coute-construction-maison-nord",
+ titre: "Combien coûte la construction d'une maison dans le Nord en 2025 ?",
+ extrait:
+ "Budget, matériaux, terrain, main-d'œuvre — tout ce qu'il faut savoir pour estimer le coût de votre construction neuve dans le Nord.",
+ cat: "Construction",
+ date: "15 février 2025",
+ readTime: "6 min",
+ },
+ {
+ slug: "etapes-renovation-maison-ancienne",
+ titre: "Les étapes clés d'une rénovation de maison ancienne",
+ extrait:
+ "Vous avez acheté une maison ancienne dans le Nord et vous voulez la rénover ? Voici les étapes indispensables pour réussir votre projet.",
+ cat: "Rénovation",
+ date: "8 janvier 2025",
+ readTime: "5 min",
+ },
+ {
+ slug: "assainissement-non-collectif-obligations",
+ titre: "Assainissement non collectif : vos obligations légales",
+ extrait:
+ "Contrôle SPANC, mise aux normes, vente immobilière — tout ce que vous devez savoir sur l'assainissement non collectif.",
+ cat: "Assainissement",
+ date: "20 décembre 2024",
+ readTime: "4 min",
+ },
+ {
+ slug: "ossature-bois-avantages",
+ titre: "Ossature bois : pourquoi choisir ce mode constructif ?",
+ extrait:
+ "Légèreté, performance thermique, rapidité de construction — l'ossature bois a de nombreux avantages. OBC Maçonnerie vous explique.",
+ cat: "Construction",
+ date: "5 novembre 2024",
+ readTime: "5 min",
+ },
+ {
+ slug: "travaux-renovation-sans-permis-construction",
+ titre: "Quels travaux de rénovation ne nécessitent pas de permis ?",
+ extrait:
+ "Permis de construire, déclaration préalable, simple déclaration — on vous explique les règles selon la nature de vos travaux.",
+ cat: "Rénovation",
+ date: "18 octobre 2024",
+ readTime: "4 min",
+ },
+ {
+ slug: "fondations-maison-quels-types",
+ titre: "Les différents types de fondations pour une maison",
+ extrait:
+ "Semelles filantes, radier, pieux — quelles fondations choisir selon votre terrain et votre projet de construction ?",
+ cat: "Construction",
+ date: "2 septembre 2024",
+ readTime: "5 min",
+ },
+];
+
+const cats = ["Tous", "Construction", "Rénovation", "Assainissement"];
+
+export default function BlogPage() {
+ return (
+
+
+
+
+
+
+ Conseils & guides
+
Blog OBC Maçonnerie
+
+ Construction, rénovation, assainissement — Benoît partage son expertise pour vous aider dans vos projets.
+
+
+
+
+
+ {/* Filtres */}
+
+
+
+ {cats.map((cat) => (
+
+ {cat}
+
+ ))}
+
+
+
+
+ {/* Articles */}
+
+
+
+ {articles.map((a, i) => (
+
+
+
+ 0{i + 1}
+
+
+
+
+ {a.cat}
+
+ {a.readTime} de lecture
+
+
+ {a.titre}
+
+
{a.extrait}
+
+ {a.date}
+ Lire →
+
+
+
+
+ ))}
+
+
+
+
+ {/* CTA */}
+
+
+
+
Un projet en tête ?
+
+ Benoît vous conseille gratuitement et vous remet un devis sous 24h.
+