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,40 +0,0 @@
import { createClient, createAdminClient } from "@/lib/supabase/server";
import type { Profile } from "@/types/database.types";
// Vérifie que l'utilisateur connecté est admin
// Utilisé dans les API routes admin
export async function verifyAdmin(): Promise<{ admin: Profile } | { error: string; status: number }> {
const supabase = await createClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return { error: "Non authentifié.", status: 401 };
}
// Utiliser le client admin pour lire le profil (pas de RLS)
const adminClient = createAdminClient();
const { data: profile, error: profileError } = await adminClient
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
if (profileError || !profile) {
return { error: "Profil introuvable.", status: 404 };
}
if (!(profile as Profile).is_admin) {
return { error: "Accès refusé.", status: 403 };
}
return { admin: profile as Profile };
}
// Helper pour répondre avec erreur si non-admin
export function isAdminError(result: { admin: Profile } | { error: string; status: number }): result is { error: string; status: number } {
return "error" in result;
}

View File

@@ -1,21 +0,0 @@
import { createClient, type SanityClient } from "@sanity/client";
import imageUrlBuilder from "@sanity/image-url";
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
// Only create the real client if projectId is configured
export const sanityClient: SanityClient | null = projectId
? createClient({
projectId,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
apiVersion: "2024-01-01",
useCdn: false,
})
: null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function urlFor(source: any) {
if (!sanityClient) return null;
const builder = imageUrlBuilder(sanityClient);
return builder.image(source);
}

View File

@@ -1,42 +0,0 @@
import { sanityClient } from "./client";
export interface PortfolioItem {
_id: string;
title: string;
result: string;
image: unknown;
slug: string;
}
export interface SiteSettings {
ownerName: string;
ownerBio: string;
ownerPhoto: unknown;
address: string;
phone: string;
email: string;
lat: number;
lng: number;
}
export async function getPortfolio(): Promise<PortfolioItem[]> {
if (!sanityClient) return [];
try {
return await sanityClient.fetch(
`*[_type == "portfolio"] | order(orderRank asc) { _id, title, result, image, "slug": slug.current }`
);
} catch {
return [];
}
}
export async function getSiteSettings(): Promise<SiteSettings | null> {
if (!sanityClient) return null;
try {
return await sanityClient.fetch(
`*[_type == "siteSettings"][0] { ownerName, ownerBio, ownerPhoto, address, phone, email, lat, lng }`
);
} catch {
return null;
}
}

View File

@@ -1,198 +0,0 @@
import { createAdminClient } from "@/lib/supabase/server";
export interface SiteImage {
key: string;
url: string;
label: string;
updated_at?: string;
}
// Images par défaut (utilisées si la table n'existe pas ou si l'image n'est pas configurée)
export const DEFAULT_IMAGES: Record<string, { url: string; label: string }> = {
// ── Page d'accueil HookLab ──────────────────────────────────────────────────
hero_portrait: {
url: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=500&q=80",
label: "Accueil — Photo portrait (Hero)",
},
about_photo: {
url: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Photo Enguerrand (Qui suis-je)",
},
process_google: {
url: "https://images.unsplash.com/photo-1611162617213-7d7a39e9b1d7?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Image Avis Google (étape 1)",
},
process_facebook: {
url: "https://images.unsplash.com/photo-1611162616305-c69b3fa7fbe0?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Image Facebook (étape 2)",
},
process_site: {
url: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Image Site Internet (étape 3)",
},
demo_macon: {
url: "https://images.unsplash.com/photo-1504307651254-35680f356dfd?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Image démo Maçon",
},
demo_paysagiste: {
url: "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Image démo Paysagiste",
},
demo_plombier: {
url: "https://images.unsplash.com/photo-1581244277943-fe4a9c777189?auto=format&fit=crop&w=600&q=80",
label: "Accueil — Image démo Plombier",
},
// ── Démo Maçon ─────────────────────────────────────────────────────────────
macon_photo_cyprien: {
url: "",
label: "Maçon — Photo de Cyprien (sur le chantier)",
},
macon_hero: {
url: "https://images.unsplash.com/photo-1504307651254-35680f356dfd?auto=format&fit=crop&w=1920&q=80",
label: "Maçon — Photo fond Hero",
},
macon_slider1_gauche: {
url: "https://images.unsplash.com/photo-1632823469850-2f77dd9c7f93?auto=format&fit=crop&w=800&q=80",
label: "Maçon — Slider 1 photo gauche (avant : maison dans son jus)",
},
macon_slider1_droite: {
url: "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=800&q=80",
label: "Maçon — Slider 1 photo droite (après : extension 30m²)",
},
macon_slider2_gauche: {
url: "https://images.unsplash.com/photo-1590274853856-f22d5ee3d228?auto=format&fit=crop&w=800&q=80",
label: "Maçon — Slider 2 photo gauche (avant : façade fissurée)",
},
macon_slider2_droite: {
url: "https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=800&q=80",
label: "Maçon — Slider 2 photo droite (après : ravalement complet)",
},
macon_slider3_gauche: {
url: "https://images.unsplash.com/photo-1504307651254-35680f356dfd?auto=format&fit=crop&w=800&q=80",
label: "Maçon — Slider 3 photo gauche (avant : terrain nu)",
},
macon_slider3_droite: {
url: "https://images.unsplash.com/photo-1600566753190-17f0baa2a6c0?auto=format&fit=crop&w=800&q=80",
label: "Maçon — Slider 3 photo droite (après : terrasse carrelée)",
},
// ── Démo Paysagiste ────────────────────────────────────────────────────────
paysagiste_hero: {
url: "https://images.unsplash.com/photo-1564429238961-bf8ad08feabb?auto=format&fit=crop&w=1920&q=80",
label: "Paysagiste — Photo fond Hero",
},
paysagiste_service_creation: {
url: "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?auto=format&fit=crop&w=800&q=80",
label: "Paysagiste — Card service Création espaces verts",
},
paysagiste_service_entretien: {
url: "https://images.unsplash.com/photo-1416879595882-3373a0480b5b?auto=format&fit=crop&w=800&q=80",
label: "Paysagiste — Card service Entretien espaces verts",
},
paysagiste_services_photo: {
url: "https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?auto=format&fit=crop&w=800&q=80",
label: "Paysagiste — Photo section savoir-faire",
},
paysagiste_equipe: {
url: "https://images.unsplash.com/photo-1558618666-fcd25c85f82e?auto=format&fit=crop&w=800&q=80",
label: "Paysagiste — Photo équipe (Qui sommes-nous)",
},
paysagiste_cta: {
url: "https://images.unsplash.com/photo-1572120360610-d971b9d7767c?auto=format&fit=crop&w=1920&q=80",
label: "Paysagiste — Photo fond section contact/CTA",
},
paysagiste_galerie_1: {
url: "https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 1 (terrasse composite)",
},
paysagiste_galerie_2: {
url: "https://images.unsplash.com/photo-1572120360610-d971b9d7767c?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 2 (piscine + clôture)",
},
paysagiste_galerie_3: {
url: "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 3 (massif fleuri)",
},
paysagiste_galerie_4: {
url: "https://images.unsplash.com/photo-1558171813-4c088753af8f?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 4 (haie bambou)",
},
paysagiste_galerie_5: {
url: "https://images.unsplash.com/photo-1598902108854-d1446c81e20e?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 5 (allée pavés anciens)",
},
paysagiste_galerie_6: {
url: "https://images.unsplash.com/photo-1582547403609-4244e80be657?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 6 (jardin japonais)",
},
paysagiste_galerie_7: {
url: "https://images.unsplash.com/photo-1416879595882-3373a0480b5b?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 7 (taille haies buis)",
},
paysagiste_galerie_8: {
url: "https://images.unsplash.com/photo-1557429287-b2e26467fc2b?auto=format&fit=crop&w=600&q=80",
label: "Paysagiste — Galerie photo 8 (entretien parc 3000m²)",
},
};
/**
* Récupère toutes les images du site depuis Supabase.
*
* - URL externe (https://...) → retournée telle quelle
* - Chemin privé (storage:...) → retourné comme /api/img/<key>
* Le proxy génère une Signed URL fraîche à chaque requête du navigateur
* (cache navigateur/CDN 55 min) — aucune signed URL n'est jamais embarquée
* dans le HTML statique, ce qui élimine tout risque d'expiration.
*/
export async function getSiteImages(): Promise<Record<string, string>> {
const result: Record<string, string> = {};
// Initialiser avec les defaults
for (const [key, val] of Object.entries(DEFAULT_IMAGES)) {
result[key] = val.url;
}
try {
const supabase = createAdminClient();
const { data, error } = await supabase.from("site_images").select("key, url");
const rows = (data ?? []) as unknown as Pick<SiteImage, "key" | "url">[];
if (!error) {
for (const row of rows) {
if (!row.url) continue;
if (row.url.startsWith("storage:")) {
// Proxy URL → la signed URL est générée à la demande côté navigateur
result[row.key] = `/api/img/${row.key}`;
} else {
// URL externe directe
result[row.key] = row.url;
}
}
}
} catch {
// Table absente → on conserve les defaults
}
return result;
}
/**
* Met à jour une image du site.
* Accepte une URL externe (https://...) ou un chemin storage (storage:...).
*/
export async function updateSiteImage(key: string, url: string): Promise<boolean> {
try {
const supabase = createAdminClient();
const label = DEFAULT_IMAGES[key]?.label || key;
const { error } = await (supabase.from("site_images") as ReturnType<typeof supabase.from>).upsert(
{ key, url, label, updated_at: new Date().toISOString() } as Record<string, unknown>,
{ onConflict: "key" }
);
return !error;
} catch {
return false;
}
}

View File

@@ -1,6 +0,0 @@
import Stripe from "stripe";
// Client Stripe côté serveur
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
typescript: true,
});

View File

@@ -1,74 +0,0 @@
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/types/database.types";
// Storage basé sur les cookies pour que le serveur puisse lire la session
// Remplace le localStorage par défaut de Supabase
const cookieStorage = {
getItem: (key: string): string | null => {
if (typeof document === "undefined") return null;
// Essayer le cookie direct
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(
new RegExp(`(?:^|; )${escaped}=([^;]*)`)
);
if (match) return decodeURIComponent(match[1]);
// Essayer les cookies chunked (.0, .1, .2, ...)
const chunks: string[] = [];
for (let i = 0; i < 10; i++) {
const chunkMatch = document.cookie.match(
new RegExp(`(?:^|; )${escaped}\\.${i}=([^;]*)`)
);
if (chunkMatch) {
chunks.push(decodeURIComponent(chunkMatch[1]));
} else {
break;
}
}
if (chunks.length > 0) return chunks.join("");
return null;
},
setItem: (key: string, value: string): void => {
if (typeof document === "undefined") return;
// Supprimer les anciens cookies d'abord
cookieStorage.removeItem(key);
const maxChunkSize = 3500; // Limite cookie ~4KB avec overhead
if (value.length <= maxChunkSize) {
document.cookie = `${key}=${encodeURIComponent(value)}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
} else {
// Découper en chunks
for (let i = 0; i * maxChunkSize < value.length; i++) {
const chunk = value.substring(i * maxChunkSize, (i + 1) * maxChunkSize);
document.cookie = `${key}.${i}=${encodeURIComponent(chunk)}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
}
}
},
removeItem: (key: string): void => {
if (typeof document === "undefined") return;
document.cookie = `${key}=; path=/; max-age=0`;
for (let i = 0; i < 10; i++) {
document.cookie = `${key}.${i}=; path=/; max-age=0`;
}
},
};
// Client Supabase côté navigateur
// Utilise les cookies comme storage pour que le serveur puisse lire la session
export const createClient = () =>
createSupabaseClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
storage: cookieStorage,
flowType: "pkce",
},
}
);

View File

@@ -1,81 +0,0 @@
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
import type { Database } from "@/types/database.types";
// Client Supabase côté serveur (Server Components, Route Handlers)
// Lit le token d'auth depuis les cookies pour maintenir la session
export const createClient = async () => {
const cookieStore = await cookies();
// Récupérer le token d'accès depuis les cookies Supabase
const accessToken = cookieStore.get("sb-access-token")?.value
|| cookieStore.get(`sb-${new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).hostname.split(".")[0]}-auth-token`)?.value;
const client = createSupabaseClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
global: {
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
},
}
);
// Essayer de restaurer la session depuis les cookies
const allCookies = cookieStore.getAll();
// Chercher le cookie de session complet (format chunked ou simple)
const projectRef = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).hostname.split(".")[0];
const authCookieName = `sb-${projectRef}-auth-token`;
// Reassembler les chunks si nécessaire
let sessionData: string | null = null;
const baseCookie = allCookies.find(c => c.name === authCookieName);
if (baseCookie) {
sessionData = baseCookie.value;
} else {
// Chercher les chunks (sb-xxx-auth-token.0, sb-xxx-auth-token.1, etc.)
const chunks: string[] = [];
for (let i = 0; i < 10; i++) {
const chunk = allCookies.find(c => c.name === `${authCookieName}.${i}`);
if (chunk) {
chunks.push(chunk.value);
} else {
break;
}
}
if (chunks.length > 0) {
sessionData = chunks.join("");
}
}
if (sessionData) {
try {
const parsed = JSON.parse(sessionData);
if (parsed?.access_token && parsed?.refresh_token) {
await client.auth.setSession({
access_token: parsed.access_token,
refresh_token: parsed.refresh_token,
});
}
} catch {
// Cookie invalide, on continue sans session
}
}
return client;
};
// Client admin avec service role (webhooks, opérations admin)
export const createAdminClient = () => {
return createSupabaseClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
};