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,135 +0,0 @@
import { createClient } from "@/lib/supabase/server";
import Card from "@/components/ui/Card";
import ProgressBar from "@/components/dashboard/ProgressBar";
import ModuleCard from "@/components/dashboard/ModuleCard";
import type { Module, UserProgress, Profile } from "@/types/database.types";
export const runtime = "nodejs";
export default async function DashboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// Récupérer le profil
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user!.id)
.single() as { data: Profile | null };
// Récupérer les modules publiés
const { data: modules } = await supabase
.from("modules")
.select("*")
.eq("is_published", true)
.order("week_number", { ascending: true })
.order("order_index", { ascending: true }) as { data: Module[] | null };
// Récupérer la progression
const { data: progress } = await supabase
.from("user_progress")
.select("*")
.eq("user_id", user!.id) as { data: UserProgress[] | null };
const totalModules = modules?.length || 0;
const completedModules =
progress?.filter((p) => p.completed).length || 0;
const progressPercent =
totalModules > 0 ? (completedModules / totalModules) * 100 : 0;
// Prochain module non complété
const completedIds = new Set(
progress?.filter((p) => p.completed).map((p) => p.module_id)
);
const nextModules =
modules?.filter((m) => !completedIds.has(m.id)).slice(0, 3) || [];
return (
<div className="max-w-6xl">
{/* Header */}
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">
Bonjour {profile?.full_name?.split(" ")[0] || "!"} 👋
</h1>
<p className="text-white/60">
Voici un aperçu de ta progression dans le programme.
</p>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10">
<Card>
<p className="text-white/40 text-sm mb-1">Progression globale</p>
<p className="text-2xl font-bold text-white mb-3">
{Math.round(progressPercent)}%
</p>
<ProgressBar value={progressPercent} showPercentage={false} />
</Card>
<Card>
<p className="text-white/40 text-sm mb-1">Modules complétés</p>
<p className="text-2xl font-bold text-white">
{completedModules}
<span className="text-white/30 text-lg font-normal">
/{totalModules}
</span>
</p>
</Card>
<Card>
<p className="text-white/40 text-sm mb-1">Statut abonnement</p>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-success rounded-full" />
<p className="text-success font-semibold">Actif</p>
</div>
{profile?.subscription_end_date && (
<p className="text-white/30 text-xs mt-1">
Jusqu&apos;au{" "}
{new Date(profile.subscription_end_date).toLocaleDateString(
"fr-FR"
)}
</p>
)}
</Card>
</div>
{/* Prochains modules */}
{nextModules.length > 0 && (
<div>
<h2 className="text-xl font-bold text-white mb-4">
Continue ta formation
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{nextModules.map((module) => {
const moduleProgress = progress?.find(
(p) => p.module_id === module.id
);
return (
<ModuleCard
key={module.id}
module={module}
progress={moduleProgress}
/>
);
})}
</div>
</div>
)}
{/* Message si aucun module */}
{totalModules === 0 && (
<Card className="text-center py-12">
<div className="text-4xl mb-4">🚀</div>
<h3 className="text-white font-semibold text-lg mb-2">
Le programme arrive bientôt !
</h3>
<p className="text-white/40 text-sm max-w-md mx-auto">
Les modules de formation sont en cours de préparation. Tu seras
notifié dès qu&apos;ils seront disponibles.
</p>
</Card>
)}
</div>
);
}

View File

@@ -1,83 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import Button from "@/components/ui/Button";
interface MarkCompleteButtonProps {
moduleId: string;
userId: string;
isCompleted: boolean;
}
export default function MarkCompleteButton({
moduleId,
userId,
isCompleted: initialCompleted,
}: MarkCompleteButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [completed, setCompleted] = useState(initialCompleted);
const handleToggle = async () => {
setLoading(true);
try {
const supabase = createClient();
if (completed) {
// Marquer comme non complété
await (supabase
.from("user_progress")
.update({ completed: false, completed_at: null } as never)
.eq("user_id", userId)
.eq("module_id", moduleId));
} else {
// Marquer comme complété (upsert)
await (supabase.from("user_progress").upsert({
user_id: userId,
module_id: moduleId,
completed: true,
completed_at: new Date().toISOString(),
} as never));
}
setCompleted(!completed);
router.refresh();
} catch (err) {
console.error("Erreur mise a jour progression:", err);
} finally {
setLoading(false);
}
};
return (
<Button
onClick={handleToggle}
loading={loading}
variant={completed ? "secondary" : "primary"}
>
{completed ? (
<span className="flex items-center gap-2">
<svg
className="w-4 h-4 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Complete - Annuler
</span>
) : (
"Marquer comme complete"
)}
</Button>
);
}

View File

@@ -1,184 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import Card from "@/components/ui/Card";
import MarkCompleteButton from "./MarkCompleteButton";
import type { Module, UserProgress } from "@/types/database.types";
export const runtime = "nodejs";
interface ModulePageProps {
params: Promise<{ moduleId: string }>;
}
export default async function ModulePage({ params }: ModulePageProps) {
const { moduleId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// Récupérer le module
const { data: module } = await supabase
.from("modules")
.select("*")
.eq("id", moduleId)
.eq("is_published", true)
.single() as { data: Module | null };
if (!module) {
redirect("/formations");
}
// Récupérer la progression pour ce module
const { data: progress } = await supabase
.from("user_progress")
.select("*")
.eq("user_id", user!.id)
.eq("module_id", moduleId)
.single() as { data: UserProgress | null };
return (
<div className="max-w-4xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm mb-8">
<Link
href="/formations"
className="text-white/40 hover:text-white transition-colors"
>
Formations
</Link>
<span className="text-white/20">/</span>
<span className="text-white/40">Semaine {module.week_number}</span>
<span className="text-white/20">/</span>
<span className="text-white">{module.title}</span>
</nav>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center px-2.5 py-1 bg-primary/10 rounded-lg text-primary text-xs font-medium">
Semaine {module.week_number}
</span>
{module.content_type && (
<span className="inline-flex items-center px-2.5 py-1 bg-dark-lighter rounded-lg text-white/40 text-xs font-medium uppercase">
{module.content_type}
</span>
)}
{module.duration_minutes && (
<span className="text-white/30 text-xs">
{module.duration_minutes} min
</span>
)}
{progress?.completed && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-success/10 rounded-lg text-success text-xs font-medium">
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
Complété
</span>
)}
</div>
<h1 className="text-3xl font-bold text-white mb-3">{module.title}</h1>
{module.description && (
<p className="text-white/60 text-lg">{module.description}</p>
)}
</div>
{/* Contenu du module */}
<Card className="mb-8">
{/* Video */}
{module.content_type === "video" && module.content_url && (
<div className="aspect-video bg-dark-lighter rounded-2xl overflow-hidden mb-6">
<iframe
src={module.content_url}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={module.title}
/>
</div>
)}
{/* PDF */}
{module.content_type === "pdf" && module.content_url && (
<div className="mb-6">
<a
href={module.content_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-3 bg-primary/10 text-primary rounded-xl hover:bg-primary/20 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Télécharger le PDF
</a>
</div>
)}
{/* Placeholder si pas de contenu */}
{!module.content_url && (
<div className="aspect-video bg-dark-lighter rounded-2xl flex items-center justify-center mb-6">
<div className="text-center">
<div className="text-4xl mb-3">🎬</div>
<p className="text-white/40 text-sm">
Le contenu sera bientôt disponible
</p>
</div>
</div>
)}
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<Link
href="/formations"
className="text-white/40 hover:text-white text-sm transition-colors flex items-center gap-1"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Retour aux formations
</Link>
<MarkCompleteButton
moduleId={moduleId}
userId={user!.id}
isCompleted={progress?.completed || false}
/>
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { createClient } from "@/lib/supabase/server";
import ModuleCard from "@/components/dashboard/ModuleCard";
import ProgressBar from "@/components/dashboard/ProgressBar";
import type { Module, UserProgress } from "@/types/database.types";
export const runtime = "nodejs";
export default async function FormationsPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// Récupérer les modules publiés
const { data: modules } = await supabase
.from("modules")
.select("*")
.eq("is_published", true)
.order("week_number", { ascending: true })
.order("order_index", { ascending: true }) as { data: Module[] | null };
// Récupérer la progression
const { data: progress } = await supabase
.from("user_progress")
.select("*")
.eq("user_id", user!.id) as { data: UserProgress[] | null };
// Grouper les modules par semaine
const modulesByWeek = (modules || []).reduce(
(acc, module) => {
const week = module.week_number;
if (!acc[week]) acc[week] = [];
acc[week].push(module);
return acc;
},
{} as Record<number, Module[]>
);
const totalModules = modules?.length || 0;
const completedModules =
progress?.filter((p) => p.completed).length || 0;
const progressPercent =
totalModules > 0 ? (completedModules / totalModules) * 100 : 0;
return (
<div className="max-w-6xl">
{/* Header */}
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">Formations</h1>
<p className="text-white/60 mb-6">
Progression dans le programme HookLab - 8 semaines.
</p>
<ProgressBar
value={progressPercent}
label={`${completedModules} modules complétés sur ${totalModules}`}
/>
</div>
{/* Modules par semaine */}
{Object.entries(modulesByWeek).map(([week, weekModules]) => {
const weekCompleted =
weekModules?.filter((m) =>
progress?.find((p) => p.module_id === m.id && p.completed)
).length || 0;
const weekTotal = weekModules?.length || 0;
return (
<div key={week} className="mb-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">
Semaine {week}
</h2>
<span className="text-white/30 text-sm">
{weekCompleted}/{weekTotal} complétés
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{weekModules?.map((module) => {
const moduleProgress = progress?.find(
(p) => p.module_id === module.id
);
return (
<ModuleCard
key={module.id}
module={module}
progress={moduleProgress}
/>
);
})}
</div>
</div>
);
})}
{/* Message si aucun module */}
{totalModules === 0 && (
<div className="text-center py-20">
<div className="text-5xl mb-4">📚</div>
<h3 className="text-white font-semibold text-lg mb-2">
Aucun module disponible
</h3>
<p className="text-white/40 text-sm">
Les modules de formation seront bientôt disponibles.
</p>
</div>
)}
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import Sidebar from "@/components/dashboard/Sidebar";
import type { Profile } from "@/types/database.types";
export const runtime = "nodejs";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
// Vérifier l'authentification
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
// Récupérer le profil
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single() as { data: Profile | null };
if (!profile) {
redirect("/login");
}
// Vérifier l'abonnement actif
if (profile.subscription_status !== "active") {
redirect("/login");
}
return (
<div className="flex min-h-screen bg-dark">
<Sidebar user={profile} />
<main className="flex-1 p-6 md:p-10 overflow-y-auto">{children}</main>
</div>
);
}

View File

@@ -1,240 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
import type { Profile } from "@/types/database.types";
export default function ProfilPage() {
const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null);
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Changement de mot de passe
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
useEffect(() => {
const loadProfile = async () => {
setLoading(true);
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single() as { data: Profile | null };
if (data) {
setProfile(data);
setFullName(data.full_name || "");
}
setLoading(false);
};
loadProfile();
}, []);
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const supabase = createClient();
const { error } = await supabase
.from("profiles")
.update({ full_name: fullName } as never)
.eq("id", profile!.id);
if (error) throw error;
setMessage({ type: "success", text: "Profil mis a jour !" });
router.refresh();
} catch {
setMessage({
type: "error",
text: "Erreur lors de la mise a jour du profil.",
});
} finally {
setSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordSaving(true);
setMessage(null);
if (newPassword !== confirmNewPassword) {
setMessage({
type: "error",
text: "Les mots de passe ne correspondent pas.",
});
setPasswordSaving(false);
return;
}
if (newPassword.length < 8) {
setMessage({
type: "error",
text: "Le mot de passe doit contenir au moins 8 caracteres.",
});
setPasswordSaving(false);
return;
}
try {
const supabase = createClient();
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
if (error) throw error;
setMessage({ type: "success", text: "Mot de passe mis a jour !" });
setNewPassword("");
setConfirmNewPassword("");
} catch {
setMessage({
type: "error",
text: "Erreur lors du changement de mot de passe.",
});
} finally {
setPasswordSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="max-w-2xl">
<h1 className="text-3xl font-bold text-white mb-2">Mon profil</h1>
<p className="text-white/60 mb-10">
Gere tes informations personnelles et ton abonnement.
</p>
{/* Message */}
{message && (
<div
className={`mb-6 p-3 rounded-xl border ${
message.type === "success"
? "bg-success/10 border-success/20 text-success"
: "bg-error/10 border-error/20 text-error"
}`}
>
<p className="text-sm">{message.text}</p>
</div>
)}
{/* Informations profil */}
<Card className="mb-6">
<h2 className="text-lg font-semibold text-white mb-6">
Informations personnelles
</h2>
<form onSubmit={handleSaveProfile} className="space-y-5">
<Input
id="email"
label="Email"
type="email"
value={profile?.email || ""}
disabled
className="opacity-50"
/>
<Input
id="fullName"
label="Nom complet"
placeholder="Jean Dupont"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<Button type="submit" loading={saving}>
Sauvegarder
</Button>
</form>
</Card>
{/* Changement de mot de passe */}
<Card className="mb-6">
<h2 className="text-lg font-semibold text-white mb-6">
Changer le mot de passe
</h2>
<form onSubmit={handleChangePassword} className="space-y-5">
<Input
id="newPassword"
label="Nouveau mot de passe"
type="password"
placeholder="Minimum 8 caracteres"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
id="confirmNewPassword"
label="Confirmer le mot de passe"
type="password"
placeholder="Confirme ton nouveau mot de passe"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
/>
<Button type="submit" loading={passwordSaving} variant="secondary">
Changer le mot de passe
</Button>
</form>
</Card>
{/* Abonnement */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Abonnement</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Statut</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-success/10 rounded-lg text-success text-sm font-medium">
<span className="w-1.5 h-1.5 bg-success rounded-full" />
{profile?.subscription_status === "active"
? "Actif"
: "Inactif"}
</span>
</div>
{profile?.subscription_end_date && (
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Valide jusqu&apos;au</span>
<span className="text-white text-sm">
{new Date(profile.subscription_end_date).toLocaleDateString(
"fr-FR",
{ day: "numeric", month: "long", year: "numeric" }
)}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Plan</span>
<span className="text-white text-sm">
HookLab - Programme 8 semaines
</span>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,331 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
interface Candidature {
id: string;
email: string;
firstname: string;
phone: string;
persona: string;
age: number;
experience: string;
time_daily: string;
availability: string;
start_date: string;
motivation: string;
monthly_goal: string;
biggest_fear: string;
tiktok_username: string | null;
status: "pending" | "approved" | "rejected";
created_at: string;
}
export default function AdminCandidaturesPage() {
const [candidatures, setCandidatures] = useState<Candidature[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [checkoutUrls, setCheckoutUrls] = useState<Record<string, string>>({});
const [expandedId, setExpandedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "pending" | "approved" | "rejected">("all");
const fetchCandidatures = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/candidatures");
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setCandidatures(data.candidatures);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur de chargement");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCandidatures();
}, [fetchCandidatures]);
const handleApprove = async (id: string) => {
setActionLoading(id);
try {
const res = await fetch(`/api/admin/candidatures/${id}/approve`, {
method: "POST",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
if (data.checkoutUrl) {
setCheckoutUrls((prev) => ({ ...prev, [id]: data.checkoutUrl }));
}
// Afficher le statut détaillé
const msgs: string[] = [];
if (data.emailSent) msgs.push("Email envoyé !");
if (data.emailError) msgs.push("Email : " + data.emailError);
if (data.stripeError) msgs.push("Stripe : " + data.stripeError);
if (msgs.length > 0) {
setError(msgs.join(" | "));
}
await fetchCandidatures();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
} finally {
setActionLoading(null);
}
};
const handleReject = async (id: string) => {
if (!confirm("Rejeter cette candidature ?")) return;
setActionLoading(id);
try {
const res = await fetch(`/api/admin/candidatures/${id}/reject`, {
method: "POST",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await fetchCandidatures();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
} finally {
setActionLoading(null);
}
};
const statusColors: Record<string, string> = {
pending: "bg-warning/10 text-warning",
approved: "bg-success/10 text-success",
rejected: "bg-error/10 text-error",
};
const statusLabels: Record<string, string> = {
pending: "En attente",
approved: "Approuvée",
rejected: "Rejetée",
};
const filtered = filter === "all" ? candidatures : candidatures.filter((c) => c.status === filter);
const pending = candidatures.filter((c) => c.status === "pending");
const approved = candidatures.filter((c) => c.status === "approved");
const rejected = candidatures.filter((c) => c.status === "rejected");
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="max-w-6xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Candidatures</h1>
<p className="text-white/40 text-sm mt-1">{candidatures.length} candidature(s) au total</p>
</div>
<button
onClick={fetchCandidatures}
className="px-3 py-1.5 bg-dark-lighter border border-dark-border rounded-lg text-white/60 text-sm hover:text-white transition-colors cursor-pointer"
>
Rafraîchir
</button>
</div>
{/* Filtres */}
<div className="flex gap-2 mb-6">
{[
{ key: "all" as const, label: "Toutes", count: candidatures.length },
{ key: "pending" as const, label: "En attente", count: pending.length },
{ key: "approved" as const, label: "Approuvées", count: approved.length },
{ key: "rejected" as const, label: "Rejetées", count: rejected.length },
].map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
filter === f.key
? "bg-primary/10 text-primary"
: "bg-dark-lighter text-white/40 hover:text-white"
}`}
>
{f.label} ({f.count})
</button>
))}
</div>
{error && (
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
{/* Liste */}
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="text-white/40">Aucune candidature dans cette catégorie.</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((c) => (
<div
key={c.id}
className="bg-dark-light border border-dark-border rounded-2xl overflow-hidden"
>
{/* Header row */}
<div
className="px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-dark-lighter/50 transition-colors"
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full gradient-bg flex items-center justify-center text-sm font-bold text-white">
{c.firstname.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-white font-medium">{c.firstname}</p>
<p className="text-white/40 text-sm">{c.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-white/30 text-xs">
{new Date(c.created_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
<span className={`px-2.5 py-1 rounded-lg text-xs font-medium ${statusColors[c.status]}`}>
{statusLabels[c.status]}
</span>
<svg
className={`w-5 h-5 text-white/30 transition-transform ${expandedId === c.id ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Expanded details */}
{expandedId === c.id && (
<div className="px-6 pb-5 border-t border-dark-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
<div>
<p className="text-white/40 text-xs mb-1">Téléphone</p>
<p className="text-white text-sm">{c.phone}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Âge</p>
<p className="text-white text-sm">{c.age} ans</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Profil</p>
<p className="text-white text-sm capitalize">{c.persona}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Expérience</p>
<p className="text-white text-sm">{c.experience}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Temps disponible</p>
<p className="text-white text-sm">{c.time_daily}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Disponibilité</p>
<p className="text-white text-sm">{c.availability}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Début souhaité</p>
<p className="text-white text-sm">{c.start_date}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Objectif mensuel</p>
<p className="text-white text-sm">{c.monthly_goal}</p>
</div>
{c.tiktok_username && (
<div>
<p className="text-white/40 text-xs mb-1">TikTok</p>
<p className="text-white text-sm">{c.tiktok_username}</p>
</div>
)}
</div>
<div className="space-y-3 py-3">
<div>
<p className="text-white/40 text-xs mb-1">Motivation</p>
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.motivation}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Plus grande peur</p>
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.biggest_fear}</p>
</div>
</div>
{/* Checkout URL */}
{checkoutUrls[c.id] && (
<div className="mt-3 p-3 bg-success/10 border border-success/20 rounded-xl">
<p className="text-success text-xs font-medium mb-1">Lien de paiement généré :</p>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={checkoutUrls[c.id]}
className="flex-1 bg-dark-lighter border border-dark-border rounded-lg px-3 py-2 text-white text-xs"
/>
<button
onClick={() => navigator.clipboard.writeText(checkoutUrls[c.id])}
className="px-3 py-2 bg-success/20 text-success rounded-lg text-xs font-medium hover:bg-success/30 transition-colors cursor-pointer"
>
Copier
</button>
</div>
</div>
)}
{/* Actions */}
{c.status === "pending" && (
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
<button
onClick={() => handleApprove(c.id)}
disabled={actionLoading === c.id}
className="px-4 py-2 bg-success/10 text-success border border-success/20 rounded-xl text-sm font-medium hover:bg-success/20 transition-colors disabled:opacity-50 cursor-pointer"
>
{actionLoading === c.id ? "Approbation..." : "Approuver + Envoyer lien paiement"}
</button>
<button
onClick={() => handleReject(c.id)}
disabled={actionLoading === c.id}
className="px-4 py-2 bg-error/10 text-error border border-error/20 rounded-xl text-sm font-medium hover:bg-error/20 transition-colors disabled:opacity-50 cursor-pointer"
>
Rejeter
</button>
</div>
)}
{c.status === "approved" && !checkoutUrls[c.id] && (
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
<button
onClick={() => handleApprove(c.id)}
disabled={actionLoading === c.id}
className="px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50 cursor-pointer"
>
{actionLoading === c.id ? "Génération..." : "Regénérer le lien de paiement"}
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,563 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
interface Module {
id: string;
title: string;
description: string | null;
week_number: number;
order_index: number;
content_type: "video" | "pdf" | "text" | "quiz" | null;
content_url: string | null;
duration_minutes: number | null;
is_published: boolean;
created_at: string;
}
type FormData = {
title: string;
description: string;
week_number: number;
order_index: number;
content_type: "video" | "pdf" | "text" | "quiz" | "";
content_url: string;
duration_minutes: number | "";
is_published: boolean;
};
const emptyForm: FormData = {
title: "",
description: "",
week_number: 1,
order_index: 0,
content_type: "video",
content_url: "",
duration_minutes: "",
is_published: false,
};
export default function AdminCoursPage() {
const [modules, setModules] = useState<Module[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Vue : "list" ou "form"
const [view, setView] = useState<"list" | "form">("list");
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const fetchModules = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/admin/modules");
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setModules(data.modules);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur de chargement");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchModules();
}, [fetchModules]);
// Auto-clear success message
useEffect(() => {
if (success) {
const t = setTimeout(() => setSuccess(null), 3000);
return () => clearTimeout(t);
}
}, [success]);
const openNew = () => {
// Calculer automatiquement le prochain order_index
const maxOrder = modules.reduce((max, m) => Math.max(max, m.order_index), -1);
setForm({ ...emptyForm, order_index: maxOrder + 1 });
setEditingId(null);
setView("form");
setError(null);
};
const openEdit = (mod: Module) => {
setForm({
title: mod.title,
description: mod.description || "",
week_number: mod.week_number,
order_index: mod.order_index,
content_type: mod.content_type || "",
content_url: mod.content_url || "",
duration_minutes: mod.duration_minutes ?? "",
is_published: mod.is_published,
});
setEditingId(mod.id);
setView("form");
setError(null);
};
const handleSave = async () => {
if (!form.title.trim()) {
setError("Le titre est obligatoire.");
return;
}
setSaving(true);
setError(null);
try {
const payload = {
title: form.title.trim(),
description: form.description.trim() || null,
week_number: form.week_number,
order_index: form.order_index,
content_type: form.content_type || null,
content_url: form.content_url.trim() || null,
duration_minutes: form.duration_minutes === "" ? null : Number(form.duration_minutes),
is_published: form.is_published,
};
let res: Response;
if (editingId) {
res = await fetch(`/api/admin/modules/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
res = await fetch("/api/admin/modules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setSuccess(editingId ? "Cours mis à jour !" : "Cours créé !");
setView("list");
await fetchModules();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/admin/modules/${id}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setSuccess("Cours supprimé !");
setDeleteConfirm(null);
await fetchModules();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
}
};
const handleTogglePublish = async (mod: Module) => {
try {
const res = await fetch(`/api/admin/modules/${mod.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_published: !mod.is_published }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await fetchModules();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
}
};
const contentTypeLabels: Record<string, string> = {
video: "Vidéo",
pdf: "PDF",
text: "Texte",
quiz: "Quiz",
};
// Grouper par semaine pour l'affichage liste
const modulesByWeek = modules.reduce(
(acc, mod) => {
const w = mod.week_number;
if (!acc[w]) acc[w] = [];
acc[w].push(mod);
return acc;
},
{} as Record<number, Module[]>
);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
// ========== FORMULAIRE ==========
if (view === "form") {
return (
<div className="max-w-2xl">
<button
onClick={() => setView("list")}
className="text-white/40 hover:text-white text-sm transition-colors mb-6 flex items-center gap-1 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Retour à la liste
</button>
<h1 className="text-2xl font-bold text-white mb-8">
{editingId ? "Modifier le cours" : "Nouveau cours"}
</h1>
{error && (
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Titre */}
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
Titre du cours *
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="Ex : Introduction au TikTok Shop"
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
Description
</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
placeholder="Description courte du module..."
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary resize-none"
/>
</div>
{/* Semaine + Ordre */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
Semaine
</label>
<select
value={form.week_number}
onChange={(e) => setForm({ ...form, week_number: Number(e.target.value) })}
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white text-sm focus:outline-none focus:border-primary"
>
{[1, 2, 3, 4, 5, 6, 7, 8].map((w) => (
<option key={w} value={w}>
Semaine {w}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
Ordre d&apos;affichage
</label>
<input
type="number"
value={form.order_index}
onChange={(e) => setForm({ ...form, order_index: Number(e.target.value) })}
min={0}
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white text-sm focus:outline-none focus:border-primary"
/>
</div>
</div>
{/* Type de contenu */}
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
Type de contenu
</label>
<div className="grid grid-cols-4 gap-2">
{(["video", "pdf", "text", "quiz"] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => setForm({ ...form, content_type: type })}
className={`px-3 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer ${
form.content_type === type
? "bg-primary/10 text-primary border border-primary/30"
: "bg-dark-lighter text-white/40 border border-dark-border hover:text-white"
}`}
>
{contentTypeLabels[type]}
</button>
))}
</div>
</div>
{/* URL du contenu */}
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
URL du contenu
</label>
<input
type="url"
value={form.content_url}
onChange={(e) => setForm({ ...form, content_url: e.target.value })}
placeholder={
form.content_type === "video"
? "https://www.youtube.com/embed/..."
: form.content_type === "pdf"
? "https://drive.google.com/file/..."
: "https://..."
}
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
/>
<p className="text-white/20 text-xs mt-1">
{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."}
</p>
</div>
{/* Durée */}
<div>
<label className="block text-sm font-medium text-white/80 mb-1.5">
Durée (minutes)
</label>
<input
type="number"
value={form.duration_minutes}
onChange={(e) =>
setForm({
...form,
duration_minutes: e.target.value === "" ? "" : Number(e.target.value),
})
}
min={0}
placeholder="15"
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
/>
</div>
{/* Publié */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setForm({ ...form, is_published: !form.is_published })}
className={`relative w-12 h-6 rounded-full transition-colors cursor-pointer ${
form.is_published ? "bg-success" : "bg-dark-lighter"
}`}
>
<span
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
form.is_published ? "translate-x-6.5" : "translate-x-0.5"
}`}
/>
</button>
<span className="text-white/80 text-sm">
{form.is_published ? "Publié (visible par les élèves)" : "Brouillon (non visible)"}
</span>
</div>
{/* Boutons */}
<div className="flex items-center gap-3 pt-4 border-t border-dark-border">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-3 gradient-bg text-white font-semibold rounded-xl disabled:opacity-50 cursor-pointer"
>
{saving ? "Enregistrement..." : editingId ? "Mettre à jour" : "Créer le cours"}
</button>
<button
onClick={() => setView("list")}
className="px-6 py-3 bg-dark-lighter text-white/60 rounded-xl hover:text-white transition-colors cursor-pointer"
>
Annuler
</button>
</div>
</div>
</div>
);
}
// ========== LISTE ==========
return (
<div className="max-w-6xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Gestion des cours</h1>
<p className="text-white/40 text-sm mt-1">
{modules.length} cours · {modules.filter((m) => m.is_published).length} publiés
</p>
</div>
<button
onClick={openNew}
className="px-4 py-2.5 gradient-bg text-white font-semibold rounded-xl text-sm flex items-center gap-2 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Nouveau cours
</button>
</div>
{error && (
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
{success && (
<div className="mb-6 p-3 bg-success/10 border border-success/20 rounded-xl">
<p className="text-success text-sm">{success}</p>
</div>
)}
{modules.length === 0 ? (
<div className="text-center py-20 bg-dark-light border border-dark-border rounded-[20px]">
<div className="text-5xl mb-4">📚</div>
<h3 className="text-white font-semibold text-lg mb-2">Aucun cours</h3>
<p className="text-white/40 text-sm mb-6">Crée ton premier cours pour commencer.</p>
<button
onClick={openNew}
className="px-6 py-3 gradient-bg text-white font-semibold rounded-xl cursor-pointer"
>
Créer un cours
</button>
</div>
) : (
<div className="space-y-8">
{Object.entries(modulesByWeek)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([week, weekModules]) => (
<div key={week}>
<h2 className="text-lg font-bold text-white mb-3 flex items-center gap-2">
<span className="px-2.5 py-1 bg-primary/10 text-primary rounded-lg text-xs font-medium">
Semaine {week}
</span>
<span className="text-white/30 text-xs font-normal">
{weekModules.length} cours
</span>
</h2>
<div className="space-y-2">
{weekModules
.sort((a, b) => a.order_index - b.order_index)
.map((mod) => (
<div
key={mod.id}
className="bg-dark-light border border-dark-border rounded-xl px-5 py-4 flex items-center justify-between group"
>
<div className="flex items-center gap-4 flex-1 min-w-0">
{/* Grip handle (visuel) */}
<span className="text-white/15 group-hover:text-white/30 transition-colors">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />
</svg>
</span>
{/* Status dot */}
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${
mod.is_published ? "bg-success" : "bg-white/20"
}`}
/>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="text-white font-medium text-sm truncate">{mod.title}</p>
<div className="flex items-center gap-2 mt-0.5">
{mod.content_type && (
<span className="text-white/30 text-xs uppercase">
{contentTypeLabels[mod.content_type] || mod.content_type}
</span>
)}
{mod.duration_minutes && (
<span className="text-white/20 text-xs">{mod.duration_minutes} min</span>
)}
<span className="text-white/20 text-xs">
#{mod.order_index}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{/* Toggle publish */}
<button
onClick={() => handleTogglePublish(mod)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer ${
mod.is_published
? "bg-success/10 text-success hover:bg-success/20"
: "bg-white/5 text-white/30 hover:text-white hover:bg-white/10"
}`}
>
{mod.is_published ? "Publié" : "Brouillon"}
</button>
{/* Edit */}
<button
onClick={() => openEdit(mod)}
className="p-2 text-white/30 hover:text-primary transition-colors cursor-pointer rounded-lg hover:bg-primary/5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{/* Delete */}
{deleteConfirm === mod.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(mod.id)}
className="px-2 py-1 bg-error/10 text-error rounded-lg text-xs font-medium cursor-pointer hover:bg-error/20"
>
Confirmer
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="px-2 py-1 text-white/30 text-xs cursor-pointer hover:text-white"
>
Annuler
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(mod.id)}
className="p-2 text-white/30 hover:text-error transition-colors cursor-pointer rounded-lg hover:bg-error/5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,375 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface SiteImage {
key: string;
url: string; // valeur brute (ex: "storage:hero_portrait/image.jpg" ou "https://...")
previewUrl: string; // URL résolvée pour l'affichage
label: string;
updated_at: string | null;
}
type UploadState = "idle" | "uploading" | "saving" | "done" | "error";
interface ImageCardState {
editUrl: string; // valeur brute en cours d'édition
previewUrl: string; // URL pour l'aperçu
uploadState: UploadState;
uploadError: string | null;
optimizationSummary: string | null; // ex: "2 400 Ko → 680 Ko (WebP q82)"
}
export default function AdminImages() {
const [images, setImages] = useState<SiteImage[]>([]);
const [loading, setLoading] = useState(true);
const [cardState, setCardState] = useState<Record<string, ImageCardState>>({});
const [globalMessage, setGlobalMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [draggingOver, setDraggingOver] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetch("/api/admin/site-images")
.then((r) => r.json())
.then((data) => {
const imgs: SiteImage[] = data.images || [];
setImages(imgs);
const state: Record<string, ImageCardState> = {};
for (const img of imgs) {
state[img.key] = {
editUrl: img.url,
previewUrl: img.previewUrl,
uploadState: "idle",
uploadError: null,
optimizationSummary: null,
};
}
setCardState(state);
})
.finally(() => setLoading(false));
}, []);
const updateCard = useCallback((key: string, patch: Partial<ImageCardState>) => {
setCardState((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
}, []);
// Upload d'un fichier + sauvegarde automatique
const handleFile = useCallback(
async (key: string, file: File) => {
updateCard(key, { uploadState: "uploading", uploadError: null });
// Aperçu local immédiat (object URL)
const localPreview = URL.createObjectURL(file);
updateCard(key, { previewUrl: localPreview });
try {
const form = new FormData();
form.append("file", file);
form.append("key", key);
const uploadRes = await fetch("/api/admin/upload", { method: "POST", body: form });
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
updateCard(key, { uploadState: "error", uploadError: uploadData.error || "Erreur upload" });
return;
}
const { storagePath, optimization } = uploadData as {
storagePath: string;
optimization?: { summary: string; inRange: boolean };
};
// Sauvegarde automatique en BDD
updateCard(key, { uploadState: "saving" });
const saveRes = await fetch("/api/admin/site-images", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, url: storagePath }),
});
const saveData = await saveRes.json();
if (!saveRes.ok) {
updateCard(key, { uploadState: "error", uploadError: saveData.error || "Erreur sauvegarde" });
return;
}
// Succès : mettre à jour l'état
updateCard(key, {
editUrl: storagePath,
uploadState: "done",
uploadError: null,
optimizationSummary: optimization?.summary ?? null,
});
setImages((prev) =>
prev.map((img) =>
img.key === key
? { ...img, url: storagePath, previewUrl: localPreview, updated_at: new Date().toISOString() }
: img
)
);
const successMsg = optimization
? `"${key}" sauvegardé — ${optimization.summary}`
: `"${key}" uploadé et sauvegardé !`;
setGlobalMessage({ type: "success", text: successMsg });
setTimeout(() => updateCard(key, { uploadState: "idle", optimizationSummary: null }), 5000);
} catch {
updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" });
}
},
[updateCard]
);
// Sauvegarde manuelle d'une URL externe
const handleSaveUrl = useCallback(
async (key: string) => {
const url = cardState[key]?.editUrl;
if (!url) return;
updateCard(key, { uploadState: "saving", uploadError: null });
setGlobalMessage(null);
try {
const res = await fetch("/api/admin/site-images", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, url }),
});
const data = await res.json();
if (res.ok) {
updateCard(key, { previewUrl: url, uploadState: "done" });
setImages((prev) =>
prev.map((img) =>
img.key === key
? { ...img, url, previewUrl: url, updated_at: new Date().toISOString() }
: img
)
);
setGlobalMessage({ type: "success", text: `"${key}" mis à jour !` });
setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000);
} else {
updateCard(key, { uploadState: "error", uploadError: data.error || "Erreur" });
}
} catch {
updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" });
}
},
[cardState, updateCard]
);
const handleDrop = useCallback(
(key: string, e: React.DragEvent) => {
e.preventDefault();
setDraggingOver(null);
const file = e.dataTransfer.files[0];
if (file) handleFile(key, file);
},
[handleFile]
);
const handleFileInputChange = useCallback(
(key: string, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(key, file);
// Reset input pour permettre de re-sélectionner le même fichier
e.target.value = "";
},
[handleFile]
);
if (loading) {
return (
<div className="max-w-4xl">
<h1 className="text-3xl font-bold text-white mb-2">Images du site</h1>
<p className="text-white/60">Chargement...</p>
</div>
);
}
return (
<div className="max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Images du site</h1>
<p className="text-white/60">
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.
</p>
</div>
{globalMessage && (
<div
className={`mb-6 p-4 rounded-xl text-sm font-medium ${
globalMessage.type === "success" ? "bg-success/10 text-success" : "bg-red-500/10 text-red-400"
}`}
>
{globalMessage.text}
</div>
)}
{/* Info SQL */}
<div className="mb-8 bg-dark-light border border-dark-border rounded-[20px] p-6 space-y-4">
<div>
<p className="text-white/50 text-xs mb-2 font-medium">
1. Créer la table <code className="text-orange">site_images</code> dans Supabase (SQL Editor) :
</p>
<pre className="bg-black/30 rounded-lg p-3 text-xs text-green-400 overflow-x-auto">
{`CREATE TABLE site_images (
key TEXT PRIMARY KEY,
url TEXT NOT NULL,
label TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);`}
</pre>
</div>
<div>
<p className="text-white/50 text-xs mb-2 font-medium">
2. Créer le bucket <code className="text-orange">private-gallery</code> (Storage New bucket, décocher &quot;Public&quot;) puis appliquer cette policy :
</p>
<pre className="bg-black/30 rounded-lg p-3 text-xs text-green-400 overflow-x-auto">
{`-- 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');`}
</pre>
</div>
</div>
<div className="space-y-6">
{images.map((img) => {
const state = cardState[img.key];
if (!state) return null;
const isUploading = state.uploadState === "uploading";
const isSaving = state.uploadState === "saving";
const isBusy = isUploading || isSaving;
const isDone = state.uploadState === "done";
const isError = state.uploadState === "error";
const isStoredInBucket = state.editUrl.startsWith("storage:");
const urlChanged = state.editUrl !== img.url;
return (
<div key={img.key} className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<div className="flex items-start gap-6">
{/* Preview */}
<div className="w-32 h-24 rounded-xl overflow-hidden bg-black/30 shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={state.previewUrl}
alt={img.label}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23666' viewBox='0 0 24 24'%3E%3Cpath d='M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z'/%3E%3C/svg%3E";
}}
/>
</div>
{/* Contenu */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-white font-semibold text-sm">{img.label}</h3>
<span className="text-white/20 text-xs font-mono">{img.key}</span>
{isStoredInBucket && (
<span className="text-xs bg-orange/10 text-orange border border-orange/20 rounded-full px-2 py-0.5">
bucket privé
</span>
)}
</div>
{img.updated_at && (
<p className="text-white/30 text-xs mb-3">
Modifié le{" "}
{new Date(img.updated_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
{/* Zone drag & drop */}
<div
className={`mb-3 border-2 border-dashed rounded-xl p-4 text-center transition-colors cursor-pointer ${
draggingOver === img.key
? "border-orange bg-orange/5"
: "border-dark-border hover:border-orange/40 hover:bg-white/2"
} ${isBusy ? "opacity-50 pointer-events-none" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDraggingOver(img.key);
}}
onDragLeave={() => setDraggingOver(null)}
onDrop={(e) => handleDrop(img.key, e)}
onClick={() => fileInputRefs.current[img.key]?.click()}
>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
ref={(el) => { fileInputRefs.current[img.key] = el; }}
onChange={(e) => handleFileInputChange(img.key, e)}
/>
{isUploading ? (
<p className="text-orange text-xs font-medium">Optimisation et upload en cours...</p>
) : isSaving ? (
<p className="text-orange text-xs font-medium">Sauvegarde...</p>
) : isDone ? (
<div className="space-y-0.5">
<p className="text-success text-xs font-medium">Fichier enregistré !</p>
{state.optimizationSummary && (
<p className="text-white/40 text-xs">{state.optimizationSummary}</p>
)}
</div>
) : (
<div className="space-y-1">
<p className="text-white/50 text-xs">
<span className="text-white/80 font-medium">Glissez une image</span> ou{" "}
<span className="text-orange font-medium underline">parcourir</span>
</p>
<p className="text-white/25 text-xs">JPEG · PNG · WebP · AVIF converti en WebP, max 20 Mo</p>
</div>
)}
</div>
{isError && (
<p className="text-red-400 text-xs mb-2">{state.uploadError}</p>
)}
{/* URL externe (fallback) */}
<div className="flex gap-2">
<input
type="url"
value={state.editUrl.startsWith("storage:") ? "" : state.editUrl}
onChange={(e) =>
updateCard(img.key, { editUrl: e.target.value, previewUrl: e.target.value })
}
placeholder="Ou coller une URL externe (https://...)"
disabled={isBusy}
className="flex-1 px-4 py-2.5 bg-black/30 border border-dark-border rounded-xl text-white text-sm placeholder:text-white/20 focus:border-orange focus:ring-1 focus:ring-orange outline-none disabled:opacity-40"
/>
<button
onClick={() => handleSaveUrl(img.key)}
disabled={
isBusy ||
!state.editUrl ||
state.editUrl.startsWith("storage:") ||
!urlChanged
}
className="px-5 py-2.5 bg-orange hover:bg-orange/90 disabled:opacity-30 text-white font-semibold text-sm rounded-xl transition-colors cursor-pointer disabled:cursor-not-allowed shrink-0"
>
{isSaving ? "..." : "Sauver"}
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { redirect } from "next/navigation";
import { createClient, createAdminClient } from "@/lib/supabase/server";
import AdminShell from "@/components/admin/AdminShell";
import type { Profile } from "@/types/database.types";
export const runtime = "nodejs";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
// Vérifier l'authentification
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login?redirect=/admin");
}
// Vérifier le statut admin via service role (pas de RLS)
const adminClient = createAdminClient();
const { data: profile } = await adminClient
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
const typedProfile = profile as Profile | null;
if (!typedProfile || !typedProfile.is_admin) {
redirect("/");
}
return (
<AdminShell
adminName={typedProfile.full_name || "Admin"}
adminEmail={typedProfile.email}
>
{children}
</AdminShell>
);
}

View File

@@ -1,143 +0,0 @@
import { createAdminClient } from "@/lib/supabase/server";
import Link from "next/link";
export const runtime = "nodejs";
export default async function AdminDashboard() {
const supabase = createAdminClient();
// Récupérer les stats en parallèle
const [candidaturesRes, modulesRes, profilesRes] = await Promise.all([
supabase.from("candidatures").select("*"),
supabase.from("modules").select("*"),
supabase.from("profiles").select("*"),
]);
const candidatures = (candidaturesRes.data || []) as { id: string; status: string; created_at: string }[];
const modules = (modulesRes.data || []) as { id: string; is_published: boolean }[];
const profiles = (profilesRes.data || []) as { id: string; subscription_status: string; created_at: string }[];
const pendingCount = candidatures.filter((c) => c.status === "pending").length;
const approvedCount = candidatures.filter((c) => c.status === "approved").length;
const publishedModules = modules.filter((m) => m.is_published).length;
const activeUsers = profiles.filter((p) => p.subscription_status === "active").length;
// Candidatures récentes (5 dernières)
const recentCandidatures = candidatures
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 5);
return (
<div className="max-w-6xl">
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">Dashboard Admin</h1>
<p className="text-white/60">Vue d&apos;ensemble de HookLab.</p>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Candidatures en attente</p>
<p className="text-3xl font-bold text-warning">{pendingCount}</p>
<p className="text-white/30 text-xs mt-1">{candidatures.length} au total</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Candidatures approuvées</p>
<p className="text-3xl font-bold text-success">{approvedCount}</p>
<p className="text-white/30 text-xs mt-1">
{candidatures.length > 0 ? Math.round((approvedCount / candidatures.length) * 100) : 0}% de conversion
</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Utilisateurs actifs</p>
<p className="text-3xl font-bold text-primary">{activeUsers}</p>
<p className="text-white/30 text-xs mt-1">{profiles.length} inscrits</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Cours publiés</p>
<p className="text-3xl font-bold text-white">{publishedModules}</p>
<p className="text-white/30 text-xs mt-1">{modules.length} au total</p>
</div>
</div>
{/* Actions rapides */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
{/* Candidatures récentes */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white">Candidatures récentes</h2>
<Link href="/admin/candidatures" className="text-primary text-sm hover:underline">
Tout voir
</Link>
</div>
{recentCandidatures.length === 0 ? (
<p className="text-white/30 text-sm">Aucune candidature.</p>
) : (
<div className="space-y-3">
{recentCandidatures.map((c) => (
<div key={c.id} className="flex items-center justify-between">
<span className="text-white/60 text-sm truncate">
{new Date(c.created_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
})}
</span>
<span
className={`px-2 py-0.5 rounded-lg text-xs font-medium ${
c.status === "pending"
? "bg-warning/10 text-warning"
: c.status === "approved"
? "bg-success/10 text-success"
: "bg-error/10 text-error"
}`}
>
{c.status === "pending" ? "En attente" : c.status === "approved" ? "Approuvée" : "Rejetée"}
</span>
</div>
))}
</div>
)}
</div>
{/* Actions rapides */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<h2 className="text-lg font-bold text-white mb-4">Actions rapides</h2>
<div className="space-y-3">
<Link
href="/admin/candidatures"
className="flex items-center gap-3 p-3 rounded-xl bg-dark-lighter hover:bg-dark-lighter/80 transition-colors group"
>
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center">
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div>
<p className="text-white text-sm font-medium group-hover:text-primary transition-colors">
Gérer les candidatures
</p>
<p className="text-white/30 text-xs">{pendingCount} en attente de traitement</p>
</div>
</Link>
<Link
href="/admin/cours"
className="flex items-center gap-3 p-3 rounded-xl bg-dark-lighter hover:bg-dark-lighter/80 transition-colors group"
>
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div>
<p className="text-white text-sm font-medium group-hover:text-primary transition-colors">
Ajouter un cours
</p>
<p className="text-white/30 text-xs">{publishedModules} cours publiés</p>
</div>
</Link>
</div>
</div>
</div>
</div>
);
}

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

117
app/assainissement/page.tsx Normal file
View File

@@ -0,0 +1,117 @@
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";
import ContactForm from "@/components/marketing/ContactForm";
export const metadata: Metadata = {
title: "Assainissement Maison Nord 59 | OBC Maçonnerie",
description:
"Création et mise aux normes de systèmes d'assainissement dans le Nord (59). OBC Maçonnerie intervient à Orchies, Douai, Valenciennes et alentours. Devis gratuit.",
keywords: [
"assainissement maison Nord",
"assainissement individuel Nord 59",
"fosse septique Nord",
"mise aux normes assainissement",
"assainissement Orchies",
"assainissement Douai",
],
alternates: { canonical: "https://obc-maconnerie.fr/assainissement" },
};
const prestations = [
{ icon: "🔍", title: "Diagnostic", desc: "Analyse de votre installation existante et vérification de sa conformité aux normes en vigueur." },
{ icon: "🏗️", title: "Création de fosse", desc: "Installation d'une fosse toutes eaux ou d'une micro-station d'épuration adaptée à votre terrain." },
{ icon: "🌱", title: "Épandage", desc: "Création ou réhabilitation du dispositif d'épandage pour un traitement optimal des eaux usées." },
{ icon: "🔧", title: "Réhabilitation", desc: "Remise en état d'une installation vieillissante ou non conforme pour éviter les sanctions." },
{ icon: "📋", title: "Mise aux normes", desc: "Mise en conformité suite à un contrôle SPANC ou en cas de vente immobilière." },
{ icon: "💧", title: "Raccordement réseau", desc: "Connexion au réseau d'assainissement collectif lorsque celui-ci est disponible." },
];
export default function AssainissementPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="max-w-2xl">
<ScrollReveal direction="up">
<Link href="/services" className="inline-flex items-center gap-1.5 text-white/50 hover:text-white text-sm mb-6 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Tous les services
</Link>
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Assainissement</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">
Assainissement dans le Nord
</h1>
<p className="text-white/70 text-lg mb-8">
Mise aux normes, création ou réhabilitation de votre système d&apos;assainissement OBC Maçonnerie intervient dans les règles de l&apos;art.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/contact" className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors pulse-glow">
Demander un devis gratuit
</Link>
<a href="tel:0674453089" className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white font-semibold px-7 py-3.5 rounded-xl transition-colors border border-white/20">
06 74 45 30 89
</a>
</div>
</ScrollReveal>
</div>
</div>
</section>
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-10 text-center">Nos prestations assainissement</h2>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{prestations.map((p, i) => (
<ScrollReveal key={p.title} direction="up" delay={i * 80}>
<div className="bg-bg-white border border-border rounded-2xl p-6 h-full">
<div className="text-3xl mb-3">{p.icon}</div>
<h3 className="text-navy font-bold text-base mb-2">{p.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{p.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
<section className="py-14 bg-stone-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-4">Assainissement dans le Nord (59)</h2>
<div className="space-y-4 text-text-light text-sm leading-relaxed">
<p>
OBC Maçonnerie réalise vos travaux d&apos;<strong className="text-text">assainissement non collectif dans le Nord</strong> fosse toutes eaux, micro-station, épandage, réhabilitation. Benoît Colin vous accompagne de l&apos;étude de votre terrain jusqu&apos;à la réception des travaux.
</p>
<p>
Que vous ayez besoin d&apos;une <strong className="text-text">mise aux normes suite à un contrôle SPANC</strong>, d&apos;une nouvelle installation pour une construction neuve ou d&apos;une réhabilitation de l&apos;existant, OBC Maçonnerie intervient à Orchies, Douai, Valenciennes, Mouchin et dans toutes les communes avoisinantes.
</p>
</div>
</ScrollReveal>
</div>
</section>
<section className="py-16 bg-bg">
<div className="max-w-xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-2 text-center">Votre projet assainissement</h2>
<p className="text-text-light text-sm text-center mb-8">Devis gratuit Réponse sous 24h</p>
</ScrollReveal>
<ScrollReveal direction="up" delay={100}>
<ContactForm />
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

221
app/blog/[slug]/page.tsx Normal file
View File

@@ -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<Metadata> {
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 (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-12 md:py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<Link href="/blog" className="inline-flex items-center gap-1.5 text-white/50 hover:text-white text-sm mb-6 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Retour au blog
</Link>
<div className="flex items-center gap-3 mb-4">
<span className="bg-orange/20 text-orange text-xs font-semibold px-2.5 py-1 rounded-full">
{article.cat}
</span>
<span className="text-white/40 text-xs">{article.date}</span>
<span className="text-white/40 text-xs">· {article.readTime} de lecture</span>
</div>
<h1 className="text-2xl md:text-4xl font-bold text-white leading-tight">
{article.titre}
</h1>
</ScrollReveal>
</div>
</section>
<section className="py-12 md:py-16 bg-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<div className="bg-bg-white border border-border rounded-2xl p-6 md:p-10">
<div className="flex items-center gap-3 mb-8 pb-6 border-b border-border">
<div className="w-10 h-10 bg-navy rounded-full flex items-center justify-center shrink-0">
<span className="text-white font-bold text-xs">OBC</span>
</div>
<div>
<p className="text-navy font-semibold text-sm">Benoît Colin</p>
<p className="text-text-muted text-xs">OBC Maçonnerie Mouchin (59)</p>
</div>
</div>
<div className="space-y-5 text-text leading-relaxed">
{article.contenu.map((para, i) => (
<p key={i} className="text-base text-text-light">
{para.split(/(\*\*[^*]+\*\*)/).map((part, j) => {
if (part.startsWith("**") && part.endsWith("**")) {
return (
<strong key={j} className="text-navy font-semibold">
{part.slice(2, -2)}
</strong>
);
}
return part;
})}
</p>
))}
</div>
<div className="mt-10 pt-8 border-t border-border">
<div className="bg-stone-bg rounded-xl p-5 flex flex-col sm:flex-row items-center gap-4">
<div className="flex-1">
<p className="text-navy font-bold mb-1">Vous avez un projet ?</p>
<p className="text-text-light text-sm">
Benoît se déplace gratuitement pour évaluer votre chantier et vous donner un devis précis.
</p>
</div>
<Link
href="/contact"
className="shrink-0 bg-orange hover:bg-orange-hover text-white font-bold px-5 py-2.5 rounded-xl text-sm transition-colors"
>
Devis gratuit
</Link>
</div>
</div>
</div>
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

167
app/blog/page.tsx Normal file
View File

@@ -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 (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<ScrollReveal direction="up">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Conseils & guides</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">Blog OBC Maçonnerie</h1>
<p className="text-white/70 text-lg max-w-xl mx-auto">
Construction, rénovation, assainissement Benoît partage son expertise pour vous aider dans vos projets.
</p>
</ScrollReveal>
</div>
</section>
{/* Filtres */}
<section className="py-6 bg-bg border-b border-border">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="flex flex-wrap gap-2 justify-center">
{cats.map((cat) => (
<span
key={cat}
className={`px-4 py-2 rounded-full text-sm font-medium cursor-default ${
cat === "Tous"
? "bg-navy text-white"
: "bg-bg-white border border-border text-text-light"
}`}
>
{cat}
</span>
))}
</div>
</div>
</section>
{/* Articles */}
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((a, i) => (
<ScrollReveal key={a.slug} direction="up" delay={i * 70}>
<Link
href={`/blog/${a.slug}`}
className="group block bg-bg-white border border-border rounded-2xl overflow-hidden hover:border-orange hover:shadow-lg transition-all card-hover"
>
<div className="bg-navy h-32 flex items-center justify-center">
<span className="text-orange font-bold text-4xl">0{i + 1}</span>
</div>
<div className="p-5">
<div className="flex items-center gap-2 mb-3">
<span className="bg-orange/10 text-orange text-xs font-semibold px-2.5 py-1 rounded-full">
{a.cat}
</span>
<span className="text-text-muted text-xs">{a.readTime} de lecture</span>
</div>
<h2 className="text-navy font-bold text-base mb-2 leading-snug group-hover:text-orange transition-colors">
{a.titre}
</h2>
<p className="text-text-light text-sm leading-relaxed line-clamp-2">{a.extrait}</p>
<div className="mt-4 flex items-center justify-between">
<span className="text-text-muted text-xs">{a.date}</span>
<span className="text-orange text-xs font-semibold">Lire </span>
</div>
</div>
</Link>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-14 bg-stone-bg">
<div className="max-w-2xl mx-auto px-4 text-center">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-3">Un projet en tête ?</h2>
<p className="text-text-light text-sm mb-6">
Benoît vous conseille gratuitement et vous remet un devis sous 24h.
</p>
<Link
href="/contact"
className="inline-flex items-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
Demander un devis gratuit
</Link>
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Candidature HookLab",
description:
"Rejoignez HookLab et apprenez \u00e0 cr\u00e9er des sites web professionnels pour artisans du b\u00e2timent. Accompagnement personnalis\u00e9.",
robots: {
index: false,
follow: false,
},
alternates: {
canonical: "https://hooklab.eu/candidature",
},
};
export default function CandidatureLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -1,427 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Button from "@/components/ui/Button";
import Input, { Textarea } from "@/components/ui/Input";
// Étapes du formulaire
type Step = 1 | 2 | 3;
interface FormData {
firstname: string;
email: string;
phone: string;
persona: string;
age: string;
experience: string;
time_daily: string;
availability: string;
start_date: string;
motivation: string;
monthly_goal: string;
biggest_fear: string;
tiktok_username: string;
}
const initialFormData: FormData = {
firstname: "",
email: "",
phone: "",
persona: "",
age: "",
experience: "",
time_daily: "",
availability: "",
start_date: "",
motivation: "",
monthly_goal: "",
biggest_fear: "",
tiktok_username: "",
};
export default function CandidaturePage() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateField = (field: keyof FormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const canGoNext = (): boolean => {
switch (step) {
case 1:
return !!(
formData.firstname &&
formData.email &&
formData.phone &&
formData.persona &&
formData.age
);
case 2:
return !!(
formData.experience &&
formData.time_daily &&
formData.availability &&
formData.start_date
);
case 3:
return !!(
formData.motivation &&
formData.monthly_goal &&
formData.biggest_fear
);
default:
return false;
}
};
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/candidature", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
age: parseInt(formData.age, 10),
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erreur lors de l'envoi");
}
router.push("/merci");
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inattendue");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen py-20 md:py-32 bg-dark">
<div className="max-w-2xl mx-auto px-4 sm:px-6">
{/* Header */}
<div className="text-center mb-12">
<Link href="/" className="inline-flex items-center gap-2 mb-8">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">H</span>
</div>
<span className="text-xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<h1 className="text-3xl md:text-4xl font-bold tracking-[-0.02em] mb-3">
Candidature <span className="gradient-text">HookLab</span>
</h1>
<p className="text-white/60">
Réponds à quelques questions pour qu&apos;on puisse évaluer ton
profil.
</p>
</div>
{/* Progress bar */}
<div className="flex items-center gap-2 mb-10">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 flex-1 rounded-full transition-colors ${
s <= step ? "gradient-bg" : "bg-dark-lighter"
}`}
/>
))}
</div>
{/* Formulaire */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
{/* Étape 1 : Informations personnelles */}
{step === 1 && (
<div className="space-y-5">
<h2 className="text-xl font-bold text-white mb-6">
Informations personnelles
</h2>
<Input
id="firstname"
label="Prénom"
placeholder="Ton prénom"
value={formData.firstname}
onChange={(e) => updateField("firstname", e.target.value)}
/>
<Input
id="email"
label="Email"
type="email"
placeholder="ton@email.com"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
/>
<Input
id="phone"
label="Téléphone"
type="tel"
placeholder="06 12 34 56 78"
value={formData.phone}
onChange={(e) => updateField("phone", e.target.value)}
/>
<Input
id="age"
label="Âge"
type="number"
placeholder="25"
min="18"
max="65"
value={formData.age}
onChange={(e) => updateField("age", e.target.value)}
/>
{/* Persona selection */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Tu es plutôt...
</label>
<div className="grid grid-cols-2 gap-3">
{[
{
id: "jeune",
label: "Étudiant / Jeune",
emoji: "🎓",
},
{
id: "parent",
label: "Parent / Reconversion",
emoji: "👨‍👩‍👧",
},
].map((p) => (
<button
key={p.id}
type="button"
className={`p-4 rounded-2xl border-2 text-left transition-all cursor-pointer ${
formData.persona === p.id
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-lighter hover:border-primary/30"
}`}
onClick={() => updateField("persona", p.id)}
>
<span className="text-2xl block mb-2">{p.emoji}</span>
<span className="text-white text-sm font-medium">
{p.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Étape 2 : Situation actuelle */}
{step === 2 && (
<div className="space-y-5">
<h2 className="text-xl font-bold text-white mb-6">
Ta situation actuelle
</h2>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Expérience e-commerce / réseaux sociaux
</label>
<div className="space-y-2">
{[
"Débutant complet",
"J'ai déjà testé des choses",
"Je génère déjà des revenus en ligne",
].map((opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.experience === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("experience", opt)}
>
{opt}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Temps disponible par jour
</label>
<div className="space-y-2">
{["1-2 heures", "2-4 heures", "4+ heures", "Temps plein"].map(
(opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.time_daily === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("time_daily", opt)}
>
{opt}
</button>
)
)}
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Disponibilité pour commencer
</label>
<div className="space-y-2">
{[
"Immédiatement",
"Dans 1-2 semaines",
"Dans 1 mois",
].map((opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.availability === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("availability", opt)}
>
{opt}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Quand souhaites-tu commencer ?
</label>
<div className="space-y-2">
{[
"Cette semaine",
"La semaine prochaine",
"Ce mois-ci",
].map((opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.start_date === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("start_date", opt)}
>
{opt}
</button>
))}
</div>
</div>
</div>
)}
{/* Étape 3 : Motivation */}
{step === 3 && (
<div className="space-y-5">
<h2 className="text-xl font-bold text-white mb-6">
Ta motivation
</h2>
<Textarea
id="motivation"
label="Pourquoi veux-tu rejoindre HookLab ?"
placeholder="Parle-nous de tes objectifs, de ce qui te motive..."
rows={4}
value={formData.motivation}
onChange={(e) => updateField("motivation", e.target.value)}
/>
<Input
id="monthly_goal"
label="Objectif de revenus mensuels"
placeholder="Ex: 1000€/mois"
value={formData.monthly_goal}
onChange={(e) => updateField("monthly_goal", e.target.value)}
/>
<Textarea
id="biggest_fear"
label="Quelle est ta plus grande peur ?"
placeholder="Qu'est-ce qui pourrait t'empêcher de réussir ?"
rows={3}
value={formData.biggest_fear}
onChange={(e) => updateField("biggest_fear", e.target.value)}
/>
<Input
id="tiktok_username"
label="Pseudo TikTok (optionnel)"
placeholder="@tonpseudo"
value={formData.tiktok_username}
onChange={(e) => updateField("tiktok_username", e.target.value)}
/>
</div>
)}
{/* Error */}
{error && (
<div className="mt-4 p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-dark-border">
{step > 1 ? (
<Button
variant="ghost"
onClick={() => setStep((step - 1) as Step)}
>
Retour
</Button>
) : (
<Link href="/">
<Button variant="ghost">Annuler</Button>
</Link>
)}
{step < 3 ? (
<Button
onClick={() => setStep((step + 1) as Step)}
disabled={!canGoNext()}
>
Continuer
</Button>
) : (
<Button
onClick={handleSubmit}
loading={loading}
disabled={!canGoNext()}
>
Envoyer ma candidature
</Button>
)}
</div>
</div>
{/* Step indicator text */}
<p className="text-center text-white/30 text-sm mt-4">
Étape {step} sur 3
</p>
</div>
</main>
);
}

View File

@@ -1,136 +1,111 @@
import type { Metadata } from "next";
import Link from "next/link";
import Navbar from "@/components/marketing/Navbar";
import Footer from "@/components/marketing/Footer";
export const metadata: Metadata = {
title: "Conditions Générales de Vente",
title: "Conditions Générales de Vente | OBC Maçonnerie",
description:
"CGV de HookLab - Conditions générales de vente pour les prestations de création de sites internet et référencement.",
alternates: {
canonical: "https://hooklab.eu/cgv",
},
"Conditions générales de vente d'OBC Maçonnerie — Benoît Colin, maçon à Mouchin (59310). Prestations de construction, rénovation et gros œuvre.",
alternates: { canonical: "https://obc-maconnerie.fr/cgv" },
robots: { index: false, follow: false },
};
export default function CGV() {
return (
<main className="min-h-screen py-20 md:py-32 bg-dark">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<Link href="/" className="inline-flex items-center gap-2 mb-10 text-white/40 hover:text-white text-sm transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<main id="main-content" className="min-h-screen bg-bg">
<Navbar />
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16 md:py-20">
<Link href="/" className="inline-flex items-center gap-2 mb-8 text-text-light hover:text-navy text-sm transition-colors group">
<svg className="w-4 h-4 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Retour à l&apos;accueil
</Link>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-10">Conditions Générales de Vente</h1>
<h1 className="text-3xl md:text-4xl font-bold text-navy mb-10">Conditions Générales de Vente</h1>
<div className="space-y-8 text-text-light text-sm leading-relaxed">
<div className="space-y-8 text-white/70 text-sm leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 1 - Objet</h2>
<h2 className="text-lg font-bold text-navy mb-3">Article 1 Objet</h2>
<p>
Les présentes Conditions Générales de Vente (CGV) régissent la vente du programme de formation
en ligne &ldquo;HookLab&rdquo; proposé par Enguerrand Ozano, entrepreneur individuel, SIREN 994 538 932,
situé au 35 rue Moïse Lambert, 59148 Flines-lez-Raches, France.
Les présentes Conditions Générales de Vente (CGV) régissent les prestations de travaux de maçonnerie, construction, rénovation, assainissement, création d&apos;accès et démolition proposées par <strong className="text-text">OBC Maçonnerie</strong>, entreprise individuelle dirigée par Benoît COLIN, SIREN 531 827 871, dont le siège est situé au 221 Route de Saint-Amand, 59310 Mouchin.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 2 - Description du service</h2>
<h2 className="text-lg font-bold text-navy mb-3">Article 2 Devis et commandes</h2>
<p>
HookLab est un programme de coaching en ligne d&apos;une durée de 8 semaines, comprenant :
Toute prestation fait l&apos;objet d&apos;un devis préalable gratuit. Le devis est établi après visite du chantier. Il est valable 30 jours à compter de sa date d&apos;émission. La signature du devis par le client vaut acceptation des présentes CGV et commande ferme.
</p>
</section>
<section>
<h2 className="text-lg font-bold text-navy mb-3">Article 3 Prix et paiement</h2>
<p>
Les prix sont indiqués hors taxes ou TTC selon le régime fiscal applicable. Un acompte de 30% peut être demandé à la commande, le solde étant payable à la réception des travaux. En cas de retard de paiement, des pénalités de retard seront appliquées conformément aux dispositions légales.
</p>
</section>
<section>
<h2 className="text-lg font-bold text-navy mb-3">Article 4 Délais d&apos;exécution</h2>
<p>
Les délais d&apos;exécution sont communiqués à titre indicatif dans le devis. OBC Maçonnerie s&apos;engage à respecter les délais convenus sauf cas de force majeure, conditions météorologiques défavorables ou retard imputable au client ou à des tiers.
</p>
</section>
<section>
<h2 className="text-lg font-bold text-navy mb-3">Article 5 Garanties</h2>
<p>
OBC Maçonnerie est couvert par les garanties légales applicables aux travaux de construction :
</p>
<ul className="mt-3 space-y-1 list-disc list-inside">
<li>Des modules vidéo hebdomadaires</li>
<li>Des appels de groupe hebdomadaires</li>
<li>Un support WhatsApp illimité</li>
<li>L&apos;accès à une communauté privée d&apos;entrepreneurs</li>
<li>Des templates et scripts de contenu</li>
<li>Une certification HookLab</li>
<li><strong className="text-text">Garantie décennale</strong> : couvre les dommages compromettant la solidité de l&apos;ouvrage pendant 10 ans.</li>
<li><strong className="text-text">Garantie biennale</strong> : couvre les éléments d&apos;équipement dissociables pendant 2 ans.</li>
<li><strong className="text-text">Garantie de parfait achèvement</strong> : couvre les défauts signalés à la réception pendant 1 an.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 3 - Prix et modalités de paiement</h2>
<h2 className="text-lg font-bold text-navy mb-3">Article 6 Responsabilité</h2>
<p>
Le prix du programme est de <strong className="text-white">980 TTC</strong>, payable en 2 mensualités
de 490. Le premier paiement est exigé lors de l&apos;inscription et donne accès immédiat au programme.
Le second paiement est prélevé automatiquement 30 jours après le premier.
</p>
<p className="mt-3">
TVA applicable : FR16994538932. Les paiements sont sécurisés via la plateforme Stripe.
OBC Maçonnerie est assuré en responsabilité civile professionnelle et décennale. La responsabilité d&apos;OBC Maçonnerie ne saurait être engagée pour des dommages résultant d&apos;une utilisation non conforme des ouvrages réalisés ou d&apos;une intervention de tiers après réception.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 4 - Processus de candidature</h2>
<h2 className="text-lg font-bold text-navy mb-3">Article 7 Réception des travaux</h2>
<p>
L&apos;accès au programme est soumis à la validation d&apos;un formulaire de candidature. L&apos;éditeur
se réserve le droit de refuser toute candidature sans avoir à en justifier les raisons. En cas de
refus, aucun paiement n&apos;est effectué.
La réception des travaux est prononcée contradictoirement entre OBC Maçonnerie et le client. Elle fait l&apos;objet d&apos;un procès-verbal. Les réserves éventuelles y sont consignées et levées dans les délais convenus.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 5 - Droit de rétractation</h2>
<p>
Conformément à l&apos;article L221-18 du Code de la consommation, le client dispose d&apos;un délai de
<strong className="text-white"> 14 jours</strong> à compter de la date d&apos;achat pour exercer son droit
de rétractation, sans avoir à justifier de motifs ni à payer de pénalités.
</p>
<p className="mt-3">
Pour exercer ce droit, le client doit envoyer un email à <strong className="text-white">contact@hooklab.fr</strong> en
indiquant sa volonté de se rétracter. Le remboursement sera effectué dans un délai de 14 jours
suivant la réception de la demande.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 6 - Accès au programme</h2>
<p>
L&apos;accès au programme est personnel et non cessible. Le client s&apos;engage à ne pas partager ses
identifiants de connexion ni le contenu du programme avec des tiers. Tout manquement à cette
obligation pourra entraîner la résiliation immédiate de l&apos;accès sans remboursement.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 7 - Limitation de responsabilité</h2>
<p>
HookLab est un programme de formation et de coaching. Les résultats obtenus dépendent de
l&apos;implication et des actions de chaque participant. Aucune garantie de revenus n&apos;est formulée.
Les témoignages présentés sur le site sont des exemples individuels et ne constituent pas une
promesse de résultats similaires.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 8 - Protection des données</h2>
<h2 className="text-lg font-bold text-navy mb-3">Article 8 Données personnelles</h2>
<p>
Les données personnelles collectées sont traitées conformément à notre{" "}
<Link href="/confidentialite" className="text-primary hover:underline">
Politique de confidentialité
<Link href="/confidentialite" className="text-orange hover:underline">
politique de confidentialité
</Link>.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">Article 9 - Droit applicable et litiges</h2>
<h2 className="text-lg font-bold text-navy mb-3">Article 9 Droit applicable et litiges</h2>
<p>
Les présentes CGV sont soumises au droit français. En cas de litige, une solution amiable sera
recherchée avant toute action judiciaire. À défaut, les tribunaux compétents seront ceux du
ressort du siège social de l&apos;éditeur.
</p>
<p className="mt-3">
Conformément à l&apos;article L612-1 du Code de la consommation, le consommateur peut recourir
gratuitement au service de médiation MEDICYS, par voie électronique à{" "}
<span className="text-white">www.medicys.fr</span> ou par courrier.
Les présentes CGV sont soumises au droit français. En cas de litige, une solution amiable sera recherchée en priorité. À défaut, le tribunal compétent sera celui de Valenciennes.
</p>
</section>
<p className="text-white/40 pt-4 border-t border-dark-border">
Dernière mise à jour : février 2026
<p className="text-text-muted text-xs pt-4 border-t border-border">
Dernière mise à jour : Février 2026
</p>
</div>
</div>
<Footer />
</main>
);
}

View File

@@ -1,152 +1,132 @@
import type { Metadata } from "next";
import Link from "next/link";
import Navbar from "@/components/marketing/Navbar";
import Footer from "@/components/marketing/Footer";
export const metadata: Metadata = {
title: "Politique de Confidentialité | HookLab",
title: "Politique de Confidentialité | OBC Maçonnerie",
description:
"Politique de confidentialité et protection des données personnelles du site HookLab.eu, conformément au RGPD.",
alternates: {
canonical: "https://hooklab.eu/confidentialite",
},
"Politique de confidentialité et protection des données personnelles du site OBC Maçonnerie, conformément au RGPD.",
alternates: { canonical: "https://obc-maconnerie.fr/confidentialite" },
robots: { index: false, follow: false },
};
export default function Confidentialite() {
return (
<main className="min-h-screen py-20 md:py-32 bg-dark">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<Link href="/" className="inline-flex items-center gap-2 mb-10 text-white/40 hover:text-white text-sm transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<main id="main-content" className="min-h-screen bg-bg">
<Navbar />
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16 md:py-20">
<Link href="/" className="inline-flex items-center gap-2 mb-8 text-text-light hover:text-navy text-sm transition-colors group">
<svg className="w-4 h-4 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Retour à l&apos;accueil
</Link>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-10">Politique de Confidentialité</h1>
<h1 className="text-3xl md:text-4xl font-bold text-navy mb-10">Politique de Confidentialité</h1>
<div className="space-y-8 text-white/70 text-sm leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-white mb-3">1. Responsable du traitement</h2>
<p>Le responsable du traitement des données est :</p>
<ul className="mt-3 space-y-1">
<li><strong className="text-white">Enguerrand Ozano (HookLab)</strong></li>
<li>SIREN : 994 538 932</li>
<li>Adresse : 35 rue Moïse Lambert, 59148 Flines-lez-Raches, France</li>
<li>Email : <a href="mailto:contact@hooklab.eu" className="hover:text-white transition-colors">contact@hooklab.eu</a></li>
</ul>
</section>
<div className="space-y-8 text-text-light text-sm leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-white mb-3">2. Données collectées</h2>
<p>Nous collectons des informations à deux moments distincts de notre relation :</p>
<div className="mt-4">
<p className="font-semibold text-white mb-2">Sur le site internet (Demande d&apos;Audit) :</p>
<p>Nous collectons les informations que vous nous transmettez volontairement via le formulaire :</p>
<ul className="mt-2 space-y-1 list-disc list-inside ml-4">
<li>Nom, Prénom</li>
<li>Numéro de téléphone</li>
<li>Adresse email</li>
<li>Nom de l&apos;entreprise</li>
<li>Ville d&apos;intervention</li>
</ul>
</div>
<div className="mt-4">
<p className="font-semibold text-white mb-2">Lors de la contractualisation (En rendez-vous ou à distance) :</p>
<p>
Pour la mise en place de votre dossier client, nous collectons via nos partenaires sécurisés les données
nécessaires à la facturation (Identité, IBAN pour le prélèvement, Signature électronique).
<strong className="text-white"> Aucune donnée bancaire n&apos;est stockée sur ce site internet.</strong>
</p>
<h2 className="text-lg font-bold text-navy mb-3">1. Responsable du traitement</h2>
<div className="bg-bg-white border border-border rounded-xl p-5 space-y-1">
<p><strong className="text-text">Benoît COLIN OBC Maçonnerie</strong></p>
<p>SIREN : 531 827 871</p>
<p>221 Route de Saint-Amand, 59310 Mouchin</p>
<p>Tél : <a href="tel:0674453089" className="text-orange hover:underline">06 74 45 30 89</a></p>
<p>Email : <a href="mailto:contact@obc-maconnerie.fr" className="text-orange hover:underline">contact@obc-maconnerie.fr</a></p>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">3. Finalités du traitement</h2>
<p>Vos données sont utilisées pour :</p>
<ul className="mt-3 space-y-1 list-disc list-inside">
<li>Répondre à vos demandes d&apos;audit gratuit et vous recontacter.</li>
<li>Établir le contrat de prestation et gérer la signature électronique.</li>
<li>Mettre en place le prélèvement automatique sécurisé pour votre abonnement.</li>
<li>Exécuter la prestation (création du site, référencement, gestion de votre visibilité).</li>
<li>Vous envoyer vos factures et des informations importantes sur votre dossier.</li>
<h2 className="text-lg font-bold text-navy mb-3">2. Données collectées</h2>
<p>Nous collectons uniquement les données que vous nous transmettez via le formulaire de contact :</p>
<ul className="mt-3 space-y-1 list-disc list-inside ml-2">
<li>Nom et prénom</li>
<li>Numéro de téléphone</li>
<li>Adresse email (optionnelle)</li>
<li>Type de projet et description</li>
<li>Budget approximatif (optionnel)</li>
<li>Commune / zone d&apos;intervention</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">4. Base légale</h2>
<ul className="space-y-2 list-disc list-inside">
<li><strong className="text-white">Consentement :</strong> Pour les données envoyées via le formulaire de contact du site.</li>
<li><strong className="text-white">Exécution du contrat :</strong> Pour les données collectées via PandaDoc et GoCardless nécessaires à la réalisation de la prestation et à la facturation.</li>
<li><strong className="text-white">Obligation légale :</strong> Pour la conservation des factures et documents comptables.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">5. Partage des données et Sous-traitants sécurisés</h2>
<p className="mb-3">
Nous ne vendons jamais vos données. Elles sont uniquement transmises aux prestataires techniques
rigoureusement sélectionnés pour assurer le fonctionnement du service :
<p className="mt-3">
<strong className="text-text">Aucune donnée bancaire</strong> n&apos;est collectée sur ce site.
</p>
<ul className="space-y-1 list-disc list-inside">
<li><strong className="text-white">PandaDoc :</strong> Pour la génération et la signature électronique sécurisée des contrats.</li>
<li><strong className="text-white">GoCardless :</strong> Pour la gestion sécurisée des mandats de prélèvement SEPA (données bancaires).</li>
<li><strong className="text-white">Sanity.io :</strong> Pour l&apos;hébergement des contenus (textes/photos) de votre futur site.</li>
<li><strong className="text-white">Vercel :</strong> Pour l&apos;hébergement technique du site internet.</li>
<li><strong className="text-white">Resend :</strong> Pour l&apos;envoi des emails transactionnels.</li>
</section>
<section>
<h2 className="text-lg font-bold text-navy mb-3">3. Finalités du traitement</h2>
<p>Vos données sont utilisées exclusivement pour :</p>
<ul className="mt-3 space-y-1 list-disc list-inside ml-2">
<li>Répondre à votre demande de devis et vous recontacter</li>
<li>Préparer et établir un devis adapté à votre projet</li>
<li>Gérer la relation commerciale si vous confiez votre chantier à OBC Maçonnerie</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">6. Sécurité des données</h2>
<p className="mb-3">La sécurité est notre priorité.</p>
<div className="space-y-3">
<p>
<strong className="text-white">Sur le site :</strong> Toutes les navigations se font sous protocole HTTPS
(cadenas fermé), garantissant le cryptage des données échangées.
</p>
<p>
<strong className="text-white">Pour le paiement et les contrats :</strong> Nous utilisons des tiers de confiance
(GoCardless et PandaDoc) qui respectent les normes de sécurité bancaires et juridiques les plus strictes.
Vos coordonnées bancaires ne transitent jamais par nos serveurs informatiques.
</p>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">7. Durée de conservation</h2>
<ul className="space-y-1 list-disc list-inside">
<li><strong className="text-white">Données prospects :</strong> 3 ans après le dernier contact.</li>
<li><strong className="text-white">Données clients :</strong> Durant toute la relation contractuelle, puis archivées pendant 5 ans (prescription légale).</li>
<li><strong className="text-white">Documents comptables :</strong> 10 ans (obligation légale).</li>
<h2 className="text-lg font-bold text-navy mb-3">4. Base légale</h2>
<ul className="space-y-1 list-disc list-inside ml-2">
<li><strong className="text-text">Consentement :</strong> Pour les données transmises via le formulaire de contact.</li>
<li><strong className="text-text">Exécution du contrat :</strong> Pour les données nécessaires à la réalisation du chantier.</li>
<li><strong className="text-text">Obligation légale :</strong> Pour la conservation des documents comptables (10 ans).</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">8. Vos droits (RGPD)</h2>
<h2 className="text-lg font-bold text-navy mb-3">5. Partage des données</h2>
<p>
Conformément à la réglementation, vous disposez d&apos;un droit d&apos;accès, de rectification,
d&apos;effacement et de portabilité de vos données.
Vos données ne sont jamais vendues à des tiers. Elles peuvent être transmises uniquement aux prestataires techniques nécessaires au fonctionnement du site :
</p>
<ul className="mt-3 space-y-1 list-disc list-inside ml-2">
<li><strong className="text-text">Vercel :</strong> Hébergement du site web</li>
<li><strong className="text-text">Resend :</strong> Envoi des emails de notification</li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-navy mb-3">6. Durée de conservation</h2>
<ul className="space-y-1 list-disc list-inside ml-2">
<li><strong className="text-text">Prospects :</strong> 3 ans après le dernier contact</li>
<li><strong className="text-text">Clients :</strong> 5 ans après la fin de la relation contractuelle</li>
<li><strong className="text-text">Documents comptables :</strong> 10 ans (obligation légale)</li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-navy mb-3">7. Vos droits (RGPD)</h2>
<p>
Conformément au RGPD, vous disposez d&apos;un droit d&apos;accès, de rectification, d&apos;effacement et de portabilité de vos données. Pour exercer ces droits, contactez-nous :
</p>
<p className="mt-3">
Pour exercer ce droit, envoyez simplement un email à : <a href="mailto:contact@hooklab.eu" className="text-white hover:underline">contact@hooklab.eu</a>
<a href="mailto:contact@obc-maconnerie.fr" className="text-orange font-semibold hover:underline">
contact@obc-maconnerie.fr
</a>
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-white mb-3">9. Cookies</h2>
<h2 className="text-lg font-bold text-navy mb-3">8. Cookies</h2>
<p>
Ce site utilise des cookies techniques nécessaires à son bon fonctionnement et des outils de mesure
d&apos;audience anonymes pour améliorer nos services. Aucune donnée n&apos;est revendue à des tiers publicitaires.
Ce site utilise uniquement des cookies techniques nécessaires à son bon fonctionnement. Aucun cookie publicitaire ou de traçage tiers n&apos;est utilisé.
</p>
</section>
<p className="text-white/40 pt-4 border-t border-dark-border">
<section>
<h2 className="text-lg font-bold text-navy mb-3">9. Sécurité</h2>
<p>
Toutes les communications entre votre navigateur et notre site sont chiffrées via le protocole HTTPS. Vos données sont traitées avec le plus grand soin et ne sont accessibles qu&apos;aux personnes habilitées.
</p>
</section>
<p className="text-text-muted text-xs pt-4 border-t border-border">
Dernière mise à jour : Février 2026
</p>
</div>
</div>
<Footer />
</main>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Maçon Douai | Construction & Rénovation | OBC Maçonnerie",
description:
"OBC Maçonnerie intervient à Douai pour vos travaux de construction de maison, rénovation et gros œuvre. Benoît Colin, maçon expert. Devis gratuit.",
keywords: ["construction maison Douai", "maçon Douai", "rénovation Douai", "gros oeuvre Douai", "maçon rénovation Douai"],
alternates: { canonical: "https://obc-maconnerie.fr/construction-maison-douai" },
};
export default function ConstructionMaisonDouaiPage() {
return (
<LocalSEOPage
ville="Douai"
departement="Nord (59)"
servicesPrincipaux={["Construction de maison", "Rénovation"]}
description="Construction de maison et rénovation à Douai — OBC Maçonnerie intervient dans toute la commune et ses alentours."
texteIntro="Votre projet de construction ou de rénovation à Douai mérite un maçon de confiance. OBC Maçonnerie intervient dans toute l'agglomération douaisienne avec rigueur et professionnalisme."
texteLocal={`OBC Maçonnerie intervient à Douai et dans toute son agglomération pour vos travaux de maçonnerie. Que vous souhaitiez construire une maison neuve, rénover un bien existant ou réaliser des travaux d'assainissement, Benoît Colin est à votre disposition.\n\nDouai est une ville que nous connaissons bien, avec ses spécificités : maisons de ville à rénover, terrains en zone urbaine, règles d'urbanisme particulières. Notre expérience locale vous garantit un projet réalisé dans les règles de l'art et dans les délais.\n\nN'hésitez pas à contacter OBC Maçonnerie pour un devis gratuit à Douai. Benoît se déplace pour évaluer votre projet.`}
distanceMouchin="À environ 20 km"
/>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Maçon Orchies | Construction & Rénovation | OBC Maçonnerie",
description:
"OBC Maçonnerie intervient à Orchies pour vos travaux de construction de maison, rénovation et gros œuvre. Benoît Colin, maçon expert. Devis gratuit.",
keywords: ["construction maison Orchies", "maçon Orchies", "rénovation Orchies", "gros oeuvre Orchies"],
alternates: { canonical: "https://obc-maconnerie.fr/construction-maison-orchies" },
};
export default function ConstructionMaisonOrchiesPage() {
return (
<LocalSEOPage
ville="Orchies"
departement="Nord (59)"
servicesPrincipaux={["Construction de maison", "Rénovation"]}
description="Construction de maison et rénovation à Orchies — OBC Maçonnerie intervient dans toute la commune."
texteIntro="Vous habitez à Orchies ou ses alentours et vous avez un projet de construction ou de rénovation ? OBC Maçonnerie intervient dans toute la commune avec expertise et disponibilité."
texteLocal={`OBC Maçonnerie, basée à Mouchin (59310), est votre entreprise de maçonnerie de proximité à Orchies. Benoît Colin intervient sur tous vos chantiers : construction de maison individuelle, rénovation complète ou partielle, assainissement, création d'accès et démolition.\n\nOrchies est au cœur de notre zone d'intervention. Nous y réalisons régulièrement des chantiers de construction neuve et de rénovation. Notre connaissance du tissu local, des entreprises et des contraintes de terrain de la commune est un vrai atout pour votre projet.\n\nSi vous cherchez un maçon à Orchies, disponible, à l'écoute et capable de vous accompagner de A à Z, contactez Benoît Colin au 06 74 45 30 89 pour un devis gratuit.`}
distanceMouchin="À environ 10 km"
/>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Maçon Valenciennes | Construction & Rénovation | OBC Maçonnerie",
description:
"OBC Maçonnerie intervient à Valenciennes pour vos travaux de construction de maison, rénovation et gros œuvre. Benoît Colin, maçon expert. Devis gratuit.",
keywords: ["construction maison Valenciennes", "maçon Valenciennes", "rénovation Valenciennes", "gros oeuvre Valenciennes"],
alternates: { canonical: "https://obc-maconnerie.fr/construction-maison-valenciennes" },
};
export default function ConstructionMaisonValenciennesPage() {
return (
<LocalSEOPage
ville="Valenciennes"
departement="Nord (59)"
servicesPrincipaux={["Construction de maison", "Rénovation"]}
description="Construction de maison et rénovation à Valenciennes — OBC Maçonnerie intervient dans toute la commune et le Valenciennois."
texteIntro="Vous recherchez un maçon de confiance à Valenciennes ? OBC Maçonnerie intervient dans tout le Valenciennois pour vos projets de construction neuve et de rénovation."
texteLocal={`OBC Maçonnerie étend son intervention jusqu'à Valenciennes et son agglomération. Benoît Colin et son équipe réalisent des chantiers de construction de maison individuelle, de rénovation complète et de gros œuvre dans tout le secteur valenciennois.\n\nNotre savoir-faire en construction neuve et rénovation s'adapte aux projets du Valenciennois : constructions traditionnelles, maisons en ossature bois, rénovation de maisons de ville anciennes. Chaque projet est traité avec la même rigueur.\n\nContactez OBC Maçonnerie pour un devis gratuit à Valenciennes et dans les communes environnantes. Benoît se déplace pour évaluer votre projet.`}
distanceMouchin="À environ 25 km"
/>
);
}

View File

@@ -0,0 +1,147 @@
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";
import ContactForm from "@/components/marketing/ContactForm";
export const metadata: Metadata = {
title: "Construction de Maison dans le Nord | OBC Maçonnerie Orchies",
description:
"Construction neuve, fondations, ossature bois dans le Nord (59). OBC Maçonnerie vous accompagne de A à Z. Devis gratuit.",
keywords: [
"construction maison Nord",
"maçon construction maison Orchies",
"fondation ossature bois Nord",
"gros œuvre Nord",
"construction maison Douai",
"construction maison Valenciennes",
],
alternates: { canonical: "https://obc-maconnerie.fr/construction-maison" },
};
const etapes = [
{ num: "01", title: "Étude & conseil", desc: "Benoît analyse votre terrain, votre plan et vos envies. Il adapte si besoin les plans d'architecte et vous conseille sur les matériaux." },
{ num: "02", title: "Fondations", desc: "Terrassement, fouilles, semelles filantes ou radier — la fondation, c'est la base de tout. Réalisée avec rigueur pour durer des décennies." },
{ num: "03", title: "Gros œuvre", desc: "Élévation des murs porteurs, dalles, planchers, chaînages — tout le squelette de votre maison prend forme." },
{ num: "04", title: "Ossature bois (option)", desc: "Construction en ossature bois légère et performante thermiquement, parfaitement maîtrisée par OBC Maçonnerie." },
{ num: "05", title: "Coordination des artisans", desc: "Grâce au réseau de partenaires, Benoît coordonne électriciens, plombiers, couvreurs et autres corps de métier." },
{ num: "06", title: "Remise des clés", desc: "Livraison de votre maison dans les délais convenus, avec un chantier propre et soigné." },
];
export default function ConstructionMaisonPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
{/* Hero */}
<section className="bg-navy py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="max-w-2xl">
<ScrollReveal direction="up">
<Link href="/services" className="inline-flex items-center gap-1.5 text-white/50 hover:text-white text-sm mb-6 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Tous les services
</Link>
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Gros œuvre</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4 leading-tight">
Construction de maison dans le Nord
</h1>
<p className="text-white/70 text-lg mb-8">
Benoît Colin, maçon expert à Mouchin, vous accompagne dans la construction de votre maison individuelle de la première fondation à la remise des clés.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/contact" className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors pulse-glow">
Demander un devis gratuit
</Link>
<a href="tel:0674453089" className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white font-semibold px-7 py-3.5 rounded-xl transition-colors border border-white/20">
06 74 45 30 89
</a>
</div>
</ScrollReveal>
</div>
</div>
</section>
{/* Points clés */}
<section className="py-14 bg-stone-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ val: "15+", label: "ans d'expérience" },
{ val: "100+", label: "maisons construites" },
{ val: "30km", label: "rayon d'action" },
{ val: "A→Z", label: "accompagnement complet" },
].map((s) => (
<div key={s.label} className="bg-bg-white border border-border rounded-xl p-5 text-center">
<div className="text-2xl font-bold text-orange">{s.val}</div>
<div className="text-text-light text-sm mt-1">{s.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Étapes */}
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-10 text-center">
Comment se déroule votre construction ?
</h2>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{etapes.map((e, i) => (
<ScrollReveal key={e.num} direction="up" delay={i * 80}>
<div className="bg-bg-white border border-border rounded-2xl p-6">
<span className="text-orange font-black text-2xl">{e.num}</span>
<h3 className="text-navy font-bold text-base mt-2 mb-2">{e.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{e.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* SEO text */}
<section className="py-14 bg-stone-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-4">
Votre maçon constructeur dans le Nord (59)
</h2>
<div className="space-y-4 text-text-light text-sm leading-relaxed">
<p>
OBC Maçonnerie, dirigé par Benoît Colin, est une entreprise de maçonnerie spécialisée dans la <strong className="text-text">construction de maison individuelle dans le Nord</strong>. Basés à Mouchin (59310), nous intervenons sur Orchies, Douai, Valenciennes, Flines-lès-Raches, Saint-Amand-les-Eaux et toutes les communes dans un rayon de 30 km.
</p>
<p>
Que vous souhaitiez construire une maison en <strong className="text-text">parpaing</strong>, en <strong className="text-text">béton banché</strong> ou en <strong className="text-text">ossature bois</strong>, Benoît vous conseille et adapte chaque solution à votre terrain, votre budget et vos envies. Il ne fait jamais deux fois la même maison.
</p>
<p>
Grâce à son réseau de partenaires (électricien, plombier, charpentier, couvreur, menuisier, carreleur, peintre), OBC Maçonnerie coordonne l&apos;ensemble des corps de métier pour vous livrer une maison complète, dans les délais.
</p>
</div>
</ScrollReveal>
</div>
</section>
{/* Contact form */}
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-2 text-center">Votre projet de construction</h2>
<p className="text-text-light text-sm text-center mb-8">Devis gratuit Réponse sous 24h</p>
</ScrollReveal>
<ScrollReveal direction="up" delay={100}>
<ContactForm />
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

129
app/contact/page.tsx Normal file
View File

@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import Navbar from "@/components/marketing/Navbar";
import Footer from "@/components/marketing/Footer";
import ScrollReveal from "@/components/animations/ScrollReveal";
import ContactForm from "@/components/marketing/ContactForm";
export const metadata: Metadata = {
title: "Contact & Devis Gratuit | OBC Maçonnerie Nord",
description:
"Contactez OBC Maçonnerie pour un devis gratuit. Benoît Colin intervient à Orchies, Douai, Valenciennes et dans un rayon de 30km autour de Mouchin (59).",
alternates: { canonical: "https://obc-maconnerie.fr/contact" },
};
const infos = [
{
icon: "📞",
titre: "Téléphone",
val: "06 74 45 30 89",
href: "tel:0674453089",
desc: "LunVen 7h19h",
},
{
icon: "📍",
titre: "Adresse",
val: "221 Route de Saint-Amand, 59310 Mouchin",
href: undefined,
desc: "Rayon d'intervention : 30km",
},
{
icon: "📧",
titre: "Email",
val: "contact@obc-maconnerie.fr",
href: "mailto:contact@obc-maconnerie.fr",
desc: "Réponse sous 24h",
},
];
const zones = [
"Orchies",
"Mouchin",
"Flines-lès-Raches",
"Château-l'Abbaye",
"Mérignies",
"Douai",
"Valenciennes",
"Saint-Amand-les-Eaux",
];
export default function ContactPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<ScrollReveal direction="up">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Devis gratuit</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">
Contactez OBC Maçonnerie
</h1>
<p className="text-white/70 text-lg max-w-xl mx-auto">
Benoît se déplace gratuitement pour évaluer votre projet et vous remettre un devis détaillé sous 24h.
</p>
</ScrollReveal>
</div>
</section>
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
{/* Infos + zones */}
<div>
<ScrollReveal direction="left">
<h2 className="text-xl font-bold text-navy mb-6">Nos coordonnées</h2>
<div className="space-y-4 mb-8">
{infos.map((info) => (
<div key={info.titre} className="flex items-start gap-4 bg-bg-white border border-border rounded-xl p-4">
<span className="text-2xl shrink-0">{info.icon}</span>
<div>
<p className="text-navy font-semibold text-sm">{info.titre}</p>
{info.href ? (
<a href={info.href} className="text-orange font-bold hover:underline text-sm">
{info.val}
</a>
) : (
<p className="text-text-light text-sm">{info.val}</p>
)}
<p className="text-text-muted text-xs mt-0.5">{info.desc}</p>
</div>
</div>
))}
</div>
<h3 className="text-base font-bold text-navy mb-3">Zone d&apos;intervention</h3>
<div className="flex flex-wrap gap-2 mb-6">
{zones.map((z) => (
<span key={z} className="inline-flex items-center gap-1 bg-bg-white border border-border text-navy text-xs font-medium px-3 py-1.5 rounded-full">
<span className="text-orange">📍</span> {z}
</span>
))}
</div>
<p className="text-text-muted text-xs italic">
Et toutes les communes dans un rayon de 20-30 km autour de Mouchin (Nord 59).
</p>
<div className="mt-8 bg-navy rounded-2xl p-6">
<h3 className="text-white font-bold mb-2">Devis gratuit & sans engagement</h3>
<p className="text-white/60 text-sm">
Benoît se déplace sur votre chantier pour évaluer votre projet, vous conseiller et vous remettre un devis clair et détaillé. Gratuit et sans engagement.
</p>
</div>
</ScrollReveal>
</div>
{/* Formulaire */}
<div>
<ScrollReveal direction="right">
<h2 className="text-xl font-bold text-navy mb-6">Votre demande de devis</h2>
<ContactForm />
</ScrollReveal>
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

101
app/creation-acces/page.tsx Normal file
View File

@@ -0,0 +1,101 @@
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";
import ContactForm from "@/components/marketing/ContactForm";
export const metadata: Metadata = {
title: "Création d'Accès, Voiries & Entrées | OBC Maçonnerie Nord",
description:
"Création d'accès, voiries privées, entrées de propriété et chemins dans le Nord (59). OBC Maçonnerie à Orchies. Devis gratuit.",
keywords: [
"création accès maison Nord",
"voirie privée Nord 59",
"entrée propriété Nord",
"chemin béton Nord",
"béton imprimé Nord",
"création accès Orchies",
],
alternates: { canonical: "https://obc-maconnerie.fr/creation-acces" },
};
const types = [
{ icon: "🚗", title: "Entrées de propriété", desc: "Création d'une entrée soignée en béton, béton imprimé, pavés ou gravier stabilisé — adaptée à votre maison." },
{ icon: "🛤️", title: "Voiries privées", desc: "Aménagement de voiries sur propriété privée, chemin d'accès à un bâtiment agricole ou industriel." },
{ icon: "🌾", title: "Chemins ruraux", desc: "Création ou réfection de chemins en gravier compacté, grave non traitée ou béton désactivé." },
{ icon: "🏗️", title: "Travaux de terrassement", desc: "Terrassement, nivellement et compactage du terrain avant réalisation de votre accès." },
{ icon: "🔳", title: "Béton imprimé", desc: "Effet pavés, dalles ou pierre naturelle — le béton imprimé apporte une touche décorative durable." },
{ icon: "💧", title: "Drainage & évacuation", desc: "Mise en place de caniveaux, avaloirs et systèmes de drainage pour éviter les accumulations d'eau." },
];
export default function CreationAccesPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="max-w-2xl">
<ScrollReveal direction="up">
<Link href="/services" className="inline-flex items-center gap-1.5 text-white/50 hover:text-white text-sm mb-6 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Tous les services
</Link>
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Voiries & accès</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">
Création d&apos;accès dans le Nord
</h1>
<p className="text-white/70 text-lg mb-8">
Voiries, entrées de propriété, chemins OBC Maçonnerie crée vos accès sur mesure avec les matériaux adaptés à vos besoins.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/contact" className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors pulse-glow">
Demander un devis gratuit
</Link>
<a href="tel:0674453089" className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white font-semibold px-7 py-3.5 rounded-xl transition-colors border border-white/20">
06 74 45 30 89
</a>
</div>
</ScrollReveal>
</div>
</div>
</section>
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-10 text-center">Nos réalisations d&apos;accès</h2>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{types.map((t, i) => (
<ScrollReveal key={t.title} direction="up" delay={i * 80}>
<div className="bg-bg-white border border-border rounded-2xl p-6 h-full">
<div className="text-3xl mb-3">{t.icon}</div>
<h3 className="text-navy font-bold text-base mb-2">{t.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{t.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
<section className="py-16 bg-stone-bg">
<div className="max-w-xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-2 text-center">Votre projet d&apos;accès</h2>
<p className="text-text-light text-sm text-center mb-8">Devis gratuit Réponse sous 24h</p>
</ScrollReveal>
<ScrollReveal direction="up" delay={100}>
<ContactForm />
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

117
app/demolition/page.tsx Normal file
View File

@@ -0,0 +1,117 @@
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";
import ContactForm from "@/components/marketing/ContactForm";
export const metadata: Metadata = {
title: "Démolition Maison Nord 59 | OBC Maçonnerie",
description:
"Démolition totale ou partielle de maison, murs porteurs, bâtiments dans le Nord (59). OBC Maçonnerie à Orchies. Toutes garanties de sécurité. Devis gratuit.",
keywords: [
"démolition maison Nord 59",
"démolition bâtiment Nord",
"démolition mur porteur Nord",
"démolition partielle Nord",
"démolition Orchies",
"démolition Douai",
],
alternates: { canonical: "https://obc-maconnerie.fr/demolition" },
};
const types = [
{ icon: "🏚️", title: "Démolition totale", desc: "Destruction complète d'un bâtiment résidentiel ou annexe, avec évacuation des gravats et remise en état du terrain." },
{ icon: "🧱", title: "Démolition partielle", desc: "Démolition ciblée d'une partie du bâtiment pour permettre une extension ou une restructuration." },
{ icon: "🏗️", title: "Suppression murs porteurs", desc: "Ouverture de murs porteurs avec pose de poutres et reprises en sous-œuvre pour sécuriser la structure." },
{ icon: "⛏️", title: "Dépose de dalles", desc: "Retrait de chapes béton, dalles existantes, fondations obsolètes pour préparer un nouveau sol." },
{ icon: "🚛", title: "Évacuation des gravats", desc: "Transport et évacuation de tous les déchets de démolition vers les filières de recyclage agréées." },
{ icon: "🏠", title: "Curage intérieur", desc: "Enlèvement complet des éléments intérieurs (cloisons, planchers, revêtements) avant une rénovation lourde." },
];
export default function DemolitionPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="max-w-2xl">
<ScrollReveal direction="up">
<Link href="/services" className="inline-flex items-center gap-1.5 text-white/50 hover:text-white text-sm mb-6 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Tous les services
</Link>
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Démolition</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">
Démolition dans le Nord
</h1>
<p className="text-white/70 text-lg mb-8">
Démolition totale ou partielle, avec tout le matériel et les garanties de sécurité. OBC Maçonnerie gère votre chantier du début à la fin.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/contact" className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors pulse-glow">
Demander un devis gratuit
</Link>
<a href="tel:0674453089" className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white font-semibold px-7 py-3.5 rounded-xl transition-colors border border-white/20">
06 74 45 30 89
</a>
</div>
</ScrollReveal>
</div>
</div>
</section>
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-10 text-center">Nos prestations de démolition</h2>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{types.map((t, i) => (
<ScrollReveal key={t.title} direction="up" delay={i * 80}>
<div className="bg-bg-white border border-border rounded-2xl p-6 h-full">
<div className="text-3xl mb-3">{t.icon}</div>
<h3 className="text-navy font-bold text-base mb-2">{t.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{t.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
<section className="py-14 bg-stone-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<div className="bg-bg-white border border-border rounded-2xl p-6 flex items-start gap-4">
<span className="text-3xl"></span>
<div>
<h3 className="text-navy font-bold mb-1">Démolition en toute sécurité</h3>
<p className="text-text-light text-sm leading-relaxed">
Avant toute démolition, OBC Maçonnerie vérifie la présence éventuelle d&apos;amiante, de plomb ou d&apos;autres matériaux dangereux, et fait appel aux spécialistes agréés si nécessaire. La sécurité du chantier et de ses riverains est une priorité absolue.
</p>
</div>
</div>
</ScrollReveal>
</div>
</section>
<section className="py-16 bg-bg">
<div className="max-w-xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-2 text-center">Votre projet de démolition</h2>
<p className="text-text-light text-sm text-center mb-8">Devis gratuit Réponse sous 24h</p>
</ScrollReveal>
<ScrollReveal direction="up" delay={100}>
<ContactForm />
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -10,6 +10,10 @@
--color-orange-hover: #D06522;
--color-orange-light: #F5A623;
--color-stone: #8B7355;
--color-stone-light: #C4A882;
--color-stone-bg: #F5F0EA;
--color-bg: #F7F8FA;
--color-bg-white: #FFFFFF;
--color-bg-card: #FFFFFF;
@@ -26,8 +30,8 @@
--color-warning: #F59E0B;
--color-error: #EF4444;
--color-primary: #6D5EF6;
--color-primary-hover: #5B4FDB;
--color-primary: #E8772E;
--color-primary-hover: #D06522;
--color-dark: #0B0F19;
--color-dark-bg: #0B0F19;

View File

@@ -2,38 +2,37 @@ import type { Metadata } from "next";
import CookieBanner from "@/components/CookieBanner";
import "./globals.css";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://hooklab.eu";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://obc-maconnerie.fr";
export const metadata: Metadata = {
metadataBase: new URL(BASE_URL),
title: {
default:
"Cr\u00e9ation Site Internet Artisan & BTP Nord (59) | HookLab Flines-lez-Raches",
template: "%s | HookLab",
default: "OBC Maçonnerie | Constructeur & Maçon à Orchies (Nord 59)",
template: "%s | OBC Maçonnerie",
},
description:
"Sp\u00e9cialiste web pour artisans du b\u00e2timent et paysagistes \u00e0 Douai, Orchies, Valenciennes. Site ultra-rapide, s\u00e9curis\u00e9 et con\u00e7u pour g\u00e9n\u00e9rer des chantiers qualifi\u00e9s. Audit offert.",
"Benoît Colin, maçon expert à Mouchin. Construction de maison, rénovation, assainissement et gros œuvre dans un rayon de 30km autour d'Orchies. Devis gratuit.",
keywords: [
"site web artisan",
"cr\u00e9ation site artisan b\u00e2timent",
"r\u00e9f\u00e9rencement local artisan",
"agence web Nord",
"site internet couvreur",
"site internet menuisier",
"site internet paysagiste",
"visibilit\u00e9 Google artisan",
"site web Douai",
"site web Valenciennes",
"agence web Orchies",
"site pro artisan Nord",
"HookLab",
"hooklab.eu",
"cr\u00e9ation site internet Nord",
"site internet artisan 59",
"construction maison Nord",
"maçon construction maison Orchies",
"rénovation maison Nord 59",
"entreprise maçonnerie Mouchin",
"fondation ossature bois Nord",
"assainissement maison Nord",
"création accès maison Nord",
"démolition maison Nord 59",
"maçon rénovation Douai",
"maçon rénovation Valenciennes",
"gros œuvre Nord",
"entrepreneur maçon Nord 59",
"OBC Maçonnerie",
"Benoît Colin maçon",
"maçon Mouchin",
"construction maison Orchies",
],
authors: [{ name: "HookLab - Enguerrand Ozano" }],
creator: "HookLab",
publisher: "HookLab",
authors: [{ name: "OBC Maçonnerie - Benoît Colin" }],
creator: "OBC Maçonnerie",
publisher: "OBC Maçonnerie",
robots: {
index: true,
follow: true,
@@ -46,9 +45,7 @@ export const metadata: Metadata = {
},
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
],
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
apple: [
{ url: "/apple-touch-icon.svg", type: "image/svg+xml", sizes: "180x180" },
],
@@ -58,26 +55,25 @@ export const metadata: Metadata = {
type: "website",
locale: "fr_FR",
url: BASE_URL,
siteName: "HookLab",
title:
"HookLab | Sites web pour artisans du b\u00e2timent dans le Nord",
siteName: "OBC Maçonnerie",
title: "OBC Maçonnerie | Constructeur & Maçon dans le Nord (59)",
description:
"Transformez votre bouche-\u00e0-oreille en syst\u00e8me automatique. Sites web et r\u00e9f\u00e9rencement Google pour artisans \u00e0 Douai, Orchies, Valenciennes.",
"Construction de maison, rénovation, assainissement et gros œuvre autour d'Orchies, Douai, Valenciennes. Benoît Colin vous accompagne de A à Z.",
images: [
{
url: "/og-image.png",
url: "/og-image.jpg",
width: 1200,
height: 630,
alt: "HookLab - Sites web pour artisans du Nord",
alt: "OBC Maçonnerie - Construction et rénovation dans le Nord",
},
],
},
twitter: {
card: "summary_large_image",
title: "HookLab | Sites web pour artisans du Nord",
title: "OBC Maçonnerie | Maçon constructeur dans le Nord (59)",
description:
"Agence web locale pour artisans du b\u00e2timent. Douai, Orchies, Valenciennes.",
images: ["/og-image.png"],
"Construction de maison, rénovation, assainissement. Orchies, Douai, Valenciennes.",
images: ["/og-image.jpg"],
},
alternates: {
canonical: BASE_URL,
@@ -92,119 +88,85 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// Schema.org LocalBusiness
const jsonLdOrganization = {
const jsonLdBusiness = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${BASE_URL}/#organization`,
name: "HookLab",
"@id": `${BASE_URL}/#business`,
name: "OBC Maçonnerie",
description:
"Construction de maison, rénovation, assainissement et gros œuvre dans le Nord",
telephone: "06 74 45 30 89",
email: "contact@obc-maconnerie.fr",
url: BASE_URL,
logo: `${BASE_URL}/icon-512.svg`,
image: `${BASE_URL}/og-image.png`,
description:
"Agence web sp\u00e9cialis\u00e9e dans la cr\u00e9ation de sites et la visibilit\u00e9 Google pour les artisans du b\u00e2timent dans le Nord.",
telephone: "+33604408157",
email: "contact@hooklab.eu",
image: `${BASE_URL}/og-image.jpg`,
priceRange: "$$",
address: {
"@type": "PostalAddress",
streetAddress: "35 rue Mo\u00efse Lambert",
addressLocality: "Flines-lez-Raches",
postalCode: "59148",
streetAddress: "221 Route de Saint-Amand",
addressLocality: "Mouchin",
postalCode: "59310",
addressRegion: "Hauts-de-France",
addressCountry: "FR",
},
geo: {
"@type": "GeoCoordinates",
latitude: 50.4267,
longitude: 3.2372,
latitude: 50.4817,
longitude: 3.3342,
},
areaServed: [
{ "@type": "City", name: "Douai" },
{ "@type": "City", name: "Orchies" },
{ "@type": "City", name: "Mouchin" },
{ "@type": "City", name: "Douai" },
{ "@type": "City", name: "Valenciennes" },
{ "@type": "City", name: "Arleux" },
{ "@type": "City", name: "Flines-lez-Raches" },
{ "@type": "City", name: "Flines-lès-Raches" },
{ "@type": "City", name: "Saint-Amand-les-Eaux" },
{ "@type": "City", name: "Denain" },
{ "@type": "City", name: "Château-l'Abbaye" },
{ "@type": "City", name: "Mérignies" },
],
priceRange: "$$",
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Services de maçonnerie",
itemListElement: [
{ "@type": "Offer", "name": "Construction de maison" },
{ "@type": "Offer", "name": "Rénovation" },
{ "@type": "Offer", "name": "Assainissement" },
{ "@type": "Offer", "name": "Création d'accès" },
{ "@type": "Offer", "name": "Démolition" },
],
},
openingHoursSpecification: {
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "09:00",
closes: "18:00",
opens: "07:00",
closes: "19:00",
},
contactPoint: {
"@type": "ContactPoint",
telephone: "+33604408157",
telephone: "06 74 45 30 89",
contactType: "customer service",
availableLanguage: "French",
},
founder: {
"@type": "Person",
name: "Benoît Colin",
jobTitle: "Maçon - Gérant OBC Maçonnerie",
},
sameAs: [],
};
// Schema.org WebSite - aide Google à afficher le nom du site et les sitelinks
const jsonLdWebSite = {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${BASE_URL}/#website`,
name: "HookLab",
alternateName: "HookLab Tech",
name: "OBC Maçonnerie",
url: BASE_URL,
description:
"Cr\u00e9ation de sites internet et r\u00e9f\u00e9rencement Google pour artisans du b\u00e2timent dans le Nord (59).",
"Site officiel d'OBC Maçonnerie, entreprise de construction et rénovation dans le Nord (59).",
publisher: {
"@id": `${BASE_URL}/#organization`,
"@id": `${BASE_URL}/#business`,
},
inLanguage: "fr-FR",
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${BASE_URL}/?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
};
// Schema.org SiteNavigationElement - signale les pages principales à Google
const jsonLdNavigation = {
"@context": "https://schema.org",
"@type": "SiteNavigationElement",
"@id": `${BASE_URL}/#navigation`,
name: "Navigation principale",
hasPart: [
{
"@type": "WebPage",
name: "Accueil",
url: BASE_URL,
},
{
"@type": "WebPage",
name: "D\u00e9mo Ma\u00e7on / Couvreur",
url: `${BASE_URL}/macon`,
},
{
"@type": "WebPage",
name: "D\u00e9mo Paysagiste",
url: `${BASE_URL}/paysagiste`,
},
{
"@type": "WebPage",
name: "D\u00e9mo Plombier",
url: `${BASE_URL}/plombier`,
},
{
"@type": "WebPage",
name: "Mentions L\u00e9gales",
url: `${BASE_URL}/mentions-legales`,
},
{
"@type": "WebPage",
name: "Politique de Confidentialit\u00e9",
url: `${BASE_URL}/confidentialite`,
},
],
};
return (
@@ -216,7 +178,7 @@ export default function RootLayout({
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([jsonLdOrganization, jsonLdWebSite, jsonLdNavigation]),
__html: JSON.stringify([jsonLdBusiness, jsonLdWebSite]),
}}
/>
</head>

View File

@@ -1,157 +0,0 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const supabase = createClient();
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) {
if (authError.message.includes("Invalid login credentials")) {
setError("Email ou mot de passe incorrect.");
} else {
setError(authError.message);
}
return;
}
// Si un redirect est spécifié dans l'URL, l'utiliser directement
if (redirectTo) {
router.push(redirectTo);
router.refresh();
return;
}
// Sinon, vérifier si l'utilisateur est admin
if (authData.user) {
try {
const { data: profile } = await supabase
.from("profiles")
.select("is_admin, subscription_status")
.eq("id", authData.user.id)
.single();
const p = profile as { is_admin?: boolean; subscription_status?: string } | null;
if (p?.is_admin) {
router.push("/admin");
router.refresh();
return;
}
} catch {
// RLS peut bloquer, on continue vers dashboard
}
}
router.push("/dashboard");
router.refresh();
} catch {
setError("Erreur de connexion. Veuillez réessayer.");
} finally {
setLoading(false);
}
};
return (
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
<form onSubmit={handleLogin} className="space-y-5">
<Input
id="email"
label="Email"
type="email"
placeholder="ton@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
id="password"
label="Mot de passe"
type="password"
placeholder="Ton mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && (
<div className="p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
<Button type="submit" loading={loading} className="w-full">
Se connecter
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-white/40 text-sm">
Pas encore de compte ?{" "}
<Link
href="/candidature"
className="text-primary hover:text-primary-hover transition-colors"
>
Candidater
</Link>
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<main className="min-h-screen flex items-center justify-center px-4 bg-dark">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<span className="text-white font-bold">H</span>
</div>
<span className="text-2xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<h1 className="text-2xl font-bold text-white mb-2">
Content de te revoir
</h1>
<p className="text-white/60 text-sm">
Connecte-toi pour accéder à tes formations.
</p>
</div>
<Suspense fallback={
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8 flex items-center justify-center py-12">
<div className="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
}>
<LoginForm />
</Suspense>
</div>
</main>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Maçon Flines-lès-Raches | Construction & Rénovation | OBC Maçonnerie",
description:
"OBC Maçonnerie intervient à Flines-lès-Raches pour vos travaux de construction, rénovation et gros œuvre. Benoît Colin, maçon expert. Devis gratuit.",
keywords: ["maçon Flines-lès-Raches", "construction Flines Raches", "rénovation Flines Raches", "maçon Flines Nord"],
alternates: { canonical: "https://obc-maconnerie.fr/macon-flines-lez-raches" },
};
export default function MaconFlinesPage() {
return (
<LocalSEOPage
ville="Flines-lès-Raches"
departement="Nord (59148)"
servicesPrincipaux={["Construction de maison", "Rénovation"]}
description="Maçon à Flines-lès-Raches — OBC Maçonnerie intervient dans la commune pour vos travaux de construction et rénovation."
texteIntro="Vous avez un projet de maçonnerie à Flines-lès-Raches ? OBC Maçonnerie, basée à quelques kilomètres à Mouchin, intervient rapidement dans la commune."
texteLocal={`Flines-lès-Raches est l'une des communes que OBC Maçonnerie dessert régulièrement. Benoît Colin y réalise des chantiers de construction neuve, de rénovation de maison et d'assainissement.\n\nVotre maçon de proximité est à Mouchin, soit à quelques minutes de Flines-lès-Raches. Cette proximité garantit une réactivité optimale pour vos urgences et une meilleure coordination du chantier.\n\nContactez OBC Maçonnerie pour un devis gratuit à Flines-lès-Raches. Benoît se déplace pour évaluer votre projet et vous proposer la meilleure solution.`}
distanceMouchin="À environ 5 km"
/>
);
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Maçon Mouchin | Construction & Rénovation | OBC Maçonnerie",
description:
"OBC Maçonnerie est basée à Mouchin (59310). Benoît Colin, maçon expert local. Construction, rénovation, assainissement, gros œuvre. Devis gratuit.",
keywords: ["maçon Mouchin", "entreprise maçonnerie Mouchin", "construction Mouchin", "rénovation Mouchin"],
alternates: { canonical: "https://obc-maconnerie.fr/macon-mouchin" },
};
export default function MaconMouchinPage() {
return (
<LocalSEOPage
ville="Mouchin"
departement="Nord (59310)"
servicesPrincipaux={["Construction de maison", "Rénovation", "Assainissement"]}
description="OBC Maçonnerie, basée à Mouchin — votre entreprise de maçonnerie locale. Benoît Colin vous accompagne pour tous vos travaux."
texteIntro="Basée à Mouchin (59310), OBC Maçonnerie est votre entreprise de maçonnerie de proximité. Benoît Colin connaît parfaitement le secteur et intervient sur tous vos chantiers locaux."
texteLocal={`OBC Maçonnerie a ses racines à Mouchin (221 Route de Saint-Amand, 59310). C'est ici que Benoît Colin a fondé son entreprise, avec une ambition claire : offrir un service de maçonnerie expert, disponible et honnête aux particuliers et professionnels du secteur.\n\nMouchin et ses environs sont au cœur de notre zone d'intervention. Nous connaissons les terrains, les réglementations locales et les contraintes spécifiques à la commune. Cette proximité est un vrai avantage pour vos projets de construction, rénovation ou assainissement.\n\nEn tant qu'entreprise locale mouchinoise, OBC Maçonnerie est fier de contribuer au développement et à la rénovation du bâti de la commune et des villages environnants. Contactez Benoît pour un devis gratuit.`}
/>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Maçon Saint-Amand-les-Eaux | Construction & Rénovation | OBC Maçonnerie",
description:
"OBC Maçonnerie intervient à Saint-Amand-les-Eaux pour vos travaux de construction, rénovation, assainissement et gros œuvre. Devis gratuit.",
keywords: ["maçon Saint-Amand-les-Eaux", "construction Saint-Amand", "rénovation Saint-Amand les Eaux", "maçon Saint-Amand Nord"],
alternates: { canonical: "https://obc-maconnerie.fr/macon-saint-amand-les-eaux" },
};
export default function MaconSaintAmandPage() {
return (
<LocalSEOPage
ville="Saint-Amand-les-Eaux"
departement="Nord (59230)"
servicesPrincipaux={["Construction de maison", "Rénovation", "Assainissement"]}
description="Maçon à Saint-Amand-les-Eaux — OBC Maçonnerie intervient dans la commune pour tous vos travaux de maçonnerie."
texteIntro="Vous recherchez un maçon de confiance à Saint-Amand-les-Eaux ? OBC Maçonnerie intervient dans toute la commune et ses alentours pour vos projets de construction et rénovation."
texteLocal={`Saint-Amand-les-Eaux est une commune importante de notre zone d'intervention. OBC Maçonnerie y réalise régulièrement des chantiers de construction de maison individuelle, de rénovation et d'assainissement non collectif.\n\nLa commune de Saint-Amand-les-Eaux est connue pour son patrimoine architectural. Benoît Colin apprécie travailler sur les maisons de la région, souvent en pierre ou en brique, qui nécessitent une connaissance spécifique des matériaux traditionnels.\n\nPour un devis gratuit à Saint-Amand-les-Eaux, contactez OBC Maçonnerie au 06 74 45 30 89 ou via notre formulaire de contact.`}
distanceMouchin="À environ 10 km"
/>
);
}

View File

@@ -1,286 +0,0 @@
"use client";
import { useState } from "react";
import MagicReveal from "@/components/ui/MagicReveal";
import Button from "@/components/ui/Button";
interface MaconClientProps {
type?: "slider" | "form" | "faq" | "floating";
avantLabel?: string;
apresLabel?: string;
avantImage?: string;
apresImage?: string;
faqs?: { q: string; a: string }[];
}
export default function MaconClient({
type,
avantLabel,
apresLabel,
avantImage,
apresImage,
faqs,
}: MaconClientProps) {
if (type === "slider") {
return (
<MagicReveal
avantLabel={avantLabel || ""}
apresLabel={apresLabel || ""}
avantImage={avantImage || ""}
apresImage={apresImage || ""}
height="h-64"
/>
);
}
if (type === "form") {
return <DevisForm />;
}
if (type === "faq") {
return <FaqAccordion faqs={faqs || []} />;
}
if (type === "floating") {
return <FloatingCTA />;
}
return null;
}
/* ============================================================
FAQ ACCORDION
============================================================ */
function FaqAccordion({ faqs }: { faqs: { q: string; a: string }[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(null);
return (
<div className="space-y-3">
{faqs.map((faq, i) => {
const isOpen = openIdx === i;
return (
<div key={i} className="bg-[#f8f6f3] border border-gray-200 rounded-xl overflow-hidden">
<button
onClick={() => setOpenIdx(isOpen ? null : i)}
className="w-full flex items-center justify-between p-5 text-left cursor-pointer"
>
<span className="text-navy font-semibold text-sm pr-4">{faq.q}</span>
<svg
className={`w-5 h-5 text-orange shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="px-5 pb-5 -mt-1">
<p className="text-text-light text-sm leading-relaxed">{faq.a}</p>
</div>
)}
</div>
);
})}
</div>
);
}
/* ============================================================
SMART DEVIS FORM
============================================================ */
function DevisForm() {
const [step, setStep] = useState<"type" | "details" | "done">("type");
const [projectType, setProjectType] = useState("");
const [fields, setFields] = useState({ name: "", phone: "", ville: "", description: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateField = (key: keyof typeof fields, value: string) =>
setFields((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/devis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...fields, projectType }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erreur lors de l'envoi");
}
setStep("done");
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inattendue");
} finally {
setLoading(false);
}
};
if (step === "done") {
return (
<div className="bg-white rounded-2xl p-6 sm:p-8 text-center">
<div className="w-14 h-14 bg-green-50 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-navy font-bold text-lg mb-2">Demande envoyée !</h3>
<p className="text-text-muted text-sm">Nous vous recontactons sous 24h pour votre devis.</p>
</div>
);
}
if (step === "details") {
return (
<div className="bg-white rounded-2xl p-6 sm:p-8">
<div className="flex items-center gap-2 mb-6">
<span className="bg-orange text-white text-xs font-bold px-2.5 py-1 rounded-full">2/2</span>
<h3 className="text-navy font-bold text-lg">Vos coordonn&eacute;es</h3>
</div>
<p className="text-text-muted text-sm mb-5">
Projet : <strong className="text-navy">{projectType}</strong>
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text mb-1.5">Votre nom</label>
<input
type="text"
required
placeholder="Marc Dupont"
value={fields.name}
onChange={(e) => updateField("name", e.target.value)}
className="w-full px-4 py-3 bg-[#f8f6f3] border border-gray-200 rounded-xl text-text text-sm placeholder:text-text-muted focus:border-orange focus:ring-1 focus:ring-orange outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1.5">T&eacute;l&eacute;phone</label>
<input
type="tel"
required
placeholder="06 12 34 56 78"
value={fields.phone}
onChange={(e) => updateField("phone", e.target.value)}
className="w-full px-4 py-3 bg-[#f8f6f3] border border-gray-200 rounded-xl text-text text-sm placeholder:text-text-muted focus:border-orange focus:ring-1 focus:ring-orange outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1.5">Ville</label>
<input
type="text"
required
placeholder="Orchies, Cysoing, Sam&eacute;on..."
value={fields.ville}
onChange={(e) => updateField("ville", e.target.value)}
className="w-full px-4 py-3 bg-[#f8f6f3] border border-gray-200 rounded-xl text-text text-sm placeholder:text-text-muted focus:border-orange focus:ring-1 focus:ring-orange outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1.5">D&eacute;crivez votre projet (optionnel)</label>
<textarea
placeholder="Surface, type de travaux, d&eacute;lais souhait&eacute;s..."
rows={3}
value={fields.description}
onChange={(e) => updateField("description", e.target.value)}
className="w-full px-4 py-3 bg-[#f8f6f3] border border-gray-200 rounded-xl text-text text-sm placeholder:text-text-muted focus:border-orange focus:ring-1 focus:ring-orange outline-none resize-none"
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<Button type="submit" size="lg" className="w-full" loading={loading}>
Envoyer ma demande de devis
</Button>
<button
type="button"
onClick={() => setStep("type")}
className="w-full text-text-muted hover:text-text text-sm underline cursor-pointer"
>
&larr; Retour
</button>
</form>
</div>
);
}
return (
<div className="bg-white rounded-2xl p-6 sm:p-8">
<div className="flex items-center gap-2 mb-6">
<span className="bg-orange text-white text-xs font-bold px-2.5 py-1 rounded-full">1/2</span>
<h3 className="text-navy font-bold text-lg">Quel type de projet&nbsp;?</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{[
{
label: "Projet Extension",
desc: "Agrandissement, garage, sur\u00e9l\u00e9vation",
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
label: "R\u00e9novation",
desc: "Fa\u00e7ade, rejointoiement, murs",
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
},
{
label: "Petits Travaux",
desc: "Terrasse, muret, dalle",
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
),
},
].map((item) => (
<button
key={item.label}
onClick={() => {
setProjectType(item.label);
setStep("details");
}}
className="p-5 rounded-xl border-2 border-gray-200 bg-[#f8f6f3] hover:border-orange hover:shadow-md text-center transition-all cursor-pointer group"
>
<div className="w-10 h-10 bg-orange/10 rounded-lg flex items-center justify-center mx-auto mb-3 text-orange group-hover:bg-orange group-hover:text-white transition-colors">
{item.icon}
</div>
<p className="font-semibold text-navy text-sm mb-1">{item.label}</p>
<p className="text-text-muted text-xs">{item.desc}</p>
</button>
))}
</div>
</div>
);
}
/* ============================================================
FLOATING MOBILE CTA
============================================================ */
function FloatingCTA() {
return (
<div className="fixed bottom-4 left-4 right-4 z-50 md:hidden">
<a
href="tel:+33600000000"
className="flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold text-sm py-3.5 rounded-xl shadow-lg transition-colors w-full"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
Appeler maintenant
</a>
</div>
);
}

View File

@@ -1,565 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import MaconClient from "./MaconClient";
import { getSiteImages } from "@/lib/site-images";
export const revalidate = 60;
export const metadata: Metadata = {
title:
"Ma\u00e7onnerie & Extension de Maison \u00e0 Orchies, Cysoing, Saint-Amand-les-Eaux | Artisan Ma\u00e7on Nord (59)",
description:
"Artisan ma\u00e7on depuis 10 ans. Extension de maison, gros \u0153uvre, r\u00e9novation, rejointoiement de briques, terrasse. Devis gratuit sous 24h. Orchies, Cysoing, Saint-Amand-les-Eaux, Sam\u00e9on et P\u00e9v\u00e8le.",
keywords: [
"ma\u00e7on Orchies",
"ma\u00e7onnerie Cysoing",
"extension maison Nord",
"agrandissement maison 59",
"ma\u00e7on Saint-Amand-les-Eaux",
"artisan ma\u00e7on P\u00e9v\u00e8le",
"gros \u0153uvre Nord",
"r\u00e9novation fa\u00e7ade",
"rejointoiement briques",
"terrasse carrel\u00e9e",
"dalle b\u00e9ton",
"ouverture mur porteur IPN",
"ma\u00e7on Sam\u00e9on",
"ma\u00e7on Landas",
"ma\u00e7on Nomain",
],
alternates: {
canonical: "https://hooklab.eu/macon",
},
};
const services = [
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
title: "Extension de maison & Garage",
desc: "Agrandissez votre surface habitable : extension en parpaing ou brique, garage, toit plat ou traditionnel. Nous g\u00e9rons le gros \u0153uvre de A \u00e0 Z.",
},
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
title: "Ma\u00e7onnerie G\u00e9n\u00e9rale",
desc: "Murs de cl\u00f4ture, fondations, ouverture de murs porteurs avec pose d\u2019IPN. Construction neuve ou reprise de ma\u00e7onnerie ancienne.",
},
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
),
title: "R\u00e9novation & Fa\u00e7ade",
desc: "Rejointoiement de briques, ravalement de fa\u00e7ade, dalle b\u00e9ton, reprise d\u2019enduit. Redonner vie \u00e0 votre b\u00e2ti sans compromis.",
},
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
),
title: "Am\u00e9nagement Ext\u00e9rieur",
desc: "Terrasse carrel\u00e9e, all\u00e9es, pavage, muret. Cr\u00e9ez un ext\u00e9rieur qui met en valeur votre maison et votre terrain.",
},
];
const faqs = [
{
q: "Combien de temps pour un devis\u00a0?",
a: "Nous nous d\u00e9pla\u00e7ons pour une visite technique sous 48h, et le devis d\u00e9taill\u00e9 vous est remis dans la foul\u00e9e. Gratuit et sans engagement.",
},
{
q: "Vous avez la d\u00e9cennale\u00a0?",
a: "Oui, bien s\u00fbr. L\u2019attestation de garantie d\u00e9cennale est syst\u00e9matiquement fournie avec chaque devis. Votre ouvrage est couvert pendant 10 ans.",
},
{
q: "Vous faites l\u2019\u00e9vacuation des gravats\u00a0?",
a: "Oui. Chantier propre garanti. Nous g\u00e9rons l\u2019\u00e9vacuation des gravats et le nettoyage en fin de chantier. Vous n\u2019avez rien \u00e0 faire.",
},
];
export default async function MaconPage() {
const images = await getSiteImages();
return (
<main className="min-h-screen bg-[#f8f6f3]">
{/* ============================================================
SECTION 1 : HERO
============================================================ */}
<section className="relative min-h-[85vh] flex items-center bg-navy overflow-hidden">
{/* Background image overlay */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url('${images.macon_hero}')`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-r from-[#1b2a4a]/95 via-[#1b2a4a]/85 to-[#1b2a4a]/70" />
{/* Nav bar */}
<nav className="absolute top-0 left-0 right-0 z-30">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<Link href="/" className="text-white/60 hover:text-white text-sm transition-colors">
&larr; HookLab
</Link>
<span className="text-white/30">|</span>
<span className="text-white font-bold text-sm">
[Votre Entreprise] &mdash; <span className="text-orange">Ma&ccedil;onnerie</span>
</span>
</div>
<a
href="tel:+33600000000"
className="bg-orange hover:bg-orange-hover text-white font-bold text-sm px-4 py-2 rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span className="hidden sm:inline">06 XX XX XX XX</span>
<span className="sm:hidden">Appeler</span>
</a>
</div>
</nav>
<div className="relative z-20 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32">
{/* Google badge */}
<div className="inline-flex items-center gap-2 bg-white/10 border border-white/20 rounded-full px-4 py-2 mb-8 backdrop-blur-sm">
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
))}
</div>
<span className="text-white text-sm font-semibold">4.9/5 sur Google</span>
</div>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-[1.1] tracking-[-0.03em] mb-6 max-w-3xl">
Ma&ccedil;onnerie &amp; Extension de Maison &agrave;{" "}
<span className="text-orange">Orchies, Cysoing et Saint-Amand-les-Eaux.</span>
</h1>
<p className="text-white/70 text-lg sm:text-xl max-w-2xl mb-4 leading-relaxed">
Artisan ma&ccedil;on depuis 10 ans. Sp&eacute;cialiste agrandissement, gros &oelig;uvre
et r&eacute;novation dans le P&eacute;v&egrave;le.
</p>
<p className="text-white/40 text-sm mb-8">
Garantie d&eacute;cennale &middot; Devis gratuit &middot; Intervention sur tout le secteur Nord (59)
</p>
<div className="flex flex-col sm:flex-row gap-4">
<a
href="#devis"
className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold text-base px-8 py-4 rounded-xl transition-colors pulse-glow"
>
Obtenir mon devis sous 24h
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
<a
href="tel:+33600000000"
className="inline-flex items-center justify-center gap-2 border-2 border-white/20 text-white font-semibold text-sm px-6 py-4 rounded-xl hover:bg-white/10 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Appeler directement
</a>
</div>
</div>
</section>
{/* ============================================================
SECTION 2 : REASSURANCE
============================================================ */}
<section className="py-14 md:py-20 bg-white border-b border-gray-100">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-6">
{[
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
title: "Garantie D\u00e9cennale incluse",
desc: "Votre ouvrage est couvert 10 ans. Attestation fournie avec le devis.",
},
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
title: "Devis gratuit & d\u00e9taill\u00e9",
desc: "Pas de mauvaise surprise. Chaque poste est d\u00e9taill\u00e9 et expliqu\u00e9 clairement.",
},
{
icon: (
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
title: "Intervention rapide",
desc: "Sur Orchies, Cysoing, Saint-Amand-les-Eaux et dans un rayon de 25\u00a0km.",
},
].map((item, i) => (
<div key={i} className="text-center md:text-left flex flex-col items-center md:items-start gap-3">
<div className="w-14 h-14 bg-orange/10 rounded-2xl flex items-center justify-center text-orange">
{item.icon}
</div>
<div>
<h3 className="text-navy font-bold text-base mb-1">{item.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* ============================================================
SECTION 3 : SERVICES (SEO + EXPERTISE)
============================================================ */}
<section className="py-16 md:py-24 bg-[#f8f6f3]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-14">
<span className="inline-block px-3 py-1.5 bg-orange/10 border border-orange/20 rounded-full text-orange text-xs font-semibold mb-4">
Nos Prestations
</span>
<h2 className="text-2xl md:text-3xl lg:text-4xl font-bold text-navy tracking-[-0.02em] mb-3">
Tous vos travaux de <span className="text-orange">ma&ccedil;onnerie</span> dans le Nord
</h2>
<p className="text-text-light text-base md:text-lg max-w-2xl mx-auto">
Du gros &oelig;uvre &agrave; la finition, nous intervenons sur tous types de projets
pour les particuliers et professionnels du P&eacute;v&egrave;le.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{services.map((s, i) => (
<div
key={i}
className="bg-white border border-gray-200 rounded-2xl p-6 hover:shadow-lg hover:-translate-y-1 transition-all"
>
<div className="w-12 h-12 bg-orange/10 rounded-xl flex items-center justify-center text-orange mb-4">
{s.icon}
</div>
<h3 className="text-navy font-bold text-lg mb-2">{s.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ============================================================
SECTION 4 : AVANT / APRES
============================================================ */}
<section className="py-16 md:py-24 bg-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<span className="inline-block px-3 py-1.5 bg-orange/10 border border-orange/20 rounded-full text-orange text-xs font-semibold mb-4">
R&eacute;alisations
</span>
<h2 className="text-2xl md:text-3xl lg:text-4xl font-bold text-navy mb-3">
Vos voisins nous font <span className="text-orange">confiance</span>
</h2>
<p className="text-text-light text-base md:text-lg max-w-2xl mx-auto">
Glissez la barre pour voir la transformation. Ce sont de vrais chantiers r&eacute;alis&eacute;s dans le secteur.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{
avant: "Maison dans son jus",
apres: "Extension moderne 30m\u00b2 + terrasse",
avantImg: images.macon_slider1_gauche,
apresImg: images.macon_slider1_droite,
legend: "Extension 30m\u00b2 \u00e0 Cysoing \u2014 R\u00e9alis\u00e9 en 4 semaines.",
},
{
avant: "Fa\u00e7ade fissur\u00e9e",
apres: "Ravalement complet",
avantImg: images.macon_slider2_gauche,
apresImg: images.macon_slider2_droite,
legend: "Rejointoiement briques \u00e0 Orchies.",
},
{
avant: "Terrain nu",
apres: "Terrasse carrel\u00e9e + muret",
avantImg: images.macon_slider3_gauche,
apresImg: images.macon_slider3_droite,
legend: "Am\u00e9nagement ext\u00e9rieur \u00e0 Sam\u00e9on.",
},
].map((item, i) => (
<div key={i}>
<MaconClient
type="slider"
avantLabel={item.avant}
apresLabel={item.apres}
avantImage={item.avantImg}
apresImage={item.apresImg}
/>
<p className="text-text-light text-xs mt-2 text-center italic">{item.legend}</p>
</div>
))}
</div>
</div>
</section>
{/* ============================================================
SECTION 5 : ZONE D'INTERVENTION (SEO LOCAL)
============================================================ */}
<section className="py-16 md:py-24 bg-[#f8f6f3]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-center">
{/* Map */}
<div className="rounded-2xl overflow-hidden shadow-lg border border-gray-200 h-[350px] md:h-[420px]">
<iframe
src="https://www.openstreetmap.org/export/embed.html?bbox=3.05%2C50.38%2C3.45%2C50.52&layer=mapnik&marker=50.4567%2C3.2300"
className="w-full h-full border-0"
title="Zone d'intervention ma\u00e7onnerie Nord"
loading="lazy"
/>
</div>
{/* Text */}
<div>
<span className="inline-block px-3 py-1.5 bg-orange/10 border border-orange/20 rounded-full text-orange text-xs font-semibold mb-4">
Proximit&eacute;
</span>
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-4">
Votre ma&ccedil;on de proximit&eacute; dans le <span className="text-orange">Nord (59)</span>
</h2>
<p className="text-text-light text-base leading-relaxed mb-4">
Bas&eacute;s &agrave; <strong className="text-navy">Sam&eacute;on</strong>, nous sommes rapidement
sur vos chantiers &agrave; <strong className="text-navy">Orchies</strong>,{" "}
<strong className="text-navy">Cysoing</strong> ou{" "}
<strong className="text-navy">Saint-Amand-les-Eaux</strong>.
</p>
<p className="text-text-light text-sm leading-relaxed mb-6">
Nous intervenons pour tous vos travaux de ma&ccedil;onnerie &agrave;{" "}
<strong>Orchies</strong>, <strong>Cysoing</strong>, <strong>Saint-Amand-les-Eaux</strong>,{" "}
<strong>Sam&eacute;on</strong>, <strong>Landas</strong>, <strong>Beuvry-la-For&ecirc;t</strong>,{" "}
<strong>Nomain</strong>, <strong>Genech</strong>, <strong>Templeuve</strong> et dans
tout le secteur du P&eacute;v&egrave;le et du Douaisis.
</p>
<a
href="#devis"
className="inline-flex items-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold text-sm px-6 py-3 rounded-xl transition-colors"
>
Demander un devis
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
</div>
</div>
</section>
{/* ============================================================
SECTION 6 : QUI SUIS-JE ?
============================================================ */}
<section className="py-16 md:py-24 bg-navy">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
{/* Photo placeholder */}
<div className="flex justify-center">
<div className="relative">
<div className="absolute -inset-3 bg-orange/20 rounded-3xl blur-xl" />
<div className="relative w-72 h-80 sm:w-80 sm:h-96 rounded-2xl overflow-hidden border-2 border-white/10">
{images.macon_photo_cyprien ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={images.macon_photo_cyprien}
alt="Cyprien, artisan maçon"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-navy-light flex items-center justify-center">
<div className="text-center">
<div className="w-20 h-20 bg-orange/20 rounded-full flex items-center justify-center mx-auto mb-3">
<svg className="w-10 h-10 text-orange/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<p className="text-white/30 text-sm">Photo de Cyprien</p>
<p className="text-white/20 text-xs mt-1">(sur le chantier)</p>
</div>
</div>
)}
</div>
<div className="absolute -bottom-3 -right-3 bg-orange text-white text-xs font-bold px-4 py-2 rounded-xl shadow-lg">
Artisan depuis 10 ans
</div>
</div>
</div>
{/* Bio */}
<div>
<span className="inline-block px-3 py-1.5 bg-white/10 rounded-full text-orange text-xs font-semibold mb-4">
L&rsquo;artisan derri&egrave;re les chantiers
</span>
<h2 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white tracking-[-0.02em] mb-4">
Je suis Cyprien,{" "}
<span className="text-orange">artisan ma&ccedil;on passionn&eacute;.</span>
</h2>
<p className="text-white/80 text-base leading-relaxed mb-4">
Pas de commerciaux, pas de sous-traitance. Je suis votre interlocuteur unique
du devis &agrave; la fin du chantier. Quand vous m&rsquo;appelez, c&rsquo;est moi qui r&eacute;ponds.
</p>
<p className="text-white/60 text-base leading-relaxed mb-6">
Apr&egrave;s 10 ans &agrave; b&acirc;tir dans le P&eacute;v&egrave;le, je connais chaque type de terrain,
chaque contrainte locale. Mon engagement : un travail propre, dans les d&eacute;lais,
au prix annonc&eacute;. Sans mauvaise surprise.
</p>
<div className="flex flex-wrap gap-4">
{[
"Interlocuteur unique",
"Z\u00e9ro sous-traitance",
"Chantier propre garanti",
].map((item) => (
<div key={item} className="flex items-center gap-2">
<div className="w-5 h-5 bg-orange/20 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-white/70 text-sm">{item}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* ============================================================
SECTION 7 : FAQ
============================================================ */}
<section className="py-16 md:py-24 bg-white">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-3">
Questions <span className="text-orange">fr&eacute;quentes</span>
</h2>
</div>
<MaconClient type="faq" faqs={faqs} />
</div>
</section>
{/* ============================================================
SECTION 8 : FORMULAIRE INTELLIGENT (DEVIS)
============================================================ */}
<section id="devis" className="py-16 md:py-24 bg-navy">
<div className="max-w-2xl mx-auto px-4">
<div className="text-center mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3">
Demander un <span className="text-orange">devis gratuit</span>
</h2>
<p className="text-white/60">
S&eacute;lectionnez votre type de projet. Devis d&eacute;taill&eacute; sous 48h.
</p>
</div>
<MaconClient type="form" />
</div>
</section>
{/* ============================================================
CTA HookLab
============================================================ */}
<section className="py-12 bg-orange text-center">
<div className="max-w-2xl mx-auto px-4">
<p className="text-white/80 text-xs font-semibold uppercase tracking-wider mb-3">
Ceci est un mod&egrave;le HookLab
</p>
<h2 className="text-xl md:text-2xl font-bold text-white mb-4">
Vous voulez le m&ecirc;me site pour votre entreprise&nbsp;?
</h2>
<p className="text-white/80 text-sm mb-6">
Imaginez votre logo, vos photos de chantier et votre num&eacute;ro &agrave; la place.
</p>
<Link
href="/#contact"
className="inline-flex items-center gap-2 bg-navy hover:bg-navy-light text-white font-bold text-sm px-6 py-3 rounded-xl transition-colors"
>
Demander Mon Diagnostic Gratuit
</Link>
</div>
</section>
{/* ============================================================
FOOTER SEO
============================================================ */}
<footer className="bg-navy-dark border-t border-white/10 py-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-sm">
{/* NAP */}
<div>
<h4 className="text-white font-semibold mb-3">Coordonn&eacute;es</h4>
<ul className="space-y-1 text-white/50">
<li className="font-semibold text-white">[Votre Entreprise]</li>
<li>Sam&eacute;on, 59310</li>
<li>T&eacute;l : 06 XX XX XX XX</li>
<li>SIRET : XXX XXX XXX XXXXX</li>
</ul>
</div>
{/* Expertises SEO */}
<div>
<h4 className="text-white font-semibold mb-3">Expertises</h4>
<ul className="space-y-1 text-white/50">
<li>Ma&ccedil;on Orchies</li>
<li>Ma&ccedil;on Cysoing</li>
<li>Ma&ccedil;on Saint-Amand-les-Eaux</li>
<li>Extension maison P&eacute;v&egrave;le</li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="text-white font-semibold mb-3">L&eacute;gal</h4>
<ul className="space-y-1">
<li>
<Link href="/mentions-legales" className="text-white/50 hover:text-white text-sm transition-colors">
Mentions l&eacute;gales
</Link>
</li>
<li>
<Link href="/confidentialite" className="text-white/50 hover:text-white text-sm transition-colors">
Politique de confidentialit&eacute;
</Link>
</li>
</ul>
</div>
</div>
<div className="border-t border-white/10 mt-8 pt-6 text-center">
<p className="text-white/30 text-xs">
Site cr&eacute;&eacute; par{" "}
<Link href="/" className="text-orange hover:underline">HookLab</Link>{" "}
&mdash; Cr&eacute;ation de sites internet pour artisans du b&acirc;timent.
</p>
</div>
</div>
</footer>
{/* Floating mobile CTA */}
<MaconClient type="floating" />
</main>
);
}

View File

@@ -1,195 +1,101 @@
import type { Metadata } from "next";
import Link from "next/link";
import Navbar from "@/components/marketing/Navbar";
import Footer from "@/components/marketing/Footer";
export const metadata: Metadata = {
title: "Mentions Légales | HookLab",
title: "Mentions Légales | OBC Maçonnerie",
description:
"Mentions légales du site HookLab.eu - Agence web pour artisans du bâtiment à Flines-lez-Raches (59). SIREN 994 538 932.",
alternates: {
canonical: "https://hooklab.eu/mentions-legales",
},
"Mentions légales du site OBC Maçonnerie — Benoît Colin, maçon à Mouchin (59310). SIREN 531 827 871.",
alternates: { canonical: "https://obc-maconnerie.fr/mentions-legales" },
robots: { index: false, follow: false },
};
export default function MentionsLegales() {
return (
<main className="min-h-screen py-20 md:py-32 bg-dark-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
{/* Bouton retour */}
<Link
href="/"
className="inline-flex items-center gap-2 mb-10 text-white/40 hover:text-white text-sm transition-colors group"
<main id="main-content" className="min-h-screen bg-bg">
<Navbar />
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16 md:py-20">
<Link
href="/"
className="inline-flex items-center gap-2 mb-8 text-text-light hover:text-navy text-sm transition-colors group"
>
<svg
className="w-4 h-4 transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-4 h-4 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Retour à l&apos;accueil
</Link>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-10">Mentions Légales</h1>
<h1 className="text-3xl md:text-4xl font-bold text-navy mb-10">Mentions Légales</h1>
<div className="space-y-12 text-white/70 text-sm leading-relaxed">
{/* Introduction Légale */}
<p className="text-white/60 italic">
Conformément aux dispositions de la loi n° 2004-575 du 21 juin 2004 pour la confiance en l&apos;économie numérique (LCEN),
il est précisé aux utilisateurs du site <strong>hooklab.eu</strong> l&apos;identité des différents intervenants dans le cadre de sa réalisation et de son suivi.
<div className="space-y-10 text-text-light text-sm leading-relaxed">
<p className="italic text-text-muted">
Conformément aux dispositions de la loi n° 2004-575 du 21 juin 2004 pour la confiance en l&apos;économie numérique (LCEN), voici les informations légales du site <strong className="text-text">obc-maconnerie.fr</strong>.
</p>
{/* Section 1 : Édition */}
<section>
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<span className="text-primary">1.</span> Édition du site
</h2>
<div className="bg-white/5 p-6 rounded-lg border border-white/10">
<p className="mb-4">
Le présent site, accessible à l&apos;URL <a href="https://hooklab.eu" className="text-primary hover:underline">https://hooklab.eu</a> (le « Site »), est édité par :
</p>
<p className="mb-4">
<strong className="text-white">Enguerrand OZANO</strong><br />
Exerçant sous l&apos;enseigne commerciale <strong className="text-white">HookLab</strong>.
</p>
<ul className="space-y-2">
<li><strong className="text-white">Statut :</strong> Entrepreneur individuel (EI)</li>
<li><strong className="text-white">SIREN :</strong> 994 538 932 (R.C.S. de Douai)</li>
<li><strong className="text-white">Numéro de TVA Intracommunautaire :</strong> FR16994538932</li>
<li><strong className="text-white">Siège social :</strong> 35 rue Moïse Lambert, 59148 Flines-lez-Raches, France</li>
<h2 className="text-xl font-bold text-navy mb-4">1. Édition du site</h2>
<div className="bg-bg-white border border-border rounded-xl p-6 space-y-2">
<p>Le présent site est édité par :</p>
<p><strong className="text-text">Benoît COLIN</strong><br />Exerçant sous l&apos;enseigne commerciale <strong className="text-text">OBC Maçonnerie</strong></p>
<ul className="space-y-1 mt-3">
<li><strong className="text-text">Statut :</strong> Entreprise individuelle</li>
<li><strong className="text-text">SIREN :</strong> 531 827 871</li>
<li><strong className="text-text">Siège social :</strong> 221 Route de Saint-Amand, 59310 Mouchin, France</li>
<li><strong className="text-text">Téléphone :</strong> <a href="tel:0674453089" className="text-orange hover:underline">06 74 45 30 89</a></li>
<li><strong className="text-text">Email :</strong> <a href="mailto:contact@obc-maconnerie.fr" className="text-orange hover:underline">contact@obc-maconnerie.fr</a></li>
</ul>
<div className="mt-4 pt-4 border-t border-white/10">
<p className="font-semibold text-white mb-2">Contact officiel :</p>
<ul className="space-y-1">
<li><strong className="text-white">Téléphone :</strong> <a href="tel:+33604408157" className="hover:text-white transition-colors">06 04 40 81 57</a></li>
<li><strong className="text-white">Email :</strong> <a href="mailto:contact@hooklab.eu" className="hover:text-white transition-colors">contact@hooklab.eu</a></li>
</ul>
</div>
<p className="mt-4">
<strong className="text-white">Directeur de la publication :</strong> Enguerrand OZANO
</p>
<p className="mt-3"><strong className="text-text">Directeur de la publication :</strong> Benoît COLIN</p>
</div>
</section>
{/* Section 2 : Hébergement */}
<section>
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<span className="text-primary">2.</span> Hébergement
</h2>
<div className="bg-white/5 p-6 rounded-lg border border-white/10">
<p className="mb-3">
Le Site est hébergé par la société <strong className="text-white">Vercel Inc.</strong>, dont les serveurs assurent une disponibilité et une sécurité optimales.
</p>
<ul className="space-y-1">
<li><strong className="text-white">Adresse :</strong> 440 N Barranca Ave #4133, Covina, CA 91723, États-Unis</li>
<li><strong className="text-white">Contact technique :</strong> <a href="mailto:privacy@vercel.com" className="hover:text-white transition-colors">privacy@vercel.com</a></li>
</ul>
<h2 className="text-xl font-bold text-navy mb-4">2. Conception & réalisation</h2>
<div className="bg-bg-white border border-border rounded-xl p-6">
<p>Ce site a é conçu et réalisé par <strong className="text-text">HookLab</strong> Enguerrand Ozano, agence web spécialisée dans les sites pour artisans du bâtiment dans le Nord.</p>
</div>
</section>
{/* Section 3 : Propriété Intellectuelle */}
<section>
<h2 className="text-xl font-semibold text-white mb-3">
<span className="text-primary">3.</span> Propriété intellectuelle et Droits d&apos;auteur
</h2>
<div className="space-y-4">
<div>
<p className="font-semibold text-white mb-2">Contenu HookLab :</p>
<p>
L&apos;ensemble de ce site relève de la législation française et internationale sur le droit d&apos;auteur et la propriété intellectuelle.
Tous les droits de reproduction sont réservés. La structure générale, les textes, graphismes, logos (notamment le logo HookLab),
et la mise en forme sont la propriété exclusive d&apos;Enguerrand OZANO.
</p>
</div>
<div>
<p className="font-semibold text-white mb-2">Contenu Tiers (Portfolio et Clients) :</p>
<p>
Les marques, logos et visuels des sites clients présentés dans la section &quot;Réalisations&quot; ou &quot;Portfolio&quot; appartiennent à leurs propriétaires respectifs.
Ils sont utilisés sur ce site à titre d&apos;illustration du savoir-faire de HookLab, avec l&apos;accord des clients concernés.
</p>
</div>
<p className="pt-2">
Toute exploitation non autorisée du site ou de l&apos;un quelconque des éléments qu&apos;il contient sera considérée comme constitutive d&apos;une contrefaçon
et poursuivie conformément aux dispositions des articles L.335-2 et suivants du Code de Propriété Intellectuelle.
</p>
<h2 className="text-xl font-bold text-navy mb-4">3. Hébergement</h2>
<div className="bg-bg-white border border-border rounded-xl p-6">
<p>Le site est hébergé par <strong className="text-text">Vercel Inc.</strong></p>
<p className="mt-2">440 N Barranca Ave #4133, Covina, CA 91723, États-Unis</p>
</div>
</section>
{/* Section 4 : Responsabilité */}
<section>
<h2 className="text-xl font-semibold text-white mb-3">
<span className="text-primary">4.</span> Responsabilité
</h2>
<div className="space-y-4">
<div>
<p className="font-semibold text-white mb-2">Contenu :</p>
<p>
HookLab s&apos;efforce de fournir sur le site des informations aussi précises que possible. Toutefois, Enguerrand OZANO ne pourra être tenu responsable
des oublis, des inexactitudes et des carences dans la mise à jour.
</p>
</div>
<div>
<p className="font-semibold text-white mb-2">Technique :</p>
<p>
L&apos;éditeur ne pourra être tenu responsable des dommages directs et indirects causés au matériel de l&apos;utilisateur lors de l&apos;accès au site
(bug, incompatibilité, virus), bien que le site soit sécurisé par un protocole HTTPS et hébergé sur une infrastructure moderne.
</p>
</div>
</div>
</section>
{/* Section 5 : Données Personnelles et Cookies */}
<section>
<h2 className="text-xl font-semibold text-white mb-3">
<span className="text-primary">5.</span> Données personnelles et Cookies
</h2>
<p className="mb-4">
Dans une optique de transparence et de respect du RGPD, HookLab a défini une politique claire concernant la collecte et le traitement de vos données.
</p>
<ul className="space-y-2 list-disc list-inside">
<li>Le site ne collecte que les données strictement nécessaires au traitement de votre demande (Audit, Contact).</li>
<li>
Pour en savoir plus sur la gestion de vos données, vos droits (accès, rectification) et l&apos;utilisation des cookies,
veuillez consulter notre <Link href="/confidentialite" className="text-primary hover:underline">Politique de Confidentialité</Link>.
</li>
</ul>
</section>
{/* Section 6 : Liens hypertextes */}
<section>
<h2 className="text-xl font-semibold text-white mb-3">
<span className="text-primary">6.</span> Liens hypertextes
</h2>
<h2 className="text-xl font-bold text-navy mb-4">4. Propriété intellectuelle</h2>
<p>
Le site <strong>hooklab.eu</strong> peut contenir des liens hypertextes vers d&apos;autres sites (partenaires, outils, informations).
Cependant, Enguerrand OZANO n&apos;a pas la possibilité de vérifier le contenu des sites ainsi visités et décline donc toute responsabilité
quant aux risques éventuels de contenus illicites.
L&apos;ensemble du contenu de ce site (textes, visuels, structure) est la propriété d&apos;OBC Maçonnerie. Toute reproduction est interdite sans autorisation préalable écrite de Benoît COLIN.
</p>
</section>
{/* Section 7 : Droit Applicable */}
<section>
<h2 className="text-xl font-semibold text-white mb-3">
<span className="text-primary">7.</span> Droit applicable et Juridiction
</h2>
<h2 className="text-xl font-bold text-navy mb-4">5. Données personnelles</h2>
<p>
Tout litige en relation avec l&apos;utilisation du site <strong>hooklab.eu</strong> est soumis au droit français.
En cas de litige entre professionnels (B2B), et à défaut d&apos;accord amiable, il est fait attribution exclusive de juridiction
aux tribunaux compétents de <strong>Douai</strong>.
Les données collectées via le formulaire de contact sont utilisées uniquement pour répondre à vos demandes de devis. Conformément au RGPD, vous disposez d&apos;un droit d&apos;accès, de rectification et de suppression.{" "}
<Link href="/confidentialite" className="text-orange hover:underline">
Voir notre politique de confidentialité
</Link>.
</p>
</section>
<p className="text-white/40 pt-8 border-t border-white/10 text-xs">
<section>
<h2 className="text-xl font-bold text-navy mb-4">6. Droit applicable</h2>
<p>
Tout litige en relation avec l&apos;utilisation du site est soumis au droit français. Juridiction compétente : Tribunal de Valenciennes.
</p>
</section>
<p className="text-text-muted text-xs pt-4 border-t border-border">
Dernière mise à jour : Février 2026
</p>
</div>
</div>
<Footer />
</main>
);
}

View File

@@ -1,79 +0,0 @@
import Link from "next/link";
import Button from "@/components/ui/Button";
export default function MerciPage() {
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="text-center max-w-lg">
{/* Success icon */}
<div className="w-20 h-20 gradient-bg rounded-full flex items-center justify-center mx-auto mb-8">
<svg
className="w-10 h-10 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-3xl md:text-4xl font-bold tracking-[-0.02em] mb-4">
Candidature <span className="gradient-text">envoyée !</span>
</h1>
<p className="text-white/60 text-lg mb-2">
Merci pour ta candidature. Notre équipe va étudier ton profil
attentivement.
</p>
<p className="text-white/40 mb-8">
Tu recevras une réponse par email sous 24 heures. Pense à vérifier
tes spams !
</p>
{/* Étapes suivantes */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 mb-8 text-left">
<h2 className="text-white font-semibold mb-4">Prochaines étapes</h2>
<div className="space-y-4">
{[
{
step: "1",
title: "Analyse de ton profil",
desc: "Notre équipe évalue ta candidature",
},
{
step: "2",
title: "Email de confirmation",
desc: "Tu reçois un email avec le lien de paiement",
},
{
step: "3",
title: "Accès au programme",
desc: "Tu commences ta formation immédiatement",
},
].map((item) => (
<div key={item.step} className="flex items-start gap-3">
<div className="w-7 h-7 gradient-bg rounded-lg flex items-center justify-center shrink-0 text-xs font-bold text-white">
{item.step}
</div>
<div>
<p className="text-white text-sm font-medium">{item.title}</p>
<p className="text-white/40 text-xs">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
<Link href="/">
<Button variant="secondary">Retour à l&apos;accueil</Button>
</Link>
</div>
</main>
);
}

View File

@@ -1,47 +1,539 @@
import type { Metadata } from "next";
import Link from "next/link";
import Navbar from "@/components/marketing/Navbar";
import Hero from "@/components/marketing/Hero";
import Problematique from "@/components/marketing/Problematique";
import Process from "@/components/marketing/Process";
import DemosLive from "@/components/marketing/DemosLive";
import AboutMe from "@/components/marketing/AboutMe";
import FAQ from "@/components/marketing/FAQ";
import Contact from "@/components/marketing/Contact";
import Footer from "@/components/marketing/Footer";
import { getSiteImages } from "@/lib/site-images";
import ScrollReveal from "@/components/animations/ScrollReveal";
import ContactForm from "@/components/marketing/ContactForm";
// Revalider les images toutes les 60 secondes
export const revalidate = 60;
export const metadata: Metadata = {
title: "OBC Maçonnerie | Constructeur & Maçon à Orchies (Nord 59)",
description:
"Benoît Colin, maçon expert à Mouchin. Construction de maison, rénovation, assainissement et gros œuvre dans un rayon de 30km autour d'Orchies. Devis gratuit.",
alternates: {
canonical: "https://obc-maconnerie.fr",
},
};
export default async function LandingPage() {
const images = await getSiteImages();
const services = [
{
icon: "🏠",
title: "Construction de maison",
desc: "Fondations, ossature bois, gros œuvre — on bâtit votre projet de A à Z avec vous.",
href: "/construction-maison",
},
{
icon: "🔨",
title: "Rénovation",
desc: "Maison ou appartement, on s'adapte à votre projet et vos envies.",
href: "/renovation",
},
{
icon: "💧",
title: "Assainissement",
desc: "Mise aux normes et création de systèmes d'assainissement fiables.",
href: "/assainissement",
},
{
icon: "🚧",
title: "Création d'accès",
desc: "Voiries, entrées, chemins — on crée vos accès sur mesure.",
href: "/creation-acces",
},
{
icon: "🏗️",
title: "Démolition",
desc: "Démolition totale ou partielle, avec toutes les garanties de sécurité.",
href: "/demolition",
},
{
icon: "🤝",
title: "Conseil & Accompagnement",
desc: "Benoît vous éclaire dans vos choix : matériaux, plans, adaptations — on réfléchit ensemble.",
href: "/contact",
},
];
const pilliers = [
{
icon: "📍",
title: "Proche de vous",
desc: "Disponible, à l'écoute, Benoît intervient dans votre secteur local et prend le temps de comprendre votre projet.",
},
{
icon: "💡",
title: "Conseil expert",
desc: "Il guide vos choix de matériaux et adapte les plans d'architecte pour un résultat qui vous ressemble.",
},
{
icon: "🛡️",
title: "Acteur de confiance",
desc: "Transparent à chaque étape, Benoît rassure, explique et vous tient informé de l'avancement du chantier.",
},
{
icon: "❤️",
title: "Passionné du métier",
desc: "\"On ne fait jamais deux fois la même maison.\" Benoît aime être au cœur de chaque projet, de A à Z.",
},
];
const partenaires = [
{ label: "Électricité", icon: "⚡" },
{ label: "Plomberie", icon: "🔧" },
{ label: "Charpente", icon: "🪵" },
{ label: "Couverture", icon: "🏚️" },
{ label: "Isolation", icon: "🧱" },
{ label: "Menuiserie", icon: "🚪" },
{ label: "Carrelage", icon: "🔳" },
{ label: "Peinture", icon: "🎨" },
];
const villes = [
"Orchies",
"Mouchin",
"Flines-lès-Raches",
"Château-l'Abbaye",
"Mérignies",
"Douai",
"Valenciennes",
"Saint-Amand-les-Eaux",
];
const realisations = [
{
title: "Construction d'une maison individuelle",
desc: "Fondations, gros œuvre et ossature — livraison clé en main à Orchies.",
cat: "Construction neuve",
color: "bg-navy",
},
{
title: "Rénovation complète d'une maison de ville",
desc: "Restructuration intérieure, cloisons, escalier réhabilité à Douai.",
cat: "Rénovation",
color: "bg-stone",
},
{
title: "Création d'un accès et chemin d'entrée",
desc: "Voirie et entrée béton imprimé, aménagement paysager à Mérignies.",
cat: "Création d'accès",
color: "bg-orange",
},
];
const temoignages = [
{
nom: "Christophe & Marie L.",
lieu: "Orchies",
projet: "Construction maison",
texte:
"Benoît nous a accompagnés de A à Z dans la construction de notre maison. Il a su adapter le plan d'architecte à nos envies tout en respectant notre budget. Disponible, professionnel, et vraiment à l'écoute. On recommande les yeux fermés.",
note: 5,
},
{
nom: "Sophie D.",
lieu: "Douai",
projet: "Rénovation",
texte:
"On lui a confié la rénovation complète de notre maison de 1970. Benoît a pris le temps de tout nous expliquer, a proposé des solutions auxquelles on n'avait pas pensé, et le résultat est magnifique. Un vrai professionnel.",
note: 5,
},
{
nom: "Famille Moreau",
lieu: "Saint-Amand-les-Eaux",
projet: "Assainissement",
texte:
"Mise aux normes de notre système d'assainissement réalisée dans les délais et en toute transparence. Benoît nous a expliqué chaque étape. Très sérieux et propre dans son travail.",
note: 5,
},
];
const faqs = [
{
q: "Dans quelle zone intervenez-vous ?",
a: "OBC Maçonnerie intervient dans un rayon de 20 à 30 km autour de Mouchin (59310) : Orchies, Flines-lès-Raches, Château-l'Abbaye, Mérignies, Douai, Valenciennes, Saint-Amand-les-Eaux et les communes alentour.",
},
{
q: "Faites-vous des devis gratuits ?",
a: "Oui, absolument. Le devis est gratuit et sans engagement. Contactez Benoît par téléphone ou via le formulaire, il se déplace pour évaluer votre projet.",
},
{
q: "Pouvez-vous adapter un plan d'architecte ?",
a: "Oui, c'est même l'une de nos spécialités. Benoît collabore directement avec vous pour adapter les plans à vos envies, votre budget et les contraintes du terrain.",
},
{
q: "Combien de temps dure une construction de maison ?",
a: "Une construction neuve prend en moyenne 10 à 18 mois selon la complexité du projet, les conditions météo et les délais de livraison des matériaux. Benoît vous donne un calendrier dès la signature.",
},
{
q: "Travaillez-vous avec d'autres artisans ?",
a: "Oui. OBC Maçonnerie dispose d'un réseau de partenaires de confiance pour tous les corps de métier : électricité, plomberie, charpente, isolation, menuiserie, carrelage, peinture et couverture. Vous avez un seul interlocuteur pour coordonner l'ensemble.",
},
];
function StarRating({ note }: { note: number }) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${i < note ? "text-orange" : "text-border"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
);
}
export default function HomePage() {
return (
<main id="main-content" className="min-h-screen">
{/* Navigation */}
<Navbar />
{/* Hero - Le Choc Visuel */}
<Hero images={images} />
{/* ── SECTION 1 — HERO ── */}
<section className="relative bg-navy overflow-hidden pt-20 pb-24 md:pt-28 md:pb-32">
{/* Background texture */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, #fff 0, #fff 1px, transparent 0, transparent 50%)",
backgroundSize: "20px 20px",
}}
/>
</div>
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{/* Badge */}
<div className="inline-flex items-center gap-2 bg-white/10 border border-white/20 rounded-full px-4 py-1.5 mb-6 animate-hero-text-1">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
<span className="text-white/80 text-sm">
Disponible, à l&apos;écoute Benoît vous accompagne de la première pierre à la remise des clés
</span>
</div>
{/* La Problématique - L'Identification */}
<Problematique />
<h1 className="text-4xl md:text-6xl font-bold text-white leading-tight mb-6 animate-hero-text-2">
Maçon &amp; Constructeur<br />
<span className="text-orange">dans le Nord</span>
</h1>
{/* Le Triptyque HookLab - Les 3 Piliers */}
<Process images={images} />
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto mb-8 animate-hero-text-3">
Construction de maison, rénovation, assainissement et gros œuvre
expertise autour d&apos;Orchies, Douai et Valenciennes.
</p>
{/* Démos Live - 3 Dossiers de Confiance */}
<DemosLive images={images} />
<div className="flex flex-col sm:flex-row gap-4 justify-center animate-hero-text-3">
<Link
href="/contact"
className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-8 py-4 rounded-xl text-base transition-colors pulse-glow"
>
Demander un devis gratuit
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
<Link
href="/realisations"
className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white font-semibold px-8 py-4 rounded-xl text-base transition-colors border border-white/20"
>
Voir nos réalisations
</Link>
</div>
{/* Qui suis-je - Ancrage Local */}
<AboutMe images={images} />
{/* Stats */}
<div className="mt-14 grid grid-cols-3 gap-6 max-w-lg mx-auto border-t border-white/10 pt-10">
{[
{ val: "15+", label: "ans d'expérience" },
{ val: "200+", label: "chantiers réalisés" },
{ val: "30km", label: "de rayon d'action" },
].map((s) => (
<div key={s.label} className="text-center">
<div className="text-2xl md:text-3xl font-bold text-orange">{s.val}</div>
<div className="text-white/50 text-xs mt-1">{s.label}</div>
</div>
))}
</div>
</div>
</section>
{/* FAQ - Objections */}
<FAQ />
{/* ── SECTION 2 — NOS SERVICES ── */}
<section className="py-20 md:py-24 bg-bg">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollReveal direction="up">
<div className="text-center mb-12">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Ce que nous faisons</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2">Nos services de maçonnerie</h2>
<p className="text-text-light mt-3 max-w-xl mx-auto">
De la construction neuve à la rénovation, Benoît Colin et son équipe prennent en charge tous vos travaux de gros œuvre dans le Nord.
</p>
</div>
</ScrollReveal>
{/* Contact / Audit CTA */}
<Contact />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((s, i) => (
<ScrollReveal key={s.title} direction="up" delay={i * 80}>
<Link
href={s.href}
className="group block bg-bg-white border border-border rounded-2xl p-6 hover:border-orange hover:shadow-lg transition-all duration-300 card-hover"
>
<div className="text-3xl mb-4">{s.icon}</div>
<h3 className="text-navy font-bold text-lg mb-2 group-hover:text-orange transition-colors">
{s.title}
</h3>
<p className="text-text-light text-sm leading-relaxed">{s.desc}</p>
<div className="mt-4 flex items-center gap-1 text-orange text-sm font-semibold">
En savoir plus
<svg className="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</Link>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ── SECTION 3 — POURQUOI CHOISIR OBC ── */}
<section className="py-20 md:py-24 bg-stone-bg">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollReveal direction="up">
<div className="text-center mb-12">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Notre différence</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2">Pourquoi choisir OBC Maçonnerie ?</h2>
</div>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{pilliers.map((p, i) => (
<ScrollReveal key={p.title} direction="up" delay={i * 100}>
<div className="bg-bg-white rounded-2xl p-6 border border-border text-center h-full">
<div className="text-4xl mb-4">{p.icon}</div>
<h3 className="text-navy font-bold text-lg mb-3">{p.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{p.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ── SECTION 4 — RÉSEAU PARTENAIRES ── */}
<section className="py-20 md:py-24 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollReveal direction="up">
<div className="text-center mb-12">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Un réseau solide</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2">
Seul on va vite, ensemble on va plus loin.
</h2>
<p className="text-text-light mt-4 max-w-xl mx-auto">
Grâce à notre réseau de partenaires de confiance, nous coordonnons l&apos;ensemble des corps de métier pour que votre maison prenne forme de A à Z.
</p>
</div>
</ScrollReveal>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{partenaires.map((p, i) => (
<ScrollReveal key={p.label} direction="up" delay={i * 60}>
<div className="bg-bg-white border border-border rounded-xl p-4 text-center hover:border-orange hover:shadow-md transition-all">
<div className="text-2xl mb-2">{p.icon}</div>
<span className="text-navy font-semibold text-sm">{p.label}</span>
</div>
</ScrollReveal>
))}
</div>
<ScrollReveal direction="up" delay={200}>
<div className="mt-10 bg-navy rounded-2xl p-6 md:p-8 text-center">
<p className="text-white text-base md:text-lg font-medium">
Un seul interlocuteur pour coordonner l&apos;ensemble de votre projet de la démolition à la remise des clés.
</p>
<Link
href="/partenaires"
className="inline-flex items-center gap-2 mt-4 text-orange-light hover:text-white font-semibold transition-colors"
>
Découvrir notre réseau
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</ScrollReveal>
</div>
</section>
{/* ── SECTION 5 — ZONE D'INTERVENTION ── */}
<section className="py-20 md:py-24 bg-stone-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<ScrollReveal direction="up">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Secteur d&apos;activité</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2 mb-4">
Nous intervenons dans toute la région
</h2>
<p className="text-text-light max-w-xl mx-auto mb-10">
OBC Maçonnerie intervient dans un rayon de 20 à 30 km autour de Mouchin (Nord 59).
</p>
</ScrollReveal>
<div className="flex flex-wrap justify-center gap-3 mb-8">
{villes.map((v, i) => (
<ScrollReveal key={v} direction="up" delay={i * 50}>
<span className="inline-flex items-center gap-1.5 bg-bg-white border border-border text-navy font-medium text-sm px-4 py-2 rounded-full hover:border-orange hover:shadow-sm transition-all">
<span className="text-orange">📍</span>
{v}
</span>
</ScrollReveal>
))}
</div>
<ScrollReveal direction="up" delay={100}>
<p className="text-text-light text-sm italic">
Et dans toutes les communes à 20-30 km autour de Mouchin contactez-nous pour vérifier votre zone.
</p>
<Link
href="/contact"
className="inline-flex items-center gap-2 mt-6 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
Demander un devis dans ma commune
</Link>
</ScrollReveal>
</div>
</section>
{/* ── SECTION 6 — RÉALISATIONS ── */}
<section className="py-20 md:py-24 bg-bg">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollReveal direction="up">
<div className="text-center mb-12">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Nos chantiers</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2">Aperçu de nos réalisations</h2>
</div>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{realisations.map((r, i) => (
<ScrollReveal key={r.title} direction="up" delay={i * 100}>
<div className="bg-bg-white rounded-2xl overflow-hidden border border-border hover:shadow-lg transition-all group card-hover">
<div className={`${r.color} h-44 flex items-center justify-center`}>
<span className="text-white/20 text-8xl font-bold">{i + 1}</span>
</div>
<div className="p-5">
<span className="inline-block bg-bg-muted text-text-light text-xs font-semibold px-2 py-1 rounded-full mb-2">
{r.cat}
</span>
<h3 className="text-navy font-bold text-base mb-1 group-hover:text-orange transition-colors">
{r.title}
</h3>
<p className="text-text-light text-sm">{r.desc}</p>
</div>
</div>
</ScrollReveal>
))}
</div>
<ScrollReveal direction="up" delay={150}>
<div className="text-center mt-8">
<Link
href="/realisations"
className="inline-flex items-center gap-2 border-2 border-navy text-navy hover:bg-navy hover:text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
Voir toutes nos réalisations
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</ScrollReveal>
</div>
</section>
{/* ── SECTION 7 — TÉMOIGNAGES ── */}
<section className="py-20 md:py-24 bg-navy">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollReveal direction="up">
<div className="text-center mb-12">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Ce qu&apos;ils en disent</span>
<h2 className="text-3xl md:text-4xl font-bold text-white mt-2">Témoignages clients</h2>
</div>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{temoignages.map((t, i) => (
<ScrollReveal key={t.nom} direction="up" delay={i * 100}>
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 h-full flex flex-col">
<StarRating note={t.note} />
<p className="text-white/80 text-sm leading-relaxed mt-4 flex-1 italic">
&ldquo;{t.texte}&rdquo;
</p>
<div className="mt-5 pt-4 border-t border-white/10">
<p className="text-white font-semibold text-sm">{t.nom}</p>
<p className="text-white/40 text-xs">{t.lieu} {t.projet}</p>
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ── SECTION 8 — FAQ ── */}
<section className="py-20 md:py-24 bg-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<div className="text-center mb-12">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Questions fréquentes</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2">FAQ</h2>
</div>
</ScrollReveal>
<div className="space-y-4">
{faqs.map((f, i) => (
<ScrollReveal key={f.q} direction="up" delay={i * 60}>
<details className="group bg-bg-white border border-border rounded-2xl overflow-hidden">
<summary className="flex items-center justify-between px-6 py-4 cursor-pointer font-semibold text-navy hover:text-orange transition-colors list-none">
{f.q}
<svg
className="w-5 h-5 text-text-muted group-open:rotate-180 transition-transform shrink-0 ml-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="px-6 pb-5 text-text-light text-sm leading-relaxed border-t border-border-light pt-4">
{f.a}
</div>
</details>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ── SECTION 9 — FORMULAIRE DE CONTACT ── */}
<section className="py-20 md:py-24 bg-stone-bg" id="contact">
<div className="max-w-2xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<div className="text-center mb-10">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Devis gratuit</span>
<h2 className="text-3xl md:text-4xl font-bold text-navy mt-2">Parlez-nous de votre projet</h2>
<p className="text-text-light mt-3">
Réponse sous 24h ou appelez directement Benoît au{" "}
<a href="tel:0674453089" className="text-orange font-bold hover:underline">
06 74 45 30 89
</a>
</p>
</div>
</ScrollReveal>
<ScrollReveal direction="up" delay={100}>
<ContactForm />
</ScrollReveal>
</div>
</section>
{/* Footer SEO */}
<Footer />
</main>
);

125
app/partenaires/page.tsx Normal file
View File

@@ -0,0 +1,125 @@
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: "Notre Réseau de Partenaires | OBC Maçonnerie Nord",
description:
"OBC Maçonnerie coordonne un réseau d'artisans partenaires de confiance pour livrer votre maison de A à Z : électricité, plomberie, charpente, isolation, menuiserie, carrelage, peinture.",
alternates: { canonical: "https://obc-maconnerie.fr/partenaires" },
};
const partenaires = [
{
icon: "⚡",
metier: "Électricité",
desc: "Installation électrique aux normes NF C 15-100, tableau de distribution, prises, éclairage.",
},
{
icon: "🔧",
metier: "Plomberie",
desc: "Plomberie sanitaire, chauffage central, installation de salles de bains et cuisines.",
},
{
icon: "🪵",
metier: "Charpente",
desc: "Charpente traditionnelle ou industrielle, structure bois pour combles aménageables ou non.",
},
{
icon: "🏚️",
metier: "Couverture",
desc: "Pose de toiture, tuiles, ardoises, zinc — étanchéité et finitions soignées.",
},
{
icon: "🧱",
metier: "Isolation",
desc: "Isolation thermique et phonique par l'intérieur ou l'extérieur, combles, planchers.",
},
{
icon: "🚪",
metier: "Menuiserie",
desc: "Fenêtres, portes, vérandas, volets — menuiserie bois, PVC ou aluminium.",
},
{
icon: "🔳",
metier: "Carrelage & Revêtements",
desc: "Pose de carrelage, parquet, faïence — pour sols et murs, intérieur et extérieur.",
},
{
icon: "🎨",
metier: "Peinture",
desc: "Peinture intérieure et extérieure, enduits décoratifs, ravalement de façade.",
},
];
export default function PartenairesPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<ScrollReveal direction="up">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Notre force collective</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">
Notre réseau de partenaires
</h1>
<p className="text-white/70 text-lg max-w-2xl mx-auto">
Seul on va vite, ensemble on va plus loin. Grâce à notre réseau d&apos;artisans de confiance, OBC Maçonnerie coordonne l&apos;ensemble des corps de métier pour que votre maison prenne forme de A à Z.
</p>
</ScrollReveal>
</div>
</section>
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<div className="bg-stone-bg border border-border rounded-2xl p-6 md:p-8 mb-12 text-center">
<h2 className="text-xl md:text-2xl font-bold text-navy mb-3">
Un seul interlocuteur pour tout votre projet
</h2>
<p className="text-text-light text-sm leading-relaxed max-w-xl mx-auto">
Benoît Colin sélectionne et coordonne des artisans partenaires avec lesquels il travaille depuis des années. Vous n&apos;avez qu&apos;un seul contact lui pour piloter l&apos;intégralité de votre chantier.
</p>
</div>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
{partenaires.map((p, i) => (
<ScrollReveal key={p.metier} direction="up" delay={i * 70}>
<div className="bg-bg-white border border-border rounded-2xl p-5 text-center h-full hover:border-orange hover:shadow-md transition-all">
<div className="text-4xl mb-3">{p.icon}</div>
<h3 className="text-navy font-bold text-base mb-2">{p.metier}</h3>
<p className="text-text-light text-xs leading-relaxed">{p.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
<section className="py-14 bg-navy">
<div className="max-w-3xl mx-auto px-4 sm:px-6 text-center">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
Un projet de A à Z
</h2>
<p className="text-white/70 mb-8 max-w-xl mx-auto">
Que vous construisiez une maison neuve ou rénoviez l&apos;existant, OBC Maçonnerie orchestre chaque corps de métier dans le bon ordre, au bon moment.
</p>
<Link
href="/contact"
className="inline-flex items-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-8 py-4 rounded-xl transition-colors"
>
Parler de mon projet à Benoît
</Link>
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
interface Realisation {
titre: string;
type: string;
lieu: string;
saison: string;
image: string;
}
interface PaysagisteClientProps {
realisations?: Realisation[];
whatsapp?: boolean;
}
export default function PaysagisteClient({ realisations, whatsapp }: PaysagisteClientProps) {
if (whatsapp) {
return <WhatsAppButton />;
}
if (realisations) {
return <GalerieFiltrable realisations={realisations} />;
}
return null;
}
function WhatsAppButton() {
return (
<a
href="https://wa.me/33604408157?text=Bonjour%2C%20je%20souhaite%20un%20devis%20pour%20mon%20jardin"
target="_blank"
rel="noopener noreferrer"
className="fixed bottom-6 right-6 z-50 bg-[#25D366] hover:bg-[#1fb855] text-white rounded-full p-4 shadow-lg hover:shadow-xl transition-all group"
aria-label="Contacter sur WhatsApp"
>
<svg className="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
<span className="absolute -top-2 -left-2 bg-white text-gray-800 text-[10px] font-bold px-2 py-0.5 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
Je veux le m\u00eame jardin !
</span>
</a>
);
}
function GalerieFiltrable({ realisations }: { realisations: Realisation[] }) {
const [filter, setFilter] = useState("Tous");
const types = ["Tous", "Terrasses", "Plantations", "All\u00e9es", "Entretien"];
const filtered = filter === "Tous" ? realisations : realisations.filter((r) => r.type === filter);
return (
<>
{/* Filtres */}
<div className="flex flex-wrap justify-center gap-2 mb-8">
{types.map((t) => (
<button
key={t}
onClick={() => setFilter(t)}
className={`px-4 py-2 text-sm font-semibold rounded-full transition-colors cursor-pointer ${
filter === t
? "bg-green-600 text-white"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
{t}
</button>
))}
</div>
{/* Grille */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{filtered.map((r, i) => (
<div key={i} className="bg-[#f0f5ed] border border-gray-100 rounded-2xl overflow-hidden group hover:shadow-lg transition-shadow">
<div className="h-48 relative overflow-hidden">
<img
src={r.image}
alt={r.titre}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
{/* Tag type */}
<span className={`absolute top-3 left-3 text-[10px] font-bold px-2 py-0.5 rounded-full z-10 ${
r.type === "Entretien" ? "bg-amber-100 text-amber-700" : "bg-green-100 text-green-700"
}`}>
{r.type}
</span>
</div>
<div className="p-4">
<h3 className="text-gray-800 font-bold text-sm mb-1">{r.titre}</h3>
<p className="text-gray-400 text-xs flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
{r.lieu}
</p>
</div>
</div>
))}
</div>
</>
);
}

View File

@@ -1,522 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import Button from "@/components/ui/Button";
import PaysagisteClient from "./PaysagisteClient";
import { getSiteImages } from "@/lib/site-images";
export const revalidate = 60;
export const metadata: Metadata = {
title: "Démo Site Paysagiste - Conception & Entretien Espaces Verts",
description:
"Modèle de site HookLab pour paysagistes. Design inspiré des meilleurs sites du secteur : hero immersif, services, valeurs métier, formulaire de contact.",
alternates: {
canonical: "https://hooklab.eu/paysagiste",
},
};
const valeurs = [
{
titre: "Écoute & conseil",
description: "Un accompagnement personnalisé du premier rendez-vous à la réception du chantier.",
icon: "chat",
},
{
titre: "Créativité sur-mesure",
description: "Chaque jardin est unique. Nous concevons des espaces qui vous ressemblent.",
icon: "paint",
},
{
titre: "Plantes & matériaux choisis",
description: "Sélection rigoureuse de végétaux adaptés au climat et de matériaux durables.",
icon: "leaf",
},
{
titre: "Expertise & savoir-faire",
description: "Des années d'expérience au service de vos projets d'aménagement extérieur.",
icon: "star",
},
];
function ValeurIcon({ type }: { type: string }) {
const cls = "w-8 h-8";
switch (type) {
case "chat":
return (
<svg className={cls} fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
);
case "paint":
return (
<svg className={cls} fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" />
</svg>
);
case "leaf":
return (
<svg className={cls} fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64" />
</svg>
);
case "star":
return (
<svg className={cls} fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
);
default:
return null;
}
}
export default async function PaysagisteDemo() {
const images = await getSiteImages();
const realisations = [
{ titre: "Jardin contemporain avec terrasse composite", type: "Terrasses", lieu: "Orchies", saison: "printemps", image: images.paysagiste_galerie_1 },
{ titre: "Aménagement complet piscine + clôture", type: "Terrasses", lieu: "Douai", saison: "printemps", image: images.paysagiste_galerie_2 },
{ titre: "Création massif fleuri 4 saisons", type: "Plantations", lieu: "Valenciennes", saison: "printemps", image: images.paysagiste_galerie_3 },
{ titre: "Haie brise-vue naturelle en bambou", type: "Plantations", lieu: "Arleux", saison: "automne", image: images.paysagiste_galerie_4 },
{ titre: "Allée carrossable en pavés anciens", type: "Allées", lieu: "Saint-Amand", saison: "automne", image: images.paysagiste_galerie_5 },
{ titre: "Jardin japonais zen avec bassin", type: "Plantations", lieu: "Flines-lez-Raches", saison: "printemps", image: images.paysagiste_galerie_6 },
{ titre: "Taille architecturale haies buis", type: "Entretien", lieu: "Denain", saison: "automne", image: images.paysagiste_galerie_7 },
{ titre: "Entretien annuel parc 3000m²", type: "Entretien", lieu: "Douai", saison: "automne", image: images.paysagiste_galerie_8 },
];
return (
<main className="min-h-screen bg-white">
{/* ===== NAV TRANSPARENTE SUR LE HERO ===== */}
<nav className="absolute top-0 left-0 right-0 z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between h-20">
<div className="flex items-center gap-3">
<Link href="/" className="text-white/60 hover:text-white text-sm transition-colors">
&larr; HookLab
</Link>
<span className="text-white/30">|</span>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white/15 backdrop-blur-sm rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64" />
</svg>
</div>
<span className="text-white font-bold text-sm hidden sm:block">
[Votre Entreprise] &mdash; <span className="text-green-400">Paysagiste</span>
</span>
</div>
</div>
<div className="flex items-center gap-4">
<a href="#realisations" className="hidden md:inline text-white/70 hover:text-white text-sm font-medium transition-colors">Réalisations</a>
<a href="#apropos" className="hidden md:inline text-white/70 hover:text-white text-sm font-medium transition-colors">L&rsquo;entreprise</a>
<a
href="#contact"
className="bg-green-600 hover:bg-green-700 text-white font-bold text-sm px-5 py-2.5 rounded-lg transition-colors"
>
Nous contacter
</a>
</div>
</div>
</nav>
{/* ===== HERO PLEIN ÉCRAN AVEC PHOTO ===== */}
<section className="relative h-[85vh] min-h-[600px] flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images.paysagiste_hero}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/40 to-black/70" />
<div className="relative z-10 text-center max-w-4xl mx-auto px-4">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full px-4 py-2 mb-8">
<span className="w-2 h-2 bg-green-400 rounded-full" />
<span className="text-white/90 text-sm font-medium">Conception &middot; Réalisation &middot; Entretien</span>
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-extrabold text-white leading-tight mb-6">
Conception, réalisation et entretien{" "}
<span className="text-green-400">d&rsquo;espaces paysagers</span>
</h1>
<p className="text-white/80 text-lg md:text-xl max-w-2xl mx-auto mb-10">
Votre paysagiste de confiance autour de Douai, Orchies et Valenciennes.
Du jardin d&rsquo;agrément à l&rsquo;espace professionnel.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="#contact"
className="inline-flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 text-white font-bold text-base px-8 py-4 rounded-xl transition-colors"
>
Demander un devis gratuit
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<a
href="#realisations"
className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 backdrop-blur-sm border border-white/30 text-white font-bold text-base px-8 py-4 rounded-xl transition-colors"
>
Voir nos réalisations
</a>
</div>
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce-slow">
<svg className="w-6 h-6 text-white/60" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
</div>
</section>
{/* ===== 2 GRANDES CARDS SERVICES ===== */}
<section className="py-16 md:py-24 bg-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Création */}
<div className="relative group rounded-2xl overflow-hidden h-80 md:h-96">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images.paysagiste_service_creation}
alt="Création d'espaces verts"
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div className="relative h-full flex flex-col justify-end p-8">
<div className="w-14 h-14 bg-green-600 rounded-xl flex items-center justify-center mb-4">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
</div>
<h3 className="text-2xl md:text-3xl font-bold text-white mb-2">
Création d&rsquo;espaces verts
</h3>
<p className="text-white/70 mb-5 text-sm">
Jardins, terrasses, allées, bassins &mdash; nous donnons vie à vos envies.
</p>
<a
href="#realisations"
className="inline-flex items-center gap-2 border-2 border-green-500 text-green-400 hover:bg-green-600 hover:text-white hover:border-green-600 font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors w-fit"
>
En savoir +
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
</div>
{/* Entretien */}
<div className="relative group rounded-2xl overflow-hidden h-80 md:h-96">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images.paysagiste_service_entretien}
alt="Entretien d'espaces verts"
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div className="relative h-full flex flex-col justify-end p-8">
<div className="w-14 h-14 bg-green-600 rounded-xl flex items-center justify-center mb-4">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
</div>
<h3 className="text-2xl md:text-3xl font-bold text-white mb-2">
Entretien d&rsquo;espaces verts
</h3>
<p className="text-white/70 mb-5 text-sm">
Taille, tonte, élagage, débroussaillage &mdash; vos espaces restent impeccables.
</p>
<a
href="#contact"
className="inline-flex items-center gap-2 border-2 border-green-500 text-green-400 hover:bg-green-600 hover:text-white hover:border-green-600 font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors w-fit"
>
En savoir +
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
</div>
</div>
</div>
</section>
{/* ===== DESCRIPTION SERVICES ===== */}
<section className="py-16 md:py-24 bg-[#f7faf5]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<div>
<span className="inline-block w-12 h-1 bg-green-600 rounded-full mb-4" />
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-6">
Un savoir-faire complet pour vos <span className="text-green-600">extérieurs</span>
</h2>
<p className="text-gray-600 mb-6 leading-relaxed">
Que vous souhaitiez transformer votre jardin, créer une terrasse de rêve ou simplement entretenir vos espaces verts, nous vous accompagnons à chaque étape.
</p>
<ul className="space-y-3">
{[
"Conception et aménagement de jardins",
"Création de terrasses, allées et clôtures",
"Plantation de haies, massifs et arbres",
"Entretien régulier et ponctuel",
"Élagage et abattage",
"Engazonnement et arrosage automatique",
].map((item) => (
<li key={item} className="flex items-start gap-3 text-gray-700">
<svg className="w-5 h-5 text-green-600 mt-0.5 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span className="text-sm font-medium">{item}</span>
</li>
))}
</ul>
</div>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images.paysagiste_services_photo}
alt="Jardin contemporain réalisé"
className="rounded-2xl shadow-xl w-full h-80 md:h-[420px] object-cover"
/>
<div className="absolute -bottom-4 -left-4 bg-green-600 text-white font-bold px-6 py-3 rounded-xl shadow-lg text-sm">
+ de 10 ans d&rsquo;expérience
</div>
</div>
</div>
</div>
</section>
{/* ===== RÉALISATIONS FILTRABLES ===== */}
<section id="realisations" className="py-16 md:py-24 bg-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<span className="inline-block w-12 h-1 bg-green-600 rounded-full mb-4" />
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-3">
Nos <span className="text-green-600">réalisations</span>
</h2>
<p className="text-gray-500 max-w-lg mx-auto">
Des créations dans des lieux que vous connaissez. Projetez-vous.
</p>
</div>
<PaysagisteClient realisations={realisations} />
</div>
</section>
{/* ===== QUI SOMMES-NOUS ===== */}
<section id="apropos" className="py-16 md:py-24 bg-[#f7faf5]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<div className="relative order-2 md:order-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images.paysagiste_equipe}
alt="Équipe de paysagistes au travail"
className="rounded-2xl shadow-xl w-full h-80 md:h-[400px] object-cover"
/>
</div>
<div className="order-1 md:order-2">
<span className="inline-block w-12 h-1 bg-green-600 rounded-full mb-4" />
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-6">
Qui <span className="text-green-600">sommes-nous ?</span>
</h2>
<p className="text-gray-600 mb-4 leading-relaxed">
Entreprise de paysagisme implantée dans le <strong>Nord (59)</strong>, nous intervenons autour de <strong>Douai, Orchies, Valenciennes</strong> et dans tout le Douaisis.
</p>
<p className="text-gray-600 mb-6 leading-relaxed">
Notre équipe passionnée transforme vos extérieurs en véritables espaces de vie. De la <strong>conception</strong> à la <strong>réalisation</strong>, en passant par l&rsquo;<strong>entretien régulier</strong>, nous mettons notre savoir-faire à votre service.
</p>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-white rounded-xl shadow-sm">
<div className="text-2xl font-extrabold text-green-600">150+</div>
<div className="text-xs text-gray-500 font-medium mt-1">Jardins créés</div>
</div>
<div className="text-center p-4 bg-white rounded-xl shadow-sm">
<div className="text-2xl font-extrabold text-green-600">10+</div>
<div className="text-xs text-gray-500 font-medium mt-1">Ans d&rsquo;exp.</div>
</div>
<div className="text-center p-4 bg-white rounded-xl shadow-sm">
<div className="text-2xl font-extrabold text-green-600">100%</div>
<div className="text-xs text-gray-500 font-medium mt-1">Satisfaits</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* ===== NOS VALEURS - FOND VERT FORÊT ===== */}
<section className="py-16 md:py-24 bg-[#1a3c1a] relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.03]">
<div className="absolute top-10 left-10 w-40 h-40 border border-white rounded-full" />
<div className="absolute bottom-10 right-10 w-60 h-60 border border-white rounded-full" />
<div className="absolute top-1/2 left-1/3 w-20 h-20 border border-white rounded-full" />
</div>
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-14">
<span className="inline-block w-12 h-1 bg-green-400 rounded-full mb-4" />
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3">
Nos valeurs
</h2>
<p className="text-white/60 max-w-md mx-auto">
Les principes qui guident chaque projet que nous réalisons.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{valeurs.map((v) => (
<div
key={v.titre}
className="bg-white rounded-t-[80px] rounded-b-2xl p-8 pt-10 text-center group hover:-translate-y-1 transition-transform duration-300"
>
<div className="w-16 h-16 bg-green-50 rounded-full flex items-center justify-center mx-auto mb-5 text-green-600 group-hover:bg-green-600 group-hover:text-white transition-colors duration-300">
<ValeurIcon type={v.icon} />
</div>
<h3 className="text-gray-900 font-bold text-base mb-2">{v.titre}</h3>
<p className="text-gray-500 text-sm leading-relaxed">{v.description}</p>
</div>
))}
</div>
</div>
</section>
{/* ===== CTA + FORMULAIRE CONTACT ===== */}
<section id="contact" className="relative py-20 md:py-32 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images.paysagiste_cta}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-[#1a3c1a]/85" />
<div className="relative max-w-3xl mx-auto px-4 text-center">
<span className="inline-block w-12 h-1 bg-green-400 rounded-full mb-6" />
<h2 className="text-3xl md:text-4xl font-extrabold text-white mb-4">
Un projet d&rsquo;aménagement ?
</h2>
<p className="text-white/70 text-lg mb-10 max-w-xl mx-auto">
Parlez-nous de votre projet. Nous vous recontactons sous 24h pour un rendez-vous et un devis gratuit, sans engagement.
</p>
<div className="bg-white rounded-2xl p-6 sm:p-8 text-left max-w-lg mx-auto">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Type de projet</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 text-sm focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none">
<option>Création de jardin complet</option>
<option>Terrasse / Aménagement</option>
<option>Plantation / Massifs</option>
<option>Entretien régulier</option>
<option>Taille / Élagage</option>
<option>Clôture / Brise-vue</option>
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Votre nom</label>
<input type="text" placeholder="Jean Dupont" className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 text-sm placeholder:text-gray-400 focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Téléphone</label>
<input type="tel" placeholder="06 12 34 56 78" className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 text-sm placeholder:text-gray-400 focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Décrivez votre projet</label>
<textarea rows={3} placeholder="Surface, style souhaité, budget approximatif..." className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 text-sm placeholder:text-gray-400 focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none resize-none" />
</div>
<button className="w-full bg-green-600 hover:bg-green-700 text-white font-bold text-base px-6 py-3.5 rounded-xl transition-colors cursor-pointer">
Envoyer ma demande
</button>
<p className="text-xs text-gray-400 text-center">Réponse garantie sous 24h &middot; Devis 100% gratuit</p>
</div>
</div>
</div>
</section>
{/* ===== FOOTER VERT FORÊT ===== */}
<footer className="bg-[#1a3c1a] text-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
<div>
<div className="flex items-center gap-2 mb-4">
<div className="w-9 h-9 bg-green-600 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64" />
</svg>
</div>
<span className="font-bold text-lg">[Votre Entreprise]</span>
</div>
<p className="text-white/60 text-sm leading-relaxed">
Paysagiste dans le Nord (59). Conception, création et entretien d&rsquo;espaces verts autour de Douai.
</p>
</div>
<div>
<h4 className="font-bold text-sm uppercase tracking-wider mb-4 text-green-400">Nos prestations</h4>
<ul className="space-y-2 text-white/60 text-sm">
<li>Création de jardins</li>
<li>Aménagement de terrasses</li>
<li>Plantation & engazonnement</li>
<li>Entretien d&rsquo;espaces verts</li>
<li>Élagage & abattage</li>
</ul>
</div>
<div>
<h4 className="font-bold text-sm uppercase tracking-wider mb-4 text-green-400">Contact</h4>
<ul className="space-y-3 text-white/60 text-sm">
<li className="flex items-center gap-2">
<svg className="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
06 04 40 81 57
</li>
<li className="flex items-center gap-2">
<svg className="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
Douai, Orchies, Valenciennes
</li>
<li className="flex items-center gap-2">
<svg className="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Lun-Ven : 8h-18h
</li>
</ul>
</div>
</div>
<div className="border-t border-white/10 mt-10 pt-6 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-white/40 text-xs">
&copy; 2025 [Votre Entreprise] &mdash; Tous droits réservés
</p>
<p className="text-white/30 text-xs">
Démo réalisée par{" "}
<Link href="/" className="text-green-400 hover:text-green-300 transition-colors">
HookLab
</Link>
</p>
</div>
</div>
</footer>
{/* WhatsApp flottant */}
<PaysagisteClient whatsapp />
{/* CTA HookLab sticky discret */}
<div className="fixed bottom-6 left-6 z-40">
<Link
href="/#contact"
className="bg-gray-900/90 hover:bg-gray-900 backdrop-blur-sm text-white text-xs font-semibold px-4 py-2 rounded-full shadow-lg transition-colors flex items-center gap-2"
>
<span className="w-2 h-2 bg-orange rounded-full" />
Démo HookLab &mdash; Ce site peut être le vôtre
</Link>
</div>
</main>
);
}

View File

@@ -1,189 +0,0 @@
"use client";
import { useState } from "react";
import Button from "@/components/ui/Button";
interface PlombierClientProps {
type: "diagnostic" | "sticky";
}
export default function PlombierClient({ type }: PlombierClientProps) {
if (type === "sticky") return <StickyCall />;
if (type === "diagnostic") return <Diagnostic />;
return null;
}
function StickyCall() {
return (
<div className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[#0a1628] border-t border-white/10 p-3 safe-area-bottom">
<a
href="tel:+33604408157"
className="flex items-center justify-center gap-3 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-bold text-base py-3.5 rounded-xl w-full transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Appeler maintenant &mdash; 06 04 40 81 57
</a>
<p className="text-white/40 text-[10px] text-center mt-1">Devis gratuit &middot; Pas de surprise</p>
</div>
);
}
function Diagnostic() {
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<string[]>([]);
const questions = [
{
question: "Quel est le probl\u00e8me ?",
options: [
{ icon: "\ud83d\udca7", label: "Fuite d\u2019eau" },
{ icon: "\ud83d\udebd", label: "Canalisation bouch\u00e9e" },
{ icon: "\ud83d\udd25", label: "Panne chauffe-eau" },
{ icon: "\ud83d\udee0\ufe0f", label: "Autre probl\u00e8me" },
],
},
{
question: "Quel niveau d\u2019urgence ?",
options: [
{ icon: "\ud83d\udea8", label: "Urgent (fuite active)" },
{ icon: "\u23f0", label: "Sous 48h" },
{ icon: "\ud83d\udcc5", label: "Travaux planifi\u00e9s" },
],
},
{
question: "O\u00f9 \u00eates-vous situ\u00e9 ?",
options: [
{ icon: "\ud83d\udccd", label: "Douai / Environs" },
{ icon: "\ud83d\udccd", label: "Orchies / Environs" },
{ icon: "\ud83d\udccd", label: "Valenciennes / Environs" },
{ icon: "\ud83d\udccd", label: "Autre secteur" },
],
},
];
if (step >= questions.length) {
const isUrgent = answers[1]?.includes("Urgent");
const isOutOfZone = answers[2]?.includes("Autre");
if (isOutOfZone) {
return (
<div className="bg-white rounded-2xl p-6 sm:p-8 text-center">
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-3xl">\ud83d\udccd</span>
</div>
<h3 className="text-gray-900 font-bold text-xl mb-2">Vous \u00eates hors de notre zone principale</h3>
<p className="text-gray-500 text-sm mb-6">
Notre rayon d&rsquo;action est Douai + 25km. Appelez-nous quand m\u00eame,
on trouvera peut-\u00eatre une solution !
</p>
<a
href="tel:+33604408157"
className="inline-flex items-center justify-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-bold px-6 py-3 rounded-xl transition-colors"
>
Appeler quand m\u00eame
</a>
<button onClick={() => { setStep(0); setAnswers([]); }} className="block mx-auto mt-4 text-gray-400 hover:text-gray-600 text-sm underline cursor-pointer">
Recommencer le diagnostic
</button>
</div>
);
}
if (isUrgent) {
return (
<div className="bg-white rounded-2xl p-6 sm:p-8 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-pulse">
<span className="text-3xl">\ud83d\udea8</span>
</div>
<h3 className="text-gray-900 font-bold text-xl mb-2">Urgence d\u00e9tect\u00e9e !</h3>
<p className="text-gray-500 text-sm mb-2">
<strong>{answers[0]}</strong> &mdash; {answers[2]}
</p>
<p className="text-gray-400 text-sm mb-6">Pour une intervention imm\u00e9diate, appelez directement :</p>
<a
href="tel:+33604408157"
className="inline-flex items-center justify-center gap-3 bg-red-600 hover:bg-red-700 text-white font-bold text-lg px-8 py-4 rounded-xl transition-colors w-full"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
APPELER MAINTENANT
</a>
<p className="text-gray-400 text-xs mt-3">Disponible 7j/7 &middot; Devis gratuit</p>
</div>
);
}
// Non urgent
return (
<div className="bg-white rounded-2xl p-6 sm:p-8">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-3xl">\u2705</span>
</div>
<h3 className="text-gray-900 font-bold text-xl mb-1">Diagnostic re\u00e7u !</h3>
<p className="text-gray-500 text-sm">
<strong>{answers[0]}</strong> &mdash; {answers[1]} &mdash; {answers[2]}
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Votre t\u00e9l\u00e9phone</label>
<input type="tel" placeholder="06 12 34 56 78" className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Pr\u00e9cisions (facultatif)</label>
<textarea rows={2} placeholder="D\u00e9crivez votre probl\u00e8me en quelques mots..." className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none resize-none" />
</div>
<Button size="lg" className="w-full bg-[#3b82f6] hover:bg-[#2563eb] border-[#3b82f6]">
Envoyer &mdash; On vous rappelle sous 24h
</Button>
</div>
<button onClick={() => { setStep(0); setAnswers([]); }} className="block mx-auto mt-4 text-gray-400 hover:text-gray-600 text-sm underline cursor-pointer">
Recommencer
</button>
</div>
);
}
const q = questions[step];
return (
<div className="bg-white rounded-2xl p-6 sm:p-8">
{/* Progress */}
<div className="flex items-center gap-2 mb-6">
{questions.map((_, i) => (
<div key={i} className={`h-1.5 flex-1 rounded-full transition-colors ${i <= step ? "bg-[#3b82f6]" : "bg-gray-200"}`} />
))}
</div>
<h3 className="text-gray-900 font-bold text-lg mb-5">{q.question}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{q.options.map((opt) => (
<button
key={opt.label}
onClick={() => {
setAnswers([...answers, opt.label]);
setStep(step + 1);
}}
className="p-4 rounded-xl border-2 border-gray-200 bg-gray-50 hover:border-[#3b82f6] hover:shadow-md text-left transition-all cursor-pointer"
>
<span className="text-2xl block mb-1">{opt.icon}</span>
<p className="text-gray-900 font-semibold text-sm">{opt.label}</p>
</button>
))}
</div>
{step > 0 && (
<button
onClick={() => { setStep(step - 1); setAnswers(answers.slice(0, -1)); }}
className="mt-4 text-gray-400 hover:text-gray-600 text-sm underline cursor-pointer"
>
&larr; Retour
</button>
)}
</div>
);
}

View File

@@ -1,218 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import Button from "@/components/ui/Button";
import PlombierClient from "./PlombierClient";
export const metadata: Metadata = {
title: "Démo Site Plombier / Électricien - L'Intervention Éclair",
description:
"Modèle de site HookLab pour plombiers, électriciens et serruriers. Bouton d'appel sticky, diagnostic en ligne, zone d'intervention, tarifs clairs.",
alternates: {
canonical: "https://hooklab.eu/plombier",
},
};
const tarifs = [
{ service: "Dépannage fuite", prix: "À partir de 89€", urgence: true },
{ service: "Débouchage canalisation", prix: "À partir de 120€", urgence: true },
{ service: "Remplacement chauffe-eau", prix: "À partir de 350€", urgence: false },
{ service: "Installation sanitaire complète", prix: "Sur devis", urgence: false },
{ service: "Recherche de fuite", prix: "À partir de 150€", urgence: true },
{ service: "Rénovation salle de bain", prix: "Sur devis", urgence: false },
];
const avis = [
{ name: "Laurent P.", ville: "Douai", text: "Fuite à 22h un samedi. Intervention en 45 min. Prix correct, travail pro. Merci !", note: 5 },
{ name: "Marie C.", ville: "Orchies", text: "Chauffe-eau en panne en plein hiver. Remplacé le lendemain matin. Service impeccable.", note: 5 },
{ name: "Jean-Marc B.", ville: "Valenciennes", text: "Canalisation bouchée, devis clair au téléphone, pas de surprise à la facture. Rare !", note: 5 },
];
export default function PlombierDemo() {
return (
<main className="min-h-screen bg-[#0a1628]">
{/* Nav avec avis + tél */}
<nav className="sticky top-0 z-50 bg-[#0a1628] border-b border-white/10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<Link href="/" className="text-white/50 hover:text-white text-sm transition-colors">
&larr; HookLab
</Link>
<span className="text-white/20">|</span>
<span className="text-white font-bold text-sm">
[Votre Entreprise] &mdash; <span className="text-[#3b82f6]">Plombier</span>
</span>
</div>
<div className="flex items-center gap-3">
{/* Avis Google */}
<div className="hidden sm:flex items-center gap-1.5 bg-yellow-500/10 border border-yellow-500/20 rounded-full px-3 py-1">
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-3 h-3 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
))}
</div>
<span className="text-yellow-300 text-xs font-semibold">4.9/5</span>
</div>
<a
href="tel:+33604408157"
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-bold text-sm px-4 py-2 rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
06 04 40 81 57
</a>
</div>
</div>
</nav>
{/* Hero ultra-direct */}
<section className="py-16 md:py-24 bg-[#0a1628] text-center">
<div className="max-w-4xl mx-auto px-4">
<div className="inline-flex items-center gap-2 bg-red-600/20 border border-red-500/30 rounded-full px-4 py-2 mb-6">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
<span className="text-red-400 text-xs font-semibold">Disponible 7j/7 &mdash; Intervention rapide</span>
</div>
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-white leading-tight mb-4">
Votre plombier{" "}
<span className="text-[#facc15]">réactif et transparent.</span>
</h1>
<p className="text-white/50 text-lg max-w-2xl mx-auto mb-4">
Fuite deau, panne de chauffe-eau, canalisation bouchée ?
Intervention rapide avec devis gratuit. Disponible 7j/7 dans le Douaisis.
</p>
<p className="text-white/30 text-sm mb-8">
Dépannage Douai &middot; Orchies &middot; Valenciennes &middot; Denain &middot; Saint-Amand &middot; Arleux
</p>
<a
href="tel:+33604408157"
className="inline-flex items-center gap-3 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-bold text-xl px-10 py-5 rounded-2xl transition-colors shadow-lg shadow-blue-500/20"
>
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Appeler Maintenant
</a>
<p className="text-white/40 text-sm mt-3">Pas de surprise : devis gratuit avant intervention</p>
</div>
</section>
{/* Tarifs clairs */}
<section className="py-16 md:py-24 bg-white">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-3">
Tarifs <span className="text-[#3b82f6]">transparents</span>
</h2>
<p className="text-gray-500">Pas de surprise. Vous savez ce que vous payez avant quon se déplace.</p>
</div>
<div className="space-y-3">
{tarifs.map((t, i) => (
<div key={i} className="flex items-center justify-between bg-gray-50 border border-gray-200 rounded-xl p-5 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-3">
{t.urgence && <span className="w-2 h-2 bg-red-500 rounded-full shrink-0" />}
<span className="text-gray-900 font-semibold text-sm">{t.service}</span>
{t.urgence && <span className="text-xs bg-red-100 text-red-600 font-semibold px-2 py-0.5 rounded-full">Urgence</span>}
</div>
<span className="text-[#3b82f6] font-bold text-sm">{t.prix}</span>
</div>
))}
</div>
</div>
</section>
{/* Diagnostic en ligne */}
<section className="py-16 md:py-24 bg-gray-50">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-3">
Diagnostic <span className="text-[#3b82f6]">en ligne</span>
</h2>
<p className="text-gray-500">3 questions simples. On qualifie la panne avant de décrocher.</p>
</div>
<PlombierClient type="diagnostic" />
</div>
</section>
{/* Avis */}
<section className="py-16 md:py-24 bg-white">
<div className="max-w-4xl mx-auto px-4 text-center">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-10">
Avis <span className="text-[#facc15]">Google</span> vérifiés
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{avis.map((a, i) => (
<div key={i} className="bg-gray-50 border border-gray-200 rounded-xl p-6 text-left">
<div className="flex gap-0.5 mb-3">
{[...Array(5)].map((_, j) => (
<svg key={j} className="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
))}
</div>
<p className="text-gray-600 text-sm leading-relaxed mb-3">{a.text}</p>
<p className="text-gray-900 font-semibold text-sm">{a.name} <span className="text-gray-400 font-normal">{a.ville}</span></p>
</div>
))}
</div>
</div>
</section>
{/* Zone d'intervention avec carte */}
<section className="py-16 md:py-24 bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-3">
Zone <span className="text-[#3b82f6]">dintervention</span>
</h2>
<p className="text-gray-500">Douai + 25km. Dépannage rapide dans tout le secteur.</p>
</div>
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
<div className="relative h-64 sm:h-80">
<iframe
src="https://www.openstreetmap.org/export/embed.html?bbox=2.6%2C50.2%2C3.8%2C50.55&layer=mapnik&marker=50.4267%2C3.2372"
className="absolute inset-0 w-full h-full border-0"
title="Zone d'intervention plombier"
loading="lazy"
/>
</div>
<div className="p-4 border-t border-gray-100">
<div className="flex flex-wrap gap-2 justify-center">
{["Douai", "Orchies", "Valenciennes", "Denain", "Saint-Amand", "Arleux", "Flines-lez-Raches"].map((v) => (
<span key={v} className="bg-blue-50 text-[#3b82f6] text-xs font-semibold px-3 py-1 rounded-full">{v}</span>
))}
</div>
</div>
</div>
<p className="text-gray-400 text-xs text-center mt-4">
Vous êtes hors zone ? Contactez-nous, on trouvera une solution.
</p>
</div>
</section>
{/* Sticky Call Mobile */}
<PlombierClient type="sticky" />
{/* CTA HookLab */}
<section className="py-16 bg-[#3b82f6] text-center">
<div className="max-w-2xl mx-auto px-4">
<p className="text-white/80 text-xs font-semibold uppercase tracking-wider mb-3">Ceci est une démo HookLab</p>
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
Ce site peut être le vôtre demain.
</h2>
<p className="text-white/80 mb-6">
Un site qui rassure, qui qualifie les urgences, et qui vous fait gagner du temps.
Cest ce que je construis pour les plombiers et électriciens du Nord.
</p>
<Link href="/#contact">
<Button size="lg" className="bg-[#0a1628] hover:bg-[#0a1628]/90 border-[#0a1628]">
Demander Mon Audit Gratuit
</Button>
</Link>
</div>
</section>
</main>
);
}

158
app/realisations/page.tsx Normal file
View File

@@ -0,0 +1,158 @@
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: "Nos Réalisations | Chantiers OBC Maçonnerie Nord",
description:
"Découvrez les réalisations d'OBC Maçonnerie : constructions de maisons, rénovations, assainissement et créations d'accès dans le Nord (59). Galerie photos.",
alternates: { canonical: "https://obc-maconnerie.fr/realisations" },
};
const realisations = [
{
categorie: "Construction neuve",
titre: "Maison individuelle à Orchies",
desc: "Construction d'une maison de 130 m² — fondations, gros œuvre, dalle béton et ossature.",
zone: "Orchies (59)",
color: "bg-navy",
},
{
categorie: "Rénovation",
titre: "Rénovation complète à Douai",
desc: "Restructuration intérieure complète d'une maison de ville : abattage de cloisons, création d'un escalier neuf, doublages.",
zone: "Douai (59)",
color: "bg-stone",
},
{
categorie: "Assainissement",
titre: "Mise aux normes à Saint-Amand",
desc: "Remplacement d'une fosse septique vétuste par une micro-station d'épuration conforme aux normes.",
zone: "Saint-Amand-les-Eaux (59)",
color: "bg-navy-light",
},
{
categorie: "Création d'accès",
titre: "Entrée en béton imprimé à Mérignies",
desc: "Création d'une entrée de propriété en béton imprimé effet pavés, avec caniveau de drainage.",
zone: "Mérignies (59)",
color: "bg-orange",
},
{
categorie: "Construction neuve",
titre: "Extension ossature bois à Flines",
desc: "Agrandissement d'une maison existante par extension ossature bois, fondations et dalle.",
zone: "Flines-lès-Raches (59)",
color: "bg-navy",
},
{
categorie: "Démolition",
titre: "Démolition & reconstruction à Valenciennes",
desc: "Démolition d'un bâtiment annexe et curage d'une grange pour préparer une rénovation complète.",
zone: "Valenciennes (59)",
color: "bg-stone",
},
];
const cats = ["Tous", "Construction neuve", "Rénovation", "Assainissement", "Création d'accès", "Démolition"];
export default function RealisationsPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<ScrollReveal direction="up">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Portfolio</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">Nos réalisations</h1>
<p className="text-white/70 text-lg max-w-xl mx-auto">
Chaque chantier est unique. Découvrez quelques-unes de nos réalisations dans le Nord.
</p>
</ScrollReveal>
</div>
</section>
{/* Filtres catégories */}
<section className="py-8 bg-bg border-b border-border">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="flex flex-wrap gap-2 justify-center">
{cats.map((cat) => (
<span
key={cat}
className={`px-4 py-2 rounded-full text-sm font-medium cursor-default ${
cat === "Tous"
? "bg-navy text-white"
: "bg-bg-white border border-border text-text-light"
}`}
>
{cat}
</span>
))}
</div>
</div>
</section>
{/* Galerie */}
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{realisations.map((r, i) => (
<ScrollReveal key={r.titre} direction="up" delay={i * 80}>
<div className="bg-bg-white border border-border rounded-2xl overflow-hidden hover:shadow-lg transition-all group card-hover">
<div className={`${r.color} h-48 flex items-center justify-center relative`}>
<span className="text-white/10 text-8xl font-black">{i + 1}</span>
<div className="absolute top-3 left-3">
<span className="bg-white/20 text-white text-xs font-semibold px-2.5 py-1 rounded-full">
{r.categorie}
</span>
</div>
</div>
<div className="p-5">
<h3 className="text-navy font-bold text-base mb-2 group-hover:text-orange transition-colors">
{r.titre}
</h3>
<p className="text-text-light text-sm leading-relaxed mb-3">{r.desc}</p>
<div className="flex items-center gap-1 text-text-muted text-xs">
<span>📍</span>
<span>{r.zone}</span>
</div>
</div>
</div>
</ScrollReveal>
))}
</div>
<ScrollReveal direction="up" delay={200}>
<div className="mt-14 bg-stone-bg border border-border rounded-2xl p-8 text-center">
<h2 className="text-xl font-bold text-navy mb-2">
Vous avez un projet similaire ?
</h2>
<p className="text-text-light text-sm mb-6">
Benoît se déplace gratuitement pour évaluer votre chantier et vous remettre un devis.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/contact"
className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
Demander un devis gratuit
</Link>
<a
href="tel:0674453089"
className="inline-flex items-center justify-center gap-2 border-2 border-navy text-navy hover:bg-navy hover:text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
06 74 45 30 89
</a>
</div>
</div>
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -1,152 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
export default function RegisterPage() {
const router = useRouter();
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
if (password !== confirmPassword) {
setError("Les mots de passe ne correspondent pas.");
setLoading(false);
return;
}
if (password.length < 8) {
setError("Le mot de passe doit contenir au moins 8 caractères.");
setLoading(false);
return;
}
try {
const supabase = createClient();
const { error: authError } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
},
});
if (authError) {
if (authError.message.includes("already registered")) {
setError("Un compte avec cet email existe déjà.");
} else {
setError(authError.message);
}
return;
}
router.push("/dashboard");
router.refresh();
} catch {
setError("Erreur lors de l'inscription. Veuillez réessayer.");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center px-4 py-12 bg-dark">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<span className="text-white font-bold">H</span>
</div>
<span className="text-2xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<h1 className="text-2xl font-bold text-white mb-2">
Créer ton compte
</h1>
<p className="text-white/60 text-sm">
Inscris-toi pour accéder au programme.
</p>
</div>
{/* Form */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
<form onSubmit={handleRegister} className="space-y-5">
<Input
id="fullName"
label="Nom complet"
placeholder="Jean Dupont"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
<Input
id="email"
label="Email"
type="email"
placeholder="ton@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
id="password"
label="Mot de passe"
type="password"
placeholder="Minimum 8 caractères"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Input
id="confirmPassword"
label="Confirmer le mot de passe"
type="password"
placeholder="Confirme ton mot de passe"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
{error && (
<div className="p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
<Button type="submit" loading={loading} className="w-full">
Créer mon compte
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-white/40 text-sm">
Déjà un compte ?{" "}
<Link
href="/login"
className="text-primary hover:text-primary-hover transition-colors"
>
Se connecter
</Link>
</p>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Rénovation Maison Douai | Maçon | OBC Maçonnerie",
description:
"Rénovation de maison et appartement à Douai. OBC Maçonnerie, maçon expert en rénovation dans le Nord (59). Devis gratuit.",
keywords: ["rénovation maison Douai", "maçon rénovation Douai", "rénovation appartement Douai", "travaux rénovation Douai"],
alternates: { canonical: "https://obc-maconnerie.fr/renovation-maison-douai" },
};
export default function RenovationMaisonDouaiPage() {
return (
<LocalSEOPage
ville="Douai"
departement="Nord (59)"
servicesPrincipaux={["Rénovation"]}
description="Rénovation de maison à Douai — OBC Maçonnerie, spécialiste de la rénovation dans le Douaisis."
texteIntro="Vous recherchez un maçon pour rénover votre maison ou appartement à Douai ? OBC Maçonnerie intervient dans tout le Douaisis avec expertise et rigueur."
texteLocal={`Le Douaisis compte de nombreuses maisons de ville anciennes à rénover. OBC Maçonnerie est parfaitement adapté pour ce type de chantier : restructuration intérieure, mise aux normes, ravalement de façade, création de salles de bains modernes.\n\nBenoît Colin connaît les spécificités des maisons de la région douaisienne et sait travailler sur des bâtis anciens sans compromettre la solidité de la structure. Chaque chantier est une nouvelle aventure.\n\nGrâce à son réseau de partenaires (électricien, plombier, carreleur, peintre), Benoît coordonne l'intégralité de votre rénovation à Douai pour vous livrer un logement entièrement transformé.`}
distanceMouchin="À environ 20 km"
/>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import LocalSEOPage from "@/components/marketing/LocalSEOPage";
export const metadata: Metadata = {
title: "Rénovation Maison Orchies | Maçon | OBC Maçonnerie",
description:
"Rénovation de maison et appartement à Orchies. OBC Maçonnerie, maçon expert en rénovation dans le Nord (59). Devis gratuit.",
keywords: ["rénovation maison Orchies", "maçon rénovation Orchies", "rénovation appartement Orchies"],
alternates: { canonical: "https://obc-maconnerie.fr/renovation-maison-orchies" },
};
export default function RenovationMaisonOrchiesPage() {
return (
<LocalSEOPage
ville="Orchies"
departement="Nord (59)"
servicesPrincipaux={["Rénovation"]}
description="Rénovation de maison à Orchies — OBC Maçonnerie, maçon expert en rénovation dans le secteur d'Orchies."
texteIntro="Vous avez un projet de rénovation à Orchies ? OBC Maçonnerie est votre spécialiste local pour tous vos travaux de rénovation de maison ou d'appartement."
texteLocal={`La rénovation est au cœur du métier d'OBC Maçonnerie. À Orchies comme dans toute la région, Benoît Colin transforme les logements existants en s'adaptant à chaque projet : restructuration intérieure, rénovation de façade, création d'ouvertures, extension.\n\nBenoît a une approche unique : il réfléchit avec vous à l'optimisation de vos espaces. Modifier une cage d'escalier, abattre une cloison pour ouvrir un séjour, créer une suite parentale — chaque idée est examinée et mise en œuvre avec soin.\n\nContactez OBC Maçonnerie pour un devis de rénovation gratuit à Orchies. Nous intervenons rapidement et dans les délais convenus.`}
distanceMouchin="À environ 10 km"
/>
);
}

124
app/renovation/page.tsx Normal file
View File

@@ -0,0 +1,124 @@
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";
import ContactForm from "@/components/marketing/ContactForm";
export const metadata: Metadata = {
title: "Rénovation Maison & Appartement Nord 59 | OBC Maçonnerie",
description:
"Rénovation complète ou partielle de maison et appartement dans le Nord. Benoît Colin vous conseille et adapte chaque projet. Devis gratuit.",
keywords: [
"rénovation maison Nord 59",
"rénovation appartement Nord",
"maçon rénovation Douai",
"maçon rénovation Valenciennes",
"rénovation maison Orchies",
"travaux rénovation Nord",
],
alternates: { canonical: "https://obc-maconnerie.fr/renovation" },
};
const typesTravaux = [
{ icon: "🏚️", title: "Rénovation complète", desc: "Restructuration totale d'une maison ancienne, de la démolition des cloisons existantes à la pose des revêtements." },
{ icon: "🧱", title: "Maçonnerie intérieure", desc: "Création ou suppression de cloisons, doublages, cages d'escalier, adaptation de plans d'architecte." },
{ icon: "🏗️", title: "Extension", desc: "Agrandissement de votre maison par extension latérale ou surélévation, en parfaite continuité avec l'existant." },
{ icon: "🪟", title: "Ouvertures", desc: "Création de baies vitrées, portes, fenêtres — avec reprise de linteaux et traitement des murs porteurs." },
{ icon: "🏢", title: "Rénovation de façade", desc: "Ravalement, rejointoiement, isolation par l'extérieur (ITE) pour améliorer le confort et l'esthétique." },
{ icon: "🏠", title: "Rénovation appartement", desc: "Transformation d'appartements : redistribution des pièces, mise aux normes, travaux de second œuvre." },
];
export default function RenovationPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
<section className="bg-navy py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="max-w-2xl">
<ScrollReveal direction="up">
<Link href="/services" className="inline-flex items-center gap-1.5 text-white/50 hover:text-white text-sm mb-6 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Tous les services
</Link>
<span className="text-orange text-sm font-semibold uppercase tracking-widest">Rénovation</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">
Rénovation maison & appartement dans le Nord
</h1>
<p className="text-white/70 text-lg mb-8">
Chaque rénovation est unique. Benoît Colin s&apos;adapte à votre projet, votre budget et vos envies pour transformer votre logement.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/contact" className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors pulse-glow">
Demander un devis gratuit
</Link>
<a href="tel:0674453089" className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white font-semibold px-7 py-3.5 rounded-xl transition-colors border border-white/20">
06 74 45 30 89
</a>
</div>
</ScrollReveal>
</div>
</div>
</section>
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-10 text-center">
Nos spécialités en rénovation
</h2>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{typesTravaux.map((t, i) => (
<ScrollReveal key={t.title} direction="up" delay={i * 80}>
<div className="bg-bg-white border border-border rounded-2xl p-6 h-full">
<div className="text-3xl mb-3">{t.icon}</div>
<h3 className="text-navy font-bold text-base mb-2">{t.title}</h3>
<p className="text-text-light text-sm leading-relaxed">{t.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
<section className="py-14 bg-stone-bg">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-4">
Maçon rénovation dans le Nord (59)
</h2>
<div className="space-y-4 text-text-light text-sm leading-relaxed">
<p>
OBC Maçonnerie intervient pour tous vos travaux de <strong className="text-text">rénovation dans le Nord</strong>. Que vous soyez à Orchies, Douai, Valenciennes ou dans les communes environnantes, Benoît Colin se déplace pour évaluer votre projet et vous proposer les meilleures solutions.
</p>
<p>
Sa passion : adapter les espaces. Modifier une cage d&apos;escalier pour créer un hall plus lumineux, abattre une cloison pour ouvrir un salon, adapter un plan pour coller à votre mode de vie Benoît réfléchit avec vous et vous éclaire dans vos décisions.
</p>
<p>
Grâce à son réseau de partenaires, il coordonne aussi les corps de métier complémentaires (électricité, plomberie, carrelage, peinture) pour une rénovation complète avec un seul interlocuteur.
</p>
</div>
</ScrollReveal>
</div>
</section>
<section className="py-16 bg-bg">
<div className="max-w-xl mx-auto px-4 sm:px-6">
<ScrollReveal direction="up">
<h2 className="text-2xl font-bold text-navy mb-2 text-center">Votre projet de rénovation</h2>
<p className="text-text-light text-sm text-center mb-8">Devis gratuit Réponse sous 24h</p>
</ScrollReveal>
<ScrollReveal direction="up" delay={100}>
<ContactForm />
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -1,6 +1,6 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://hooklab.eu";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://obc-maconnerie.fr";
export default function robots(): MetadataRoute.Robots {
return {
@@ -8,7 +8,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/", "/setup-admin/", "/dashboard/", "/profil/", "/formations/", "/login/", "/register/", "/candidature/"],
disallow: ["/api/"],
},
],
sitemap: `${BASE_URL}/sitemap.xml`,

141
app/services/page.tsx Normal file
View File

@@ -0,0 +1,141 @@
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: "Nos Services | Construction, Rénovation, Assainissement",
description:
"Tous les services d'OBC Maçonnerie : construction de maison, rénovation, assainissement, création d'accès et démolition dans le Nord (59). Devis gratuit.",
alternates: { canonical: "https://obc-maconnerie.fr/services" },
};
const services = [
{
icon: "🏠",
title: "Construction de maison",
desc: "De la conception au gros œuvre, Benoît Colin vous accompagne dans la construction de votre maison individuelle. Fondations, ossature bois, dalles, murs porteurs — tout est pris en charge avec rigueur et savoir-faire.",
href: "/construction-maison",
points: ["Maison individuelle", "Ossature bois", "Fondations", "Gros œuvre complet"],
},
{
icon: "🔨",
title: "Rénovation",
desc: "Que ce soit une rénovation partielle ou complète, OBC Maçonnerie s'adapte à votre projet. Maison ancienne, appartement, restructuration intérieure — chaque chantier est unique et traité comme tel.",
href: "/renovation",
points: ["Rénovation complète", "Restructuration intérieure", "Maison de ville", "Extension"],
},
{
icon: "💧",
title: "Assainissement",
desc: "Mise aux normes de votre système d'assainissement, création d'un nouveau dispositif ou réhabilitation de l'existant. OBC Maçonnerie réalise vos travaux d'assainissement dans les règles de l'art.",
href: "/assainissement",
points: ["Assainissement individuel", "Mise aux normes", "Fosse septique", "Épandage"],
},
{
icon: "🚧",
title: "Création d'accès",
desc: "Voiries, entrées de propriété, chemins, allées — OBC Maçonnerie crée vos accès selon vos besoins et vos envies. Béton, béton imprimé, pavés ou grave compactée.",
href: "/creation-acces",
points: ["Voiries privées", "Entrées de propriété", "Chemins ruraux", "Béton imprimé"],
},
{
icon: "🏗️",
title: "Démolition",
desc: "Démolition totale ou partielle de bâtiment, destruction de murs porteurs, dépose de chapes — OBC Maçonnerie intervient avec tout le matériel et les garanties de sécurité nécessaires.",
href: "/demolition",
points: ["Démolition totale", "Démolition partielle", "Murs porteurs", "Évacuation des gravats"],
},
{
icon: "🤝",
title: "Conseil & Accompagnement",
desc: "Benoît vous guide à chaque étape de votre projet : choix des matériaux, adaptation de plans, coordination des artisans partenaires. Un seul interlocuteur pour un projet serein.",
href: "/contact",
points: ["Conseils matériaux", "Adaptation de plans", "Coordination artisans", "Suivi de chantier"],
},
];
export default function ServicesPage() {
return (
<main id="main-content" className="min-h-screen">
<Navbar />
{/* Hero */}
<section className="bg-navy py-16 md:py-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<ScrollReveal direction="up">
<span className="text-orange text-sm font-semibold uppercase tracking-widest">OBC Maçonnerie</span>
<h1 className="text-3xl md:text-5xl font-bold text-white mt-2 mb-4">Nos services de maçonnerie</h1>
<p className="text-white/70 text-lg max-w-xl mx-auto">
Construction, rénovation, assainissement et gros œuvre dans le Nord Benoît Colin vous accompagne de A à Z.
</p>
</ScrollReveal>
</div>
</section>
{/* Services */}
<section className="py-16 md:py-20 bg-bg">
<div className="max-w-5xl mx-auto px-4 sm:px-6 space-y-8">
{services.map((s, i) => (
<ScrollReveal key={s.title} direction="up" delay={i * 60}>
<div className="bg-bg-white border border-border rounded-2xl p-6 md:p-8 flex flex-col md:flex-row gap-6">
<div className="text-5xl shrink-0">{s.icon}</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-navy mb-2">{s.title}</h2>
<p className="text-text-light text-sm leading-relaxed mb-4">{s.desc}</p>
<div className="flex flex-wrap gap-2 mb-5">
{s.points.map((p) => (
<span key={p} className="bg-bg-muted text-text-light text-xs font-medium px-3 py-1 rounded-full">
{p}
</span>
))}
</div>
<Link
href={s.href}
className="inline-flex items-center gap-1.5 text-orange font-semibold text-sm hover:underline"
>
En savoir plus
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</div>
</ScrollReveal>
))}
</div>
</section>
{/* CTA */}
<section className="py-16 bg-stone-bg">
<div className="max-w-2xl mx-auto px-4 text-center">
<ScrollReveal direction="up">
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-4">
Vous avez un projet ? Parlons-en.
</h2>
<p className="text-text-light mb-6">
Benoît se déplace gratuitement pour évaluer votre projet et vous remettre un devis détaillé.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/contact"
className="inline-flex items-center justify-center gap-2 bg-orange hover:bg-orange-hover text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
Demander un devis gratuit
</Link>
<a
href="tel:0674453089"
className="inline-flex items-center justify-center gap-2 border-2 border-navy text-navy hover:bg-navy hover:text-white font-bold px-7 py-3.5 rounded-xl transition-colors"
>
06 74 45 30 89
</a>
</div>
</ScrollReveal>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -1,145 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function AdminSetupPage() {
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSetup = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, full_name: fullName }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de la création.");
} finally {
setLoading(false);
}
};
if (success) {
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md text-center">
<div className="bg-dark-light border border-dark-border rounded-[20px] p-8">
<div className="w-16 h-16 gradient-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-white mb-2">Compte admin créé !</h1>
<p className="text-white/60 text-sm mb-6">
Ton compte admin a é créé avec succès. Connecte-toi pour accéder au panel d&apos;administration.
</p>
<Link
href="/login"
className="inline-block px-6 py-3 gradient-bg text-white font-semibold rounded-xl"
>
Se connecter
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<span className="text-white font-bold">H</span>
</div>
<span className="text-2xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</div>
<h1 className="text-2xl font-bold text-white mb-2">Configuration admin</h1>
<p className="text-white/60 text-sm">
Crée ton compte administrateur. Cette page ne fonctionne qu&apos;une seule fois.
</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
<form onSubmit={handleSetup} className="space-y-5">
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-white/80 mb-1.5">
Nom complet
</label>
<input
id="fullName"
type="text"
placeholder="Enguerrand Ozano"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-white/80 mb-1.5">
Email
</label>
<input
id="email"
type="email"
placeholder="ton@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-white/80 mb-1.5">
Mot de passe
</label>
<input
id="password"
type="password"
placeholder="Minimum 8 caractères"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
/>
</div>
{error && (
<div className="p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 gradient-bg text-white font-semibold rounded-xl disabled:opacity-50 cursor-pointer"
>
{loading ? "Création en cours..." : "Créer le compte admin"}
</button>
</form>
</div>
</div>
</main>
);
}

View File

@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import LocalSeoPage from "@/components/marketing/LocalSeoPage";
export const metadata: Metadata = {
title: "Création Site Internet Artisan Arleux (59) | HookLab",
description:
"Création de sites internet pour artisans à Arleux et environs. Visibilité Google, site ultra-rapide, système de confiance. Audit gratuit.",
};
export default function Page() {
return (
<LocalSeoPage
ville="Arleux"
villeSlug="arleux"
codePostal="59151"
voisines={["Douai", "Orchies", "Flines-lez-Raches"]}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import LocalSeoPage from "@/components/marketing/LocalSeoPage";
export const metadata: Metadata = {
title: "Création Site Internet Artisan Denain (59) | HookLab",
description:
"Sites web professionnels pour artisans du bâtiment à Denain. Maçon, couvreur, plombier, paysagiste. SEO local + audit offert.",
};
export default function Page() {
return (
<LocalSeoPage
ville="Denain"
villeSlug="denain"
codePostal="59220"
voisines={["Valenciennes", "Douai", "Saint-Amand-les-Eaux"]}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import LocalSeoPage from "@/components/marketing/LocalSeoPage";
export const metadata: Metadata = {
title: "Création Site Internet Artisan Douai (59) | HookLab",
description:
"Spécialiste création de sites web pour artisans du bâtiment à Douai et environs. Couvreur, maçon, paysagiste, plombier. Audit gratuit.",
};
export default function Page() {
return (
<LocalSeoPage
ville="Douai"
villeSlug="douai"
codePostal="59500"
voisines={["Orchies", "Arleux", "Flines-lez-Raches"]}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import LocalSeoPage from "@/components/marketing/LocalSeoPage";
export const metadata: Metadata = {
title: "Création Site Internet Artisan Orchies (59) | HookLab",
description:
"Création de sites web professionnels pour artisans à Orchies. Couvreur, maçon, paysagiste. Site rapide + SEO local. Audit offert.",
};
export default function Page() {
return (
<LocalSeoPage
ville="Orchies"
villeSlug="orchies"
codePostal="59310"
voisines={["Douai", "Flines-lez-Raches", "Saint-Amand-les-Eaux"]}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import LocalSeoPage from "@/components/marketing/LocalSeoPage";
export const metadata: Metadata = {
title: "Création Site Internet Artisan Saint-Amand-les-Eaux (59) | HookLab",
description:
"Votre site web professionnel d'artisan à Saint-Amand-les-Eaux. Couvreur, plombier, paysagiste. Conçu pour générer des chantiers. Audit offert.",
};
export default function Page() {
return (
<LocalSeoPage
ville="Saint-Amand-les-Eaux"
villeSlug="saint-amand-les-eaux"
codePostal="59230"
voisines={["Valenciennes", "Orchies", "Denain"]}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import LocalSeoPage from "@/components/marketing/LocalSeoPage";
export const metadata: Metadata = {
title: "Création Site Internet Artisan Valenciennes (59) | HookLab",
description:
"Sites web pour artisans du bâtiment à Valenciennes et Valenciennois. Technologie ultra-rapide, SEO local, résultats concrets. Audit gratuit.",
};
export default function Page() {
return (
<LocalSeoPage
ville="Valenciennes"
villeSlug="valenciennes"
codePostal="59300"
voisines={["Denain", "Saint-Amand-les-Eaux", "Douai"]}
/>
);
}

View File

@@ -1,95 +1,49 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://hooklab.eu";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://obc-maconnerie.fr";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return [
// Page d'accueil - priorité max
{
url: BASE_URL,
lastModified: now,
changeFrequency: "weekly",
priority: 1.0,
},
// Accueil
{ url: BASE_URL, lastModified: now, changeFrequency: "weekly", priority: 1.0 },
// Démos métiers - pages stratégiques SEO
{
url: `${BASE_URL}/macon`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.9,
},
{
url: `${BASE_URL}/paysagiste`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.9,
},
{
url: `${BASE_URL}/plombier`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.9,
},
// Pages services principales
{ url: `${BASE_URL}/services`, lastModified: now, changeFrequency: "monthly", priority: 0.9 },
{ url: `${BASE_URL}/construction-maison`, lastModified: now, changeFrequency: "monthly", priority: 0.9 },
{ url: `${BASE_URL}/renovation`, lastModified: now, changeFrequency: "monthly", priority: 0.9 },
{ url: `${BASE_URL}/assainissement`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/creation-acces`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/demolition`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
// Pages SEO locales - site internet artisan + ville
{
url: `${BASE_URL}/site-internet-artisan-douai`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/site-internet-artisan-orchies`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/site-internet-artisan-valenciennes`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/site-internet-artisan-saint-amand-les-eaux`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/site-internet-artisan-arleux`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/site-internet-artisan-denain`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
// Pages secondaires
{ url: `${BASE_URL}/realisations`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/partenaires`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/contact`, lastModified: now, changeFrequency: "monthly", priority: 0.9 },
{ url: `${BASE_URL}/blog`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
// Légal
{
url: `${BASE_URL}/cgv`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${BASE_URL}/mentions-legales`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${BASE_URL}/confidentialite`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
// Articles de blog
{ url: `${BASE_URL}/blog/combien-coute-construction-maison-nord`, lastModified: now, changeFrequency: "yearly", priority: 0.6 },
{ url: `${BASE_URL}/blog/etapes-renovation-maison-ancienne`, lastModified: now, changeFrequency: "yearly", priority: 0.6 },
{ url: `${BASE_URL}/blog/assainissement-non-collectif-obligations`, lastModified: now, changeFrequency: "yearly", priority: 0.6 },
{ url: `${BASE_URL}/blog/ossature-bois-avantages`, lastModified: now, changeFrequency: "yearly", priority: 0.6 },
{ url: `${BASE_URL}/blog/travaux-renovation-sans-permis-construction`, lastModified: now, changeFrequency: "yearly", priority: 0.6 },
{ url: `${BASE_URL}/blog/fondations-maison-quels-types`, lastModified: now, changeFrequency: "yearly", priority: 0.6 },
// Pages SEO locales
{ url: `${BASE_URL}/construction-maison-orchies`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/construction-maison-douai`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/construction-maison-valenciennes`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/renovation-maison-orchies`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/renovation-maison-douai`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE_URL}/macon-mouchin`, lastModified: now, changeFrequency: "monthly", priority: 0.9 },
{ url: `${BASE_URL}/macon-flines-lez-raches`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/macon-saint-amand-les-eaux`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
// Legal
{ url: `${BASE_URL}/cgv`, lastModified: now, changeFrequency: "yearly", priority: 0.2 },
{ url: `${BASE_URL}/mentions-legales`, lastModified: now, changeFrequency: "yearly", priority: 0.2 },
{ url: `${BASE_URL}/confidentialite`, lastModified: now, changeFrequency: "yearly", priority: 0.2 },
];
}