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:
40
lib/admin.ts
40
lib/admin.ts
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user