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:
661
PLAN.md
661
PLAN.md
@@ -1,661 +0,0 @@
|
||||
# Plan de déploiement HookLab — Sites Artisans
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Serveur OVH `(toi + moi)`
|
||||
|
||||
### 1.1 — VPS ✅ FAIT
|
||||
- [x] VPS OVH commandé — IP : `51.83.162.147` / host : `vps-9993ccd0.vps.ovh.net`
|
||||
- [x] Ubuntu **22.04** LTS
|
||||
- [x] WordOps installé et accessible sur `https://51.83.162.147:22222`
|
||||
|
||||
### 1.2 — Accès SSH ✅ FAIT
|
||||
|
||||
> **Note :** Sur OVH Ubuntu, l'utilisateur par défaut est `ubuntu`, **pas** `root`.
|
||||
> `ssh root@IP` → Permission denied (c'est normal)
|
||||
|
||||
```bash
|
||||
# Connexion correcte :
|
||||
ssh ubuntu@51.83.162.147
|
||||
|
||||
# Pour passer root une fois connecté :
|
||||
sudo -i
|
||||
```
|
||||
|
||||
### 1.3 — Stack LEMP ✅ FAIT (via WordOps)
|
||||
> WordOps gère Nginx, PHP 8.2, MariaDB, Redis automatiquement.
|
||||
> Pas besoin d'installer manuellement.
|
||||
|
||||
### 1.4 — Certbot ✅ FAIT (via WordOps)
|
||||
> WordOps intègre Let's Encrypt nativement.
|
||||
|
||||
### 1.5 — Pare-feu
|
||||
```bash
|
||||
# À vérifier une fois connecté en SSH :
|
||||
sudo ufw status
|
||||
# Si inactif :
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 80
|
||||
sudo ufw allow 443
|
||||
sudo ufw allow 22222 # WordOps backend
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — WordPress Multisite `(toi + moi)`
|
||||
|
||||
> **Objectif :** Un seul WordPress qui héberge tous les sites clients.
|
||||
> Chaque client = un sous-site isolé avec son propre accès.
|
||||
>
|
||||
> **Avec WordOps, les étapes 2.1 à 2.4 se font en 2 commandes.**
|
||||
|
||||
### 2.1 à 2.4 — Installation WordPress Multisite via WordOps ✅ FAIT
|
||||
|
||||
```bash
|
||||
# Commandes déjà exécutées :
|
||||
ssh ubuntu@51.83.162.147
|
||||
sudo wo site create hooklab.eu --wp --wpsubdomain
|
||||
```
|
||||
|
||||
> **Mode choisi : sous-domaines**
|
||||
> Chaque client aura son propre sous-domaine :
|
||||
> `cyprien.hooklab.eu`, `dupont.hooklab.eu`, etc.
|
||||
> (Nécessite un enregistrement DNS wildcard `*.hooklab.eu → 51.83.162.147`)
|
||||
|
||||
```bash
|
||||
# Si HTTPS pas encore activé :
|
||||
sudo wo site update hooklab.eu --letsencrypt
|
||||
|
||||
# Voir les identifiants WP admin et BDD :
|
||||
sudo wo site info hooklab.eu
|
||||
```
|
||||
|
||||
### 2.5 — Accéder au tableau de bord WordPress
|
||||
|
||||
> WordOps a créé ton WordPress. Voici comment y accéder.
|
||||
|
||||
**Récupérer le mot de passe WordPress :**
|
||||
```bash
|
||||
# Dans le terminal SSH :
|
||||
sudo wo site info hooklab.eu
|
||||
# → Affiche : URL admin, login, mot de passe, identifiants BDD
|
||||
```
|
||||
|
||||
**Se connecter à WordPress :**
|
||||
1. Ouvre ton navigateur
|
||||
2. Va sur `http://hooklab.eu/wp-login.php`
|
||||
3. Saisis le login et mot de passe affichés par `wo site info`
|
||||
4. Tu es dans le tableau de bord WordPress
|
||||
|
||||
> **Le DNS n'est pas encore configuré ?** Pas de problème.
|
||||
> Ajoute cette ligne sur ton PC pour simuler le DNS **uniquement chez toi** :
|
||||
> Vercel reste en ligne pour tout le monde — toi seul vois le WordPress.
|
||||
>
|
||||
> **Mac/Linux :** `sudo nano /etc/hosts` → ajoute `51.83.162.147 hooklab.eu`
|
||||
>
|
||||
> **Windows :** Bloc-notes (admin) → ouvre `C:\Windows\System32\drivers\etc\hosts` → ajoute `51.83.162.147 hooklab.eu`
|
||||
>
|
||||
> Quand tout est prêt pour basculer : supprime cette ligne + fais le DNS OVH (Phase 6).
|
||||
|
||||
**Accéder à l'administration réseau (Multisite) :**
|
||||
- En haut à gauche, clique sur **"Mes sites"** → **"Administration du réseau"**
|
||||
- OU va directement sur `http://hooklab.eu/wp-admin/network/`
|
||||
- C'est depuis là que tu gères TOUS les sites clients
|
||||
|
||||
---
|
||||
|
||||
### 2.6 — Installer les plugins essentiels (réseau)
|
||||
|
||||
> Les plugins installés depuis l'administration réseau sont disponibles pour tous les sous-sites.
|
||||
> Tu les installes une fois, tu les actives sur chaque sous-site.
|
||||
|
||||
**Aller dans les plugins réseau :**
|
||||
1. Administration réseau → menu gauche → **"Extensions"** → **"Ajouter"**
|
||||
|
||||
**Installer chaque plugin (même procédure pour tous) :**
|
||||
1. Dans la barre de recherche, tape le nom du plugin
|
||||
2. Clique sur **"Installer maintenant"**
|
||||
3. Clique sur **"Activer sur le réseau"** *(pas juste "Activer")*
|
||||
|
||||
**Plugins à installer dans cet ordre :**
|
||||
|
||||
- [ ] **Kadence Theme** *(thème de base)*
|
||||
- Menu gauche → **Apparence** → **Thèmes** → **Ajouter**
|
||||
- Recherche : `Kadence`
|
||||
- Clique **"Installer"** puis **"Activer"**
|
||||
|
||||
- [ ] **Kadence Blocks** *(constructeur de pages)*
|
||||
- Extensions → Ajouter → Recherche : `Kadence Blocks`
|
||||
- Installer → Activer sur le réseau
|
||||
|
||||
- [ ] **Rank Math SEO** *(référencement Google)*
|
||||
- Extensions → Ajouter → Recherche : `Rank Math`
|
||||
- Installer → Activer sur le réseau
|
||||
- Suivre l'assistant de configuration qui s'ouvre automatiquement
|
||||
|
||||
- [ ] **WP Super Cache** *(performances — gratuit)*
|
||||
- Extensions → Ajouter → Recherche : `WP Super Cache`
|
||||
- Installer → Activer sur le réseau
|
||||
|
||||
- [ ] **Wordfence Security** *(protection contre les hackers)*
|
||||
- Extensions → Ajouter → Recherche : `Wordfence`
|
||||
- Installer → Activer sur le réseau
|
||||
- Il te demandera un email pour les alertes → entre le tien
|
||||
|
||||
- [ ] **UpdraftPlus** *(sauvegardes automatiques)*
|
||||
- Extensions → Ajouter → Recherche : `UpdraftPlus`
|
||||
- Installer → Activer sur le réseau
|
||||
- Réglages → UpdraftPlus → Onglet "Réglages"
|
||||
- Fréquence : **Quotidien** (fichiers) / **Quotidien** (BDD)
|
||||
- Destination : **Google Drive** ou **Dropbox** (connexion en 2 clics)
|
||||
|
||||
- [ ] **NS Cloner** *(dupliquer des sites — gratuit)*
|
||||
- Extensions → Ajouter → Recherche : `NS Cloner`
|
||||
- Installer → Activer sur le réseau
|
||||
- *(Sert à créer rapidement un nouveau site client depuis un template)*
|
||||
|
||||
- [ ] **WPForms Lite** *(formulaire de contact)*
|
||||
- Extensions → Ajouter → Recherche : `WPForms`
|
||||
- Installer → Activer sur le réseau
|
||||
|
||||
---
|
||||
|
||||
### 2.7 — Structure des sous-sites
|
||||
```
|
||||
hooklab.eu ← Site principal (vitrine HookLab)
|
||||
cyprien.hooklab.eu ← Site de Cyprien (maçon)
|
||||
dupont.hooklab.eu ← Site de M. Dupont (plombier)
|
||||
martin.hooklab.eu ← Site de M. Martin (paysagiste)
|
||||
```
|
||||
|
||||
> DNS requis (Phase 6) :
|
||||
> `Type A *.hooklab.eu → 51.83.162.147` (wildcard — un seul enregistrement pour tous)
|
||||
|
||||
---
|
||||
|
||||
### 2.8 — Créer le premier sous-site client (exemple)
|
||||
|
||||
1. Administration réseau → **"Sites"** → **"Ajouter"**
|
||||
2. Remplis le formulaire :
|
||||
- **Adresse du site** : `cyprien` *(donnera `cyprien.hooklab.eu`)*
|
||||
- **Titre du site** : `Cyprien Maçonnerie`
|
||||
- **Langue** : Français
|
||||
- **Email admin** : ton email *(ou celui du client)*
|
||||
3. Clique **"Ajouter un site"**
|
||||
4. Le site est créé instantanément
|
||||
|
||||
---
|
||||
|
||||
### 2.9 — Donner accès à un client
|
||||
|
||||
> Le client peut modifier son site sans toucher aux autres.
|
||||
|
||||
1. Administration réseau → **"Sites"** → clique sur le nom du site client
|
||||
2. Clique sur **"Utilisateurs"**
|
||||
3. Clique **"Ajouter un utilisateur existant"** ou **"Ajouter un nouvel utilisateur"**
|
||||
4. Entre l'email du client
|
||||
5. Rôle : choisir **"Administrateur"** *(il ne peut gérer QUE son sous-site)*
|
||||
6. Clique **"Ajouter un utilisateur"**
|
||||
7. Le client reçoit un email avec ses identifiants
|
||||
|
||||
> **Le client voit uniquement son propre tableau de bord.**
|
||||
> Il ne peut pas accéder aux autres sites ni installer des thèmes/plugins.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — Design avec Kadence `(toi)`
|
||||
|
||||
> Kadence est un constructeur de pages visuel — tu glisses-dépose des blocs.
|
||||
> Pas besoin de coder.
|
||||
|
||||
### 3.1 — Configurer le thème Kadence (couleurs + polices)
|
||||
|
||||
1. Va sur un sous-site client : `cyprien.hooklab.eu/wp-admin`
|
||||
2. Menu gauche → **"Apparence"** → **"Personnaliser"**
|
||||
3. Un panneau s'ouvre à gauche, l'aperçu du site à droite
|
||||
|
||||
**Couleurs :**
|
||||
- Clique **"Général"** → **"Couleurs"**
|
||||
- **Couleur principale** : entre le code couleur HookLab (ex: `#FF6B2B`)
|
||||
- **Couleur secondaire** : entre la deuxième couleur
|
||||
- Les changements s'appliquent partout en temps réel
|
||||
|
||||
**Polices :**
|
||||
- Clique **"Typographie"**
|
||||
- **Police des titres** : choisis dans la liste (Google Fonts incluses)
|
||||
- **Police du corps de texte** : idem
|
||||
- Taille recommandée : 16-18px pour le corps de texte
|
||||
|
||||
**Logo :**
|
||||
- Clique **"En-tête"** → **"Logo"**
|
||||
- Clique **"Sélectionner le logo"** → téléverse le logo du client
|
||||
- Clique **"Publier"** en haut pour sauvegarder
|
||||
|
||||
---
|
||||
|
||||
### 3.2 — Créer la page d'accueil avec Kadence Blocks
|
||||
|
||||
**Créer une nouvelle page :**
|
||||
1. Menu gauche → **"Pages"** → **"Ajouter"**
|
||||
2. Titre : `Accueil`
|
||||
3. En haut à droite, clique sur les **3 points** → **"Éditeur de code"** si tu vois du code
|
||||
*(sinon tu es déjà en mode visuel — c'est bien)*
|
||||
|
||||
**Utiliser les modèles Kadence (le plus rapide) :**
|
||||
1. Dans l'éditeur, clique sur le bouton **"Kadence"** (icône bleue en haut)
|
||||
2. → **"Bibliothèque de designs"**
|
||||
3. Choisis un modèle de page d'accueil pour artisan
|
||||
4. Clique **"Importer"** → la page se remplit automatiquement
|
||||
5. Tu n'as plus qu'à remplacer les textes et photos
|
||||
|
||||
**Sinon, construire bloc par bloc :**
|
||||
|
||||
**Bloc HERO (grande bannière en haut) :**
|
||||
1. Clique **"+"** pour ajouter un bloc → cherche **"Row Layout"** (Kadence)
|
||||
2. Choisis la disposition : 1 colonne pleine largeur
|
||||
3. Ajoute dedans un bloc **"Titre avancé"** (Kadence Advanced Text)
|
||||
4. Tape : `Maçon à [Ville] — Devis Gratuit`
|
||||
5. En dessous, ajoute un bloc **"Bouton avancé"** (Kadence Advanced Button)
|
||||
6. Texte du bouton : `Demander un devis gratuit`
|
||||
7. Lien du bouton : `#contact` *(pointe vers la section contact plus bas)*
|
||||
|
||||
**Section SERVICES (3 colonnes) :**
|
||||
1. Ajoute un bloc **"Row Layout"** → choisis **3 colonnes égales**
|
||||
2. Dans chaque colonne, ajoute :
|
||||
- Une icône (bloc "Icon" Kadence)
|
||||
- Un titre (ex: "Rénovation de façade")
|
||||
- Un texte court (2-3 lignes de description)
|
||||
|
||||
**Section AVIS CLIENTS :**
|
||||
1. Ajoute un bloc **"Row Layout"** → fond de couleur légèrement grisé
|
||||
2. Ajoute un bloc **"Témoignage"** (Kadence Testimonial)
|
||||
3. Remplis : nom client, texte de l'avis, note (étoiles)
|
||||
4. Duplique le bloc pour avoir 3 avis côte à côte
|
||||
|
||||
**Section CONTACT :**
|
||||
1. Ajoute un bloc **"Row Layout"**
|
||||
2. Ajoute le bloc **"WPForms"** et sélectionne le formulaire de contact
|
||||
3. À côté (colonne de droite), ajoute les coordonnées :
|
||||
- Téléphone (bloc Titre)
|
||||
- Adresse (bloc Paragraphe)
|
||||
- Horaires
|
||||
|
||||
**Publier la page :**
|
||||
1. En haut à droite : clique **"Publier"**
|
||||
2. Menu gauche → **"Réglages"** → **"Lecture"**
|
||||
3. **"La page d'accueil affiche"** → sélectionne **"Une page statique"**
|
||||
4. **"Page d'accueil"** → choisis `Accueil`
|
||||
5. Clique **"Enregistrer les modifications"**
|
||||
|
||||
---
|
||||
|
||||
### 3.3 — Créer les autres pages
|
||||
|
||||
> Même procédure pour chaque page. Voici le contenu minimum.
|
||||
|
||||
**Page "À propos" :**
|
||||
- Photo du client (artisan au travail)
|
||||
- Son histoire / ses années d'expérience
|
||||
- Ses certifications / qualifications (RGE, Qualibat, etc.)
|
||||
- Pourquoi lui plutôt qu'un autre
|
||||
|
||||
**Page "Réalisations" :**
|
||||
- Galerie de photos (bloc Galerie WordPress ou Kadence Gallery)
|
||||
- Catégories par type de travaux
|
||||
- Chaque photo avec une courte légende
|
||||
|
||||
**Page "Contact" :**
|
||||
- Formulaire WPForms (nom, email, téléphone, message, type de travaux)
|
||||
- Adresse + carte Google Maps intégrée
|
||||
*(Bloc HTML → colle le code d'intégration Google Maps)*
|
||||
- Numéro de téléphone cliquable (important sur mobile)
|
||||
|
||||
**Page "Devis gratuit" :**
|
||||
- Formulaire WPForms plus détaillé
|
||||
- Champs : type de travaux, surface, ville, description, photos à joindre
|
||||
- Message de confirmation : "Réponse sous 24h"
|
||||
|
||||
**Pages légales obligatoires :**
|
||||
- **"Mentions légales"** — nom, adresse, SIRET du client
|
||||
- **"Politique de confidentialité"** — WordPress en génère une automatiquement
|
||||
*(Menu → Réglages → Confidentialité)*
|
||||
|
||||
---
|
||||
|
||||
### 3.4 — Configurer le header et footer
|
||||
|
||||
**Header (en-tête) :**
|
||||
1. Apparence → Personnaliser → **"En-tête"**
|
||||
2. Ajoute le logo à gauche
|
||||
3. Menu de navigation à droite : Accueil / Services / Réalisations / Contact
|
||||
4. Optionnel : numéro de téléphone visible dans le header (très utile sur mobile)
|
||||
|
||||
**Créer le menu de navigation :**
|
||||
1. Apparence → **"Menus"**
|
||||
2. Clique **"Créer un menu"** → nom : `Menu principal`
|
||||
3. Ajoute les pages : Accueil, Réalisations, Contact, Devis gratuit
|
||||
4. Clique **"Enregistrer le menu"**
|
||||
5. En bas, coche **"Menu principal"** pour l'emplacement
|
||||
|
||||
**Footer (pied de page) :**
|
||||
1. Apparence → Personnaliser → **"Pied de page"**
|
||||
2. Colonne 1 : logo + slogan
|
||||
3. Colonne 2 : liens rapides (mêmes que le menu)
|
||||
4. Colonne 3 : coordonnées + réseaux sociaux
|
||||
5. Barre du bas : "© 2025 Nom du client — Mentions légales"
|
||||
|
||||
---
|
||||
|
||||
### 3.5 — Vérifier le rendu mobile
|
||||
|
||||
1. Dans le **Personnaliseur** (Apparence → Personnaliser)
|
||||
2. En bas à gauche, clique sur l'icône **téléphone** 📱
|
||||
3. Vérifie que tout est lisible et bien aligné
|
||||
4. Le texte ne doit pas être trop petit
|
||||
5. Les boutons doivent être facilement cliquables au doigt
|
||||
6. Clique **"Publier"** quand tout est bon
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4 — Blog + SEO `(toi)`
|
||||
|
||||
> Le blog sert à attirer des visiteurs depuis Google.
|
||||
> Chaque article bien écrit = plus de clients potentiels.
|
||||
|
||||
### 4.1 — Configurer Rank Math SEO
|
||||
|
||||
1. Menu gauche → **"Rank Math"** → **"Assistant de configuration"**
|
||||
2. Suis l'assistant :
|
||||
- **Type de site** : Site d'une entreprise locale
|
||||
- **Nom du site** : nom du client (ex: "Cyprien Maçonnerie")
|
||||
- **Logo** : téléverse le logo
|
||||
3. À l'étape **"Sitemap"** : laisse tout activé par défaut → Continuer
|
||||
4. À l'étape **"Optimisation"** : clique **"Analyse SEO"** pour voir le score actuel
|
||||
5. Clique **"Terminer"**
|
||||
|
||||
**Configurer les informations locales (important pour artisans) :**
|
||||
1. Rank Math → **"Réglages"** → **"Recherche locale"**
|
||||
2. **Type** : Entreprise locale
|
||||
3. **Nom** : nom complet du client
|
||||
4. **Adresse** : adresse complète
|
||||
5. **Téléphone** : numéro du client
|
||||
6. **Horaires** : jours et heures d'ouverture
|
||||
7. Clique **"Enregistrer les modifications"**
|
||||
> Cela génère automatiquement les données Schema.org — Google affiche les infos directement dans les résultats de recherche.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 — Créer des articles de blog SEO
|
||||
|
||||
> Chaque article doit cibler une recherche précise que les gens font sur Google.
|
||||
|
||||
**Exemples d'articles à créer pour un maçon à Lyon :**
|
||||
```
|
||||
"Combien coûte une rénovation de façade à Lyon ?"
|
||||
"Maçon à Lyon : comment choisir le bon artisan ?"
|
||||
"Ravalement de façade Lyon : prix et délais en 2025"
|
||||
"Extension de maison à Lyon : maçon ou constructeur ?"
|
||||
```
|
||||
|
||||
**Créer un article :**
|
||||
1. Menu gauche → **"Articles"** → **"Ajouter"**
|
||||
2. **Titre** : la question ou le mot-clé exact (ex: "Maçon Lyon — Devis Gratuit en 24h")
|
||||
3. **Contenu** : minimum 600 mots, structuré avec des titres H2 et H3
|
||||
4. **Image mise en avant** : une vraie photo du chantier du client
|
||||
5. En bas de page, le panneau **Rank Math** apparaît :
|
||||
- **Mot-clé principal** : tape le mot-clé ciblé (ex: "maçon Lyon")
|
||||
- Rank Math donne un score sur 100 — vise au moins 70
|
||||
- Il te dit exactement quoi corriger (titre trop court, pas assez de mots-clés, etc.)
|
||||
6. Clique **"Publier"**
|
||||
|
||||
**Structure d'un bon article (exemple) :**
|
||||
```
|
||||
H1 : Maçon à Lyon — Cyprien Maçonnerie, devis gratuit sous 24h
|
||||
H2 : Nos services de maçonnerie à Lyon
|
||||
H3 : Rénovation de façade
|
||||
H3 : Construction de mur
|
||||
H3 : Extension de maison
|
||||
H2 : Pourquoi choisir Cyprien Maçonnerie ?
|
||||
H2 : Zone d'intervention
|
||||
H2 : Demander un devis gratuit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 — Connecter Google Search Console
|
||||
|
||||
> Google Search Console dit à Google que ton site existe et le surveille.
|
||||
|
||||
1. Va sur `https://search.google.com/search-console`
|
||||
2. Connecte-toi avec un compte Google
|
||||
3. Clique **"Ajouter une propriété"**
|
||||
4. Entre l'URL : `https://cyprien.hooklab.eu`
|
||||
5. Méthode de vérification : choisis **"Google Analytics"** ou **"Balise HTML"**
|
||||
6. Avec Rank Math, c'est automatique :
|
||||
- Rank Math → Général → Google Search Console → clique **"Connecter"**
|
||||
- Suis les étapes de connexion Google
|
||||
7. Une fois vérifié, clique **"Demander l'indexation"** sur les pages importantes
|
||||
8. Va dans **"Sitemaps"** → entre : `sitemap_index.xml` → clique **"Envoyer"**
|
||||
|
||||
---
|
||||
|
||||
### 4.4 — Optimiser chaque page existante pour le SEO
|
||||
|
||||
> Pour chaque page publiée (Accueil, Contact, Réalisations...) :
|
||||
|
||||
1. Ouvre la page en édition
|
||||
2. En bas, panneau Rank Math → entre le **mot-clé principal** de la page
|
||||
3. Clique sur **"Modifier le snippet"** pour personnaliser :
|
||||
- **Titre SEO** : "Maçon Lyon — Cyprien Maçonnerie | Devis Gratuit" *(60 caractères max)*
|
||||
- **Meta description** : "Maçon à Lyon depuis 15 ans. Rénovation de façade, extension, gros œuvre. Devis gratuit sous 24h. Appelez le 06 XX XX XX XX." *(155 caractères max)*
|
||||
4. Score Rank Math : vise 70+
|
||||
5. Clique **"Mettre à jour"**
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5 — Template client réutilisable `(toi)`
|
||||
|
||||
> Une fois le premier site fait, les suivants sont créés en 1h au lieu de 5h.
|
||||
|
||||
### 5.1 — Sauvegarder le premier site comme template
|
||||
|
||||
1. Sur le premier site client terminé (ex: `cyprien.hooklab.eu`)
|
||||
2. Dans l'admin de CE sous-site → Extensions → **"NS Cloner"**
|
||||
3. Clique **"Clone Site"**
|
||||
4. **Site source** : `cyprien.hooklab.eu`
|
||||
5. **Nouveau sous-domaine** : `template` *(donne `template.hooklab.eu`)*
|
||||
6. **Nouveau titre** : `TEMPLATE ARTISAN`
|
||||
7. Clique **"Cloner"** → le site est dupliqué en 30 secondes
|
||||
|
||||
> Ce site `template.hooklab.eu` est ton modèle de base.
|
||||
> **Ne le publie pas** — c'est juste pour dupliquer.
|
||||
|
||||
---
|
||||
|
||||
### 5.2 — Créer un nouveau site client depuis le template
|
||||
|
||||
**Quand tu signes un nouveau client :**
|
||||
|
||||
**Étape 1 — Dupliquer le template**
|
||||
1. Administration réseau → NS Cloner
|
||||
2. Source : `template.hooklab.eu`
|
||||
3. Nouveau sous-domaine : `martin` *(→ `martin.hooklab.eu`)*
|
||||
4. Nouveau titre : `Martin Paysagiste`
|
||||
5. Clique **"Cloner"**
|
||||
|
||||
**Étape 2 — Personnaliser le site**
|
||||
1. Va sur `martin.hooklab.eu/wp-admin`
|
||||
2. Apparence → Personnaliser → **change les couleurs** selon le client
|
||||
3. **Remplace le logo** par le logo du client
|
||||
4. Édite chaque page → remplace textes et photos
|
||||
|
||||
**Ce qu'il faut changer sur chaque page :**
|
||||
```
|
||||
- Nom de l'artisan partout dans les textes
|
||||
- Ville(s) d'intervention
|
||||
- Services proposés (différents d'un artisan à l'autre)
|
||||
- Photos (demande les vraies photos du client)
|
||||
- Numéro de téléphone
|
||||
- Email de contact
|
||||
- Adresse et zone d'intervention
|
||||
- Avis clients (demande 3 vrais avis au client)
|
||||
- Prix indicatifs si le client l'accepte
|
||||
```
|
||||
|
||||
**Étape 3 — Configurer Rank Math pour ce client**
|
||||
1. Rank Math → Réglages → Recherche locale
|
||||
2. Mets les infos de Martin Paysagiste (nom, adresse, téléphone)
|
||||
|
||||
**Étape 4 — Créer l'accès client**
|
||||
1. Administration réseau → Sites → `martin.hooklab.eu` → Utilisateurs
|
||||
2. Ajouter l'email de Martin, rôle : Administrateur
|
||||
3. Martin reçoit un email avec ses identifiants
|
||||
4. Il peut se connecter sur `martin.hooklab.eu/wp-login.php`
|
||||
|
||||
**Étape 5 — Connecter son domaine (si le client a le sien)**
|
||||
> Voir Phase 6.2
|
||||
|
||||
---
|
||||
|
||||
### 5.3 — Checklist de livraison client
|
||||
|
||||
```
|
||||
Avant de livrer le site au client :
|
||||
- [ ] Toutes les pages publiées et vérifiées
|
||||
- [ ] Formulaire de contact testé (envoie un vrai message)
|
||||
- [ ] Email de réception du formulaire vérifié
|
||||
- [ ] Numéro de téléphone cliquable sur mobile
|
||||
- [ ] Site testé sur mobile ET desktop
|
||||
- [ ] HTTPS activé (cadenas vert dans le navigateur)
|
||||
- [ ] Score PageSpeed > 80 (test sur pagespeed.web.dev)
|
||||
- [ ] Rank Math configuré sur toutes les pages
|
||||
- [ ] Google Search Console connecté
|
||||
- [ ] Sitemap soumis à Google
|
||||
- [ ] Mentions légales présentes
|
||||
- [ ] Politique de confidentialité présente
|
||||
- [ ] Accès client créé et testé
|
||||
```
|
||||
|
||||
**Formation client (15 min en visio) :**
|
||||
```
|
||||
Montrer :
|
||||
1. Comment se connecter (wp-login.php)
|
||||
2. Comment modifier un texte (cliquer sur la page → modifier)
|
||||
3. Comment changer une photo
|
||||
4. Comment voir les messages du formulaire de contact
|
||||
5. Comment créer un article de blog
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6 — DNS `(toi + moi)`
|
||||
|
||||
> Le DNS c'est comme un annuaire téléphonique :
|
||||
> il dit aux navigateurs "pour hooklab.eu, va à l'IP 51.83.162.147".
|
||||
|
||||
### 6.1 — Configurer le DNS de hooklab.eu (OVH)
|
||||
|
||||
> **A faire en dernier**, une fois que tout est prêt sur WordPress.
|
||||
> Avant ça, utilise le hosts file pour tester (voir étape 2.5).
|
||||
> La bascule prend 5 min et le site Vercel reste en ligne jusqu'au moment où tu valides.
|
||||
|
||||
**Connexion à l'espace OVH :**
|
||||
1. Va sur `https://www.ovhcloud.com/fr/`
|
||||
2. Clique **"Espace client"** en haut à droite
|
||||
3. Connecte-toi avec ton compte OVH
|
||||
|
||||
**Accéder à la zone DNS :**
|
||||
1. Menu gauche → **"Web Cloud"**
|
||||
2. Clique sur **"Noms de domaine"**
|
||||
3. Clique sur `hooklab.eu`
|
||||
4. Clique sur l'onglet **"Zone DNS"**
|
||||
|
||||
**Ajouter les enregistrements :**
|
||||
|
||||
Clique **"Ajouter une entrée"** et répète pour chaque ligne :
|
||||
|
||||
| Type | Sous-domaine | Cible | TTL |
|
||||
|------|-------------|-------|-----|
|
||||
| A | *(vide = hooklab.eu)* | `51.83.162.147` | 3600 |
|
||||
| A | `*` | `51.83.162.147` | 3600 |
|
||||
| A | `www` | `51.83.162.147` | 3600 |
|
||||
|
||||
> Le `*` (wildcard) couvre automatiquement tous les sous-domaines :
|
||||
> `cyprien.hooklab.eu`, `martin.hooklab.eu`, etc.
|
||||
> **Un seul enregistrement pour tous les clients.**
|
||||
|
||||
**Valider :**
|
||||
- Clique **"Valider"** après chaque entrée
|
||||
- La propagation DNS prend **5 à 30 minutes** (parfois jusqu'à 24h)
|
||||
|
||||
---
|
||||
|
||||
### 6.2 — Domaine custom pour un client (optionnel)
|
||||
|
||||
> Si un client veut son propre domaine (ex: `martin-paysagiste.fr`)
|
||||
> au lieu de `martin.hooklab.eu`.
|
||||
|
||||
**Dans la zone DNS du domaine client (chez son registrar) :**
|
||||
|
||||
| Type | Sous-domaine | Cible |
|
||||
|------|-------------|-------|
|
||||
| A | *(vide)* | `51.83.162.147` |
|
||||
| A | `www` | `51.83.162.147` |
|
||||
|
||||
**Dans WordPress (Network Admin) :**
|
||||
1. Administration réseau → Sites → `martin.hooklab.eu`
|
||||
2. Clique **"Modifier"**
|
||||
3. Change **"Adresse du site"** → `https://martin-paysagiste.fr`
|
||||
4. Clique **"Enregistrer"**
|
||||
|
||||
**Activer le HTTPS pour ce domaine :**
|
||||
```bash
|
||||
# Dans le terminal SSH :
|
||||
ssh ubuntu@51.83.162.147
|
||||
sudo wo site update hooklab.eu --letsencrypt
|
||||
# WordOps détecte automatiquement les nouveaux domaines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.3 — Activer le HTTPS (cadenas vert)
|
||||
|
||||
```bash
|
||||
# Dans le terminal SSH, une seule commande :
|
||||
sudo wo site update hooklab.eu --letsencrypt
|
||||
```
|
||||
|
||||
> WordOps génère automatiquement les certificats SSL pour :
|
||||
> - `hooklab.eu`
|
||||
> - `*.hooklab.eu` (tous les sous-domaines clients)
|
||||
>
|
||||
> Le certificat se renouvelle automatiquement tous les 90 jours.
|
||||
|
||||
---
|
||||
|
||||
### 6.4 — Vérifications finales après DNS
|
||||
|
||||
- [ ] Ouvre `https://hooklab.eu` → doit afficher ton site (avec cadenas vert)
|
||||
- [ ] Ouvre `https://cyprien.hooklab.eu` → doit afficher le site de Cyprien
|
||||
- [ ] Teste le formulaire de contact → vérifie que tu reçois l'email
|
||||
- [ ] Teste depuis un téléphone → le site doit être lisible et rapide
|
||||
- [ ] Va sur `https://pagespeed.web.dev/` → entre l'URL → score doit être > 80
|
||||
- [ ] Va sur `https://search.google.com/search-console` → demande l'indexation
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif
|
||||
|
||||
| Phase | Responsable | Durée estimée |
|
||||
|-------|------------|---------------|
|
||||
| 1 — Serveur OVH | toi + moi | 1h |
|
||||
| 2 — WordPress Multisite | toi + moi | 1-2h |
|
||||
| 3 — Design Kadence | toi | 3-5h par template |
|
||||
| 4 — Blog + SEO | toi | ongoing |
|
||||
| 5 — Template client | toi | 1h par client |
|
||||
| 6 — Migration DNS | toi + moi | 30min |
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-02-24*
|
||||
@@ -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'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'ils seront disponibles.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Public") 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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(" "),
|
||||
});
|
||||
}
|
||||
@@ -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." });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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é." });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
117
app/assainissement/page.tsx
Normal 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'assainissement — OBC Maçonnerie intervient dans les règles de l'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'<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'étude de votre terrain jusqu'à la réception des travaux.
|
||||
</p>
|
||||
<p>
|
||||
Que vous ayez besoin d'une <strong className="text-text">mise aux normes suite à un contrôle SPANC</strong>, d'une nouvelle installation pour une construction neuve ou d'une réhabilitation de l'existant, OBC Maçonnerie intervient à Orchies, Douai, Valenciennes, Mouchin et dans toutes les communes avoisinantes.
|
||||
</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
221
app/blog/[slug]/page.tsx
Normal 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
167
app/blog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
137
app/cgv/page.tsx
137
app/cgv/page.tsx
@@ -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'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 “HookLab” 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'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'une durée de 8 semaines, comprenant :
|
||||
Toute prestation fait l'objet d'un devis préalable gratuit. Le devis est établi après visite du chantier. Il est valable 30 jours à compter de sa date d'é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'exécution</h2>
|
||||
<p>
|
||||
Les délais d'exécution sont communiqués à titre indicatif dans le devis. OBC Maçonnerie s'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'accès à une communauté privée d'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'ouvrage pendant 10 ans.</li>
|
||||
<li><strong className="text-text">Garantie biennale</strong> : couvre les éléments d'é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'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'OBC Maçonnerie ne saurait être engagée pour des dommages résultant d'une utilisation non conforme des ouvrages réalisés ou d'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'accès au programme est soumis à la validation d'un formulaire de candidature. L'éditeur
|
||||
se réserve le droit de refuser toute candidature sans avoir à en justifier les raisons. En cas de
|
||||
refus, aucun paiement n'est effectué.
|
||||
La réception des travaux est prononcée contradictoirement entre OBC Maçonnerie et le client. Elle fait l'objet d'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'article L221-18 du Code de la consommation, le client dispose d'un délai de
|
||||
<strong className="text-white"> 14 jours</strong> à compter de la date d'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'accès au programme est personnel et non cessible. Le client s'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'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'implication et des actions de chaque participant. Aucune garantie de revenus n'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'éditeur.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Conformément à l'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'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'entreprise</li>
|
||||
<li>Ville d'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'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'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'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'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'hébergement des contenus (textes/photos) de votre futur site.</li>
|
||||
<li><strong className="text-white">Vercel :</strong> Pour l'hébergement technique du site internet.</li>
|
||||
<li><strong className="text-white">Resend :</strong> Pour l'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'un droit d'accès, de rectification,
|
||||
d'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'un droit d'accès, de rectification, d'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'audience anonymes pour améliorer nos services. Aucune donnée n'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
24
app/construction-maison-douai/page.tsx
Normal file
24
app/construction-maison-douai/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
app/construction-maison-orchies/page.tsx
Normal file
24
app/construction-maison-orchies/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
app/construction-maison-valenciennes/page.tsx
Normal file
24
app/construction-maison-valenciennes/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
147
app/construction-maison/page.tsx
Normal file
147
app/construction-maison/page.tsx
Normal 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'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
129
app/contact/page.tsx
Normal 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: "Lun–Ven 7h–19h",
|
||||
},
|
||||
{
|
||||
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'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
101
app/creation-acces/page.tsx
Normal 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'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'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'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
117
app/demolition/page.tsx
Normal 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'amiante, de plomb ou d'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
186
app/layout.tsx
186
app/layout.tsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
app/macon-flines-lez-raches/page.tsx
Normal file
24
app/macon-flines-lez-raches/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
app/macon-mouchin/page.tsx
Normal file
23
app/macon-mouchin/page.tsx
Normal 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.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
app/macon-saint-amand-les-eaux/page.tsx
Normal file
24
app/macon-saint-amand-les-eaux/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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é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élé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é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écrivez votre projet (optionnel)</label>
|
||||
<textarea
|
||||
placeholder="Surface, type de travaux, délais souhaité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"
|
||||
>
|
||||
← 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 ?</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← HookLab
|
||||
</Link>
|
||||
<span className="text-white/30">|</span>
|
||||
<span className="text-white font-bold text-sm">
|
||||
[Votre Entreprise] — <span className="text-orange">Maç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çonnerie & Extension de Maison à{" "}
|
||||
<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çon depuis 10 ans. Spécialiste agrandissement, gros œuvre
|
||||
et rénovation dans le Pévèle.
|
||||
</p>
|
||||
|
||||
<p className="text-white/40 text-sm mb-8">
|
||||
Garantie décennale · Devis gratuit · 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çonnerie</span> dans le Nord
|
||||
</h2>
|
||||
<p className="text-text-light text-base md:text-lg max-w-2xl mx-auto">
|
||||
Du gros œuvre à la finition, nous intervenons sur tous types de projets
|
||||
pour les particuliers et professionnels du Pévè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é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éalisé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é
|
||||
</span>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-4">
|
||||
Votre maçon de proximité dans le <span className="text-orange">Nord (59)</span>
|
||||
</h2>
|
||||
<p className="text-text-light text-base leading-relaxed mb-4">
|
||||
Basés à <strong className="text-navy">Saméon</strong>, nous sommes rapidement
|
||||
sur vos chantiers à <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çonnerie à{" "}
|
||||
<strong>Orchies</strong>, <strong>Cysoing</strong>, <strong>Saint-Amand-les-Eaux</strong>,{" "}
|
||||
<strong>Saméon</strong>, <strong>Landas</strong>, <strong>Beuvry-la-Forêt</strong>,{" "}
|
||||
<strong>Nomain</strong>, <strong>Genech</strong>, <strong>Templeuve</strong> et dans
|
||||
tout le secteur du Pévè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’artisan derriè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çon passionné.</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 à la fin du chantier. Quand vous m’appelez, c’est moi qui réponds.
|
||||
</p>
|
||||
<p className="text-white/60 text-base leading-relaxed mb-6">
|
||||
Après 10 ans à bâtir dans le Pévèle, je connais chaque type de terrain,
|
||||
chaque contrainte locale. Mon engagement : un travail propre, dans les délais,
|
||||
au prix annoncé. 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é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électionnez votre type de projet. Devis détaillé 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èle HookLab
|
||||
</p>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white mb-4">
|
||||
Vous voulez le même site pour votre entreprise ?
|
||||
</h2>
|
||||
<p className="text-white/80 text-sm mb-6">
|
||||
Imaginez votre logo, vos photos de chantier et votre numéro à 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ées</h4>
|
||||
<ul className="space-y-1 text-white/50">
|
||||
<li className="font-semibold text-white">[Votre Entreprise]</li>
|
||||
<li>Saméon, 59310</li>
|
||||
<li>Té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çon Orchies</li>
|
||||
<li>Maçon Cysoing</li>
|
||||
<li>Maçon Saint-Amand-les-Eaux</li>
|
||||
<li>Extension maison Pévèle</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-3">Lé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égales
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/confidentialite" className="text-white/50 hover:text-white text-sm transition-colors">
|
||||
Politique de confidentialité
|
||||
</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éé par{" "}
|
||||
<Link href="/" className="text-orange hover:underline">HookLab</Link>{" "}
|
||||
— Création de sites internet pour artisans du bâtiment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Floating mobile CTA */}
|
||||
<MaconClient type="floating" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<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-10 text-white/40 hover:text-white text-sm transition-colors group"
|
||||
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'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'économie numérique (LCEN),
|
||||
il est précisé aux utilisateurs du site <strong>hooklab.eu</strong> l'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'é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'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'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'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 été 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'auteur
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-semibold text-white mb-2">Contenu HookLab :</p>
|
||||
<p>
|
||||
L'ensemble de ce site relève de la législation française et internationale sur le droit d'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'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 "Réalisations" ou "Portfolio" appartiennent à leurs propriétaires respectifs.
|
||||
Ils sont utilisés sur ce site à titre d'illustration du savoir-faire de HookLab, avec l'accord des clients concernés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="pt-2">
|
||||
Toute exploitation non autorisée du site ou de l'un quelconque des éléments qu'il contient sera considérée comme constitutive d'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'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'éditeur ne pourra être tenu responsable des dommages directs et indirects causés au matériel de l'utilisateur lors de l'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'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'autres sites (partenaires, outils, informations).
|
||||
Cependant, Enguerrand OZANO n'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'ensemble du contenu de ce site (textes, visuels, structure) est la propriété d'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'utilisation du site <strong>hooklab.eu</strong> est soumis au droit français.
|
||||
En cas de litige entre professionnels (B2B), et à défaut d'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'un droit d'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'accueil</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
548
app/page.tsx
548
app/page.tsx
@@ -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'é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 & 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'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'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'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'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'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">
|
||||
“{t.texte}”
|
||||
</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
125
app/partenaires/page.tsx
Normal 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'artisans de confiance, OBC Maçonnerie coordonne l'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'avez qu'un seul contact — lui — pour piloter l'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 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] — <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’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 · Réalisation · 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’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’agrément à l’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’espaces verts
|
||||
</h3>
|
||||
<p className="text-white/70 mb-5 text-sm">
|
||||
Jardins, terrasses, allées, bassins — 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’espaces verts
|
||||
</h3>
|
||||
<p className="text-white/70 mb-5 text-sm">
|
||||
Taille, tonte, élagage, débroussaillage — 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’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’<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’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’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 · 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’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’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">
|
||||
© 2025 [Votre Entreprise] — 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 — Ce site peut être le vôtre
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 — 06 04 40 81 57
|
||||
</a>
|
||||
<p className="text-white/40 text-[10px] text-center mt-1">Devis gratuit · 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’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> — {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 · 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> — {answers[1]} — {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 — 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"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← HookLab
|
||||
</Link>
|
||||
<span className="text-white/20">|</span>
|
||||
<span className="text-white font-bold text-sm">
|
||||
[Votre Entreprise] — <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 — 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 d’eau, 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 · Orchies · Valenciennes · Denain · Saint-Amand · 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 qu’on 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]">d’intervention</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.
|
||||
C’est 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
158
app/realisations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
app/renovation-maison-douai/page.tsx
Normal file
24
app/renovation-maison-douai/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
app/renovation-maison-orchies/page.tsx
Normal file
24
app/renovation-maison-orchies/page.tsx
Normal 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
124
app/renovation/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
141
app/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 été créé avec succès. Connecte-toi pour accéder au panel d'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
app/sitemap.ts
120
app/sitemap.ts
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function CookieBanner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const consent = localStorage.getItem("hooklab_cookie_consent");
|
||||
const consent = localStorage.getItem("obc_cookie_consent");
|
||||
if (!consent) {
|
||||
// Small delay so it doesn't flash on page load
|
||||
const timer = setTimeout(() => setVisible(true), 800);
|
||||
@@ -16,12 +16,12 @@ export default function CookieBanner() {
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem("hooklab_cookie_consent", "accepted");
|
||||
localStorage.setItem("obc_cookie_consent", "accepted");
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleRefuse = () => {
|
||||
localStorage.setItem("hooklab_cookie_consent", "refused");
|
||||
localStorage.setItem("obc_cookie_consent", "refused");
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AdminShellProps {
|
||||
children: React.ReactNode;
|
||||
adminName: string;
|
||||
adminEmail: string;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/admin",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10-2a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
label: "Candidatures",
|
||||
href: "/admin/candidatures",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Cours",
|
||||
href: "/admin/cours",
|
||||
icon: (
|
||||
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Images du site",
|
||||
href: "/admin/images",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminShell({ children, adminName, adminEmail }: AdminShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-dark">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 min-h-screen bg-dark-light border-r border-dark-border p-6 flex flex-col">
|
||||
{/* Logo */}
|
||||
<Link href="/admin" className="flex items-center gap-2 mb-2">
|
||||
<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>
|
||||
<span className="text-xs text-primary font-medium mb-8 ml-10">Admin</span>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? pathname === item.href
|
||||
: pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-white/50 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="border-t border-dark-border my-4" />
|
||||
|
||||
{/* Lien vers le site */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Voir le site
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div className="border-t border-dark-border pt-4 mt-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 gradient-bg rounded-full flex items-center justify-center text-sm font-bold text-white">
|
||||
{(adminName || adminEmail)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{adminName || "Admin"}</p>
|
||||
<p className="text-white/40 text-xs truncate">{adminEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-white/40 hover:text-error text-sm transition-colors cursor-pointer w-full"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 md:p-10 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Card from "@/components/ui/Card";
|
||||
import type { Module, UserProgress } from "@/types/database.types";
|
||||
|
||||
interface ModuleCardProps {
|
||||
module: Module;
|
||||
progress?: UserProgress;
|
||||
}
|
||||
|
||||
const contentTypeIcons: Record<string, React.ReactNode> = {
|
||||
video: (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
pdf: (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
),
|
||||
text: (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
quiz: (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function ModuleCard({ module, progress }: ModuleCardProps) {
|
||||
const isCompleted = progress?.completed;
|
||||
|
||||
return (
|
||||
<Link href={`/formations/${module.id}`}>
|
||||
<Card hover className="relative overflow-hidden group">
|
||||
{/* Status indicator */}
|
||||
{isCompleted && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="w-6 h-6 rounded-full bg-success flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content type + Duration */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${
|
||||
isCompleted
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
{module.content_type && contentTypeIcons[module.content_type]}
|
||||
{module.content_type?.toUpperCase() || "CONTENU"}
|
||||
</span>
|
||||
{module.duration_minutes && (
|
||||
<span className="text-white/30 text-xs">
|
||||
{module.duration_minutes} min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-white font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||
{module.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{module.description && (
|
||||
<p className="text-white/50 text-sm line-clamp-2">
|
||||
{module.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Week badge */}
|
||||
<div className="mt-4 pt-3 border-t border-dark-border">
|
||||
<span className="text-white/30 text-xs">
|
||||
Semaine {module.week_number}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
interface ProgressBarProps {
|
||||
value: number; // 0-100
|
||||
label?: string;
|
||||
showPercentage?: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressBar({
|
||||
value,
|
||||
label,
|
||||
showPercentage = true,
|
||||
}: ProgressBarProps) {
|
||||
const clampedValue = Math.min(100, Math.max(0, value));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(label || showPercentage) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{label && (
|
||||
<span className="text-white/60 text-sm">{label}</span>
|
||||
)}
|
||||
{showPercentage && (
|
||||
<span className="text-white font-medium text-sm">
|
||||
{Math.round(clampedValue)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 bg-dark-lighter rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full gradient-bg rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${clampedValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Profile } from "@/types/database.types";
|
||||
|
||||
interface SidebarProps {
|
||||
user: Profile;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/dashboard",
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
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: "Formations",
|
||||
href: "/formations",
|
||||
icon: (
|
||||
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Profil",
|
||||
href: "/profil",
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar({ user }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-64 min-h-screen bg-dark-light border-r border-dark-border p-6 flex flex-col">
|
||||
{/* Logo */}
|
||||
<Link href="/dashboard" className="flex items-center gap-2 mb-10">
|
||||
<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>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-white/50 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User info + Logout */}
|
||||
<div className="border-t border-dark-border pt-4 mt-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 gradient-bg rounded-full flex items-center justify-center text-sm font-bold text-white">
|
||||
{(user.full_name || user.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
{user.full_name || "Utilisateur"}
|
||||
</p>
|
||||
<p className="text-white/40 text-xs truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-white/40 hover:text-error text-sm transition-colors cursor-pointer w-full"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Deconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ScrollReveal from "@/components/animations/ScrollReveal";
|
||||
|
||||
interface AboutMeProps {
|
||||
images?: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function AboutMe({ images }: AboutMeProps) {
|
||||
const photoUrl = images?.about_photo;
|
||||
|
||||
return (
|
||||
<section id="qui-suis-je" className="py-16 md:py-24 bg-orange relative overflow-hidden" aria-label="Qui suis-je">
|
||||
{/* Subtle pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 right-20 w-40 h-40 border-2 border-white rounded-full" />
|
||||
<div className="absolute bottom-10 left-10 w-60 h-60 border-2 border-white rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
{/* Photo */}
|
||||
<ScrollReveal direction="left">
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-64 h-80 sm:w-72 sm:h-[22rem] rounded-2xl overflow-hidden border-4 border-white/20 shadow-xl">
|
||||
{photoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={photoUrl} alt="Enguerrand Ozano" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-orange-hover flex items-center justify-center">
|
||||
<div className="text-center p-6">
|
||||
<div className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-10 h-10 text-white/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/60 text-sm">Votre photo ici</p>
|
||||
<p className="text-white/40 text-xs mt-1">(modifiable dans Admin > Images)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-navy text-white text-xs font-bold px-4 py-2 rounded-full shadow-lg whitespace-nowrap">
|
||||
Basé à Flines-lez-Raches
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Text */}
|
||||
<ScrollReveal direction="right">
|
||||
<div>
|
||||
<span className="inline-block px-3 py-1.5 bg-white/15 rounded-full text-white text-xs font-semibold mb-4">
|
||||
Votre expert local
|
||||
</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white tracking-[-0.02em] mb-4">
|
||||
Enguerrand Ozano.{" "}
|
||||
<span className="text-navy">Votre voisin à Flines-lez-Raches.</span>
|
||||
</h2>
|
||||
<p className="text-white/90 text-base leading-relaxed mb-6">
|
||||
Oubliez les plateformes téléphoniques à l’autre bout du monde.
|
||||
Je suis ici, dans le Nord (59). Je connais la réalité de vos métiers
|
||||
et vos contraintes géographiques.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-4 mb-6">
|
||||
{[
|
||||
{ strong: "Un interlocuteur unique", text: "C\u2019est moi qui g\u00e8re votre dossier du d\u00e9but \u00e0 la fin." },
|
||||
{ strong: "100% G\u00e9r\u00e9 pour vous", text: "Une fois le site lanc\u00e9, vous n\u2019avez rien \u00e0 faire. Si vous avez une nouvelle photo de chantier, vous me l\u2019envoyez, je la mets en ligne." },
|
||||
{ strong: "Pas de mauvaise surprise", text: "Tout est clair d\u00e8s le d\u00e9part." },
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 bg-white/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white/80 text-base leading-relaxed">
|
||||
<strong className="text-white">{item.strong} :</strong> {item.text}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
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"
|
||||
>
|
||||
Discutons de votre situation
|
||||
<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>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AnnouncementBar() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="relative bg-gradient-to-r from-primary via-primary-hover to-primary text-white text-center py-2 px-10 text-xs sm:text-sm font-medium">
|
||||
<Link href="/candidature" className="hover:underline">
|
||||
<span className="hidden sm:inline">
|
||||
Places limitées — Nouvelle session de formation TikTok Shop ouverte →{" "}
|
||||
<span className="underline font-bold">Candidater</span>
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
Places limitées —{" "}
|
||||
<span className="underline font-bold">Candidater maintenant</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setVisible(false);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/70 hover:text-white cursor-pointer p-1"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
components/marketing/ContactForm.tsx
Normal file
223
components/marketing/ContactForm.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const typesProjets = [
|
||||
"Construction de maison",
|
||||
"Rénovation",
|
||||
"Assainissement",
|
||||
"Création d'accès",
|
||||
"Démolition",
|
||||
"Autre",
|
||||
];
|
||||
|
||||
export default function ContactForm() {
|
||||
const [form, setForm] = useState({
|
||||
nom: "",
|
||||
telephone: "",
|
||||
email: "",
|
||||
typeProjet: "",
|
||||
description: "",
|
||||
budget: "",
|
||||
zone: "",
|
||||
});
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom || !form.telephone || !form.typeProjet) {
|
||||
setError("Merci de renseigner au minimum votre nom, téléphone et type de projet.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setStatus("sending");
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatus("success");
|
||||
setForm({ nom: "", telephone: "", email: "", typeProjet: "", description: "", budget: "", zone: "" });
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<div className="bg-bg-white border border-success rounded-2xl p-8 text-center">
|
||||
<div className="text-4xl mb-4">✅</div>
|
||||
<h3 className="text-navy font-bold text-xl mb-2">Demande envoyée !</h3>
|
||||
<p className="text-text-light text-sm">
|
||||
Benoît vous rappellera dans les 24h. En cas d'urgence, appelez directement le{" "}
|
||||
<a href="tel:0674453089" className="text-orange font-bold">
|
||||
06 74 45 30 89
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-bg-white border border-border rounded-2xl p-6 md:p-8 space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="nom" className="block text-sm font-semibold text-navy mb-1">
|
||||
Nom <span className="text-orange">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nom"
|
||||
name="nom"
|
||||
type="text"
|
||||
value={form.nom}
|
||||
onChange={handleChange}
|
||||
placeholder="Votre nom"
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="telephone" className="block text-sm font-semibold text-navy mb-1">
|
||||
Téléphone <span className="text-orange">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="telephone"
|
||||
name="telephone"
|
||||
type="tel"
|
||||
value={form.telephone}
|
||||
onChange={handleChange}
|
||||
placeholder="06 XX XX XX XX"
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-navy mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="votre@email.fr"
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="typeProjet" className="block text-sm font-semibold text-navy mb-1">
|
||||
Type de projet <span className="text-orange">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="typeProjet"
|
||||
name="typeProjet"
|
||||
value={form.typeProjet}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg"
|
||||
required
|
||||
>
|
||||
<option value="">Choisissez un type de projet</option>
|
||||
{typesProjets.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-semibold text-navy mb-1">
|
||||
Description du projet
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder="Décrivez votre projet : surface, localisation, contraintes particulières..."
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="budget" className="block text-sm font-semibold text-navy mb-1">
|
||||
Budget approximatif
|
||||
</label>
|
||||
<input
|
||||
id="budget"
|
||||
name="budget"
|
||||
type="text"
|
||||
value={form.budget}
|
||||
onChange={handleChange}
|
||||
placeholder="ex : 80 000 €"
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="zone" className="block text-sm font-semibold text-navy mb-1">
|
||||
Commune / Zone
|
||||
</label>
|
||||
<input
|
||||
id="zone"
|
||||
name="zone"
|
||||
type="text"
|
||||
value={form.zone}
|
||||
onChange={handleChange}
|
||||
placeholder="ex : Orchies, Douai..."
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors bg-bg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-error text-sm bg-red-50 border border-red-200 rounded-xl px-4 py-3">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "sending"}
|
||||
className="w-full bg-orange hover:bg-orange-hover text-white font-bold py-4 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed text-base"
|
||||
>
|
||||
{status === "sending" ? "Envoi en cours..." : "Envoyer ma demande de devis"}
|
||||
</button>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-error text-sm text-center">
|
||||
Une erreur est survenue. Appelez directement le{" "}
|
||||
<a href="tel:0674453089" className="font-bold underline">
|
||||
06 74 45 30 89
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-text-muted text-xs text-center">
|
||||
Devis gratuit & sans engagement — Réponse sous 24h
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Card from "@/components/ui/Card";
|
||||
import Link from "next/link";
|
||||
import ScrollReveal from "@/components/animations/ScrollReveal";
|
||||
|
||||
const demos = [
|
||||
{
|
||||
title: "Le Mod\u00e8le \u00ab\u00a0Gros \u0152uvre\u00a0\u00bb",
|
||||
subtitle: "Ma\u00e7ons, Couvreurs",
|
||||
description: "Id\u00e9al pour montrer la technique. Un site qui met en avant vos photos \u00ab\u00a0Avant / Apr\u00e8s\u00a0\u00bb pour prouver la qualit\u00e9 de vos finitions.",
|
||||
cta: "Voir un exemple Ma\u00e7onnerie",
|
||||
href: "/macon",
|
||||
icon: (
|
||||
<svg className="w-10 h-10" 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: "Le Mod\u00e8le \u00ab\u00a0Cr\u00e9ation\u00a0\u00bb",
|
||||
subtitle: "Paysagistes, Peintres",
|
||||
description: "Id\u00e9al pour vendre du r\u00eave. Un design \u00e9pur\u00e9 qui laisse toute la place \u00e0 la beaut\u00e9 de vos r\u00e9alisations.",
|
||||
cta: "Voir un exemple Paysagiste",
|
||||
href: "/paysagiste",
|
||||
icon: (
|
||||
<svg className="w-10 h-10" 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: "Le Mod\u00e8le \u00ab\u00a0Intervention\u00a0\u00bb",
|
||||
subtitle: "Plombiers, \u00c9lectriciens",
|
||||
description: "Id\u00e9al pour l\u2019urgence. Un site ultra-rapide avec votre num\u00e9ro de t\u00e9l\u00e9phone bien visible pour \u00eatre appel\u00e9 en un clic.",
|
||||
cta: "Voir un exemple Plombier",
|
||||
href: "/plombier",
|
||||
icon: (
|
||||
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface DemosLiveProps {
|
||||
images?: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function DemosLive(_props: DemosLiveProps) {
|
||||
return (
|
||||
<section id="exemples" className="py-16 md:py-24 bg-bg" aria-label="Exemples">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<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">
|
||||
Exemples
|
||||
</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-bold text-navy tracking-[-0.02em] mb-3">
|
||||
Ne signez pas les yeux fermés.{" "}
|
||||
<span className="text-orange">Regardez ce que je peux faire pour vous.</span>
|
||||
</h2>
|
||||
<p className="text-text-light text-base md:text-lg max-w-2xl mx-auto">
|
||||
Je ne vous demande pas de me croire sur parole. J’ai préparé des modèles
|
||||
adaptés à votre métier. Cliquez et imaginez votre logo à la place.
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{demos.map((demo, i) => (
|
||||
<ScrollReveal key={i} direction="up" delay={i * 200}>
|
||||
<Card hover className="flex flex-col p-0 overflow-hidden h-full">
|
||||
{/* Header visuel */}
|
||||
<div className="bg-navy p-6 text-center">
|
||||
<div className="w-16 h-16 bg-orange/20 rounded-2xl flex items-center justify-center mx-auto mb-3 text-orange">
|
||||
{demo.icon}
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg">{demo.title}</h3>
|
||||
<p className="text-orange text-sm font-semibold">{demo.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<p className="text-text-light text-sm leading-relaxed">{demo.description}</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={demo.href}
|
||||
className="mt-5 flex items-center justify-center gap-2 bg-orange text-white font-bold text-sm px-5 py-3 rounded-xl hover:bg-orange/90 hover:scale-[1.02] transition-all duration-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{demo.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
export default function ExitIntentPopup() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const lastScrollY = useRef(0);
|
||||
const maxScrollY = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissed) return;
|
||||
|
||||
// Check if already shown this session
|
||||
if (typeof window !== "undefined" && sessionStorage.getItem("hooklab_exit_popup")) {
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerPopup = () => {
|
||||
if (!show && !dismissed) {
|
||||
setShow(true);
|
||||
sessionStorage.setItem("hooklab_exit_popup", "1");
|
||||
}
|
||||
};
|
||||
|
||||
// Desktop: mouse leaves viewport at top
|
||||
const handleMouseLeave = (e: MouseEvent) => {
|
||||
if (e.clientY <= 5) {
|
||||
triggerPopup();
|
||||
}
|
||||
};
|
||||
|
||||
// Mobile: user scrolls back up fast after scrolling at least 60% of the page
|
||||
const handleScroll = () => {
|
||||
const currentY = window.scrollY;
|
||||
const pageHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const scrollPercent = pageHeight > 0 ? currentY / pageHeight : 0;
|
||||
|
||||
if (currentY > maxScrollY.current) {
|
||||
maxScrollY.current = currentY;
|
||||
}
|
||||
|
||||
// Trigger if user scrolled past 60% of page and then scrolls up by 300px+
|
||||
const scrolledUpAmount = maxScrollY.current - currentY;
|
||||
const maxScrollPercent = pageHeight > 0 ? maxScrollY.current / pageHeight : 0;
|
||||
|
||||
if (maxScrollPercent > 0.6 && scrolledUpAmount > 300 && scrollPercent < 0.4) {
|
||||
triggerPopup();
|
||||
}
|
||||
|
||||
lastScrollY.current = currentY;
|
||||
};
|
||||
|
||||
// Desktop: mouseleave
|
||||
document.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
// Mobile: scroll-based trigger
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [show, dismissed]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShow(false);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-dark-light border border-dark-border rounded-3xl p-6 sm:p-8 max-w-md w-full shadow-2xl animate-scale-in">
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 text-white/40 hover:text-white cursor-pointer"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
{/* Icon */}
|
||||
<div className="w-14 h-14 sm:w-16 sm:h-16 gradient-bg rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-5">
|
||||
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-white mb-2 sm:mb-3">
|
||||
Tu hésites encore ?
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm mb-5 sm:mb-6 leading-relaxed">
|
||||
TikTok Shop vient d'arriver en France. Le marché n'est pas
|
||||
encore saturé et les premiers créateurs captent
|
||||
l'essentiel des commissions.
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5 sm:mb-6">
|
||||
<div className="bg-dark border border-dark-border rounded-xl p-3">
|
||||
<p className="text-lg sm:text-xl font-bold gradient-text">50,5M€</p>
|
||||
<p className="text-white/40 text-xs">Marché FR en 2 mois</p>
|
||||
</div>
|
||||
<div className="bg-dark border border-dark-border rounded-xl p-3">
|
||||
<p className="text-lg sm:text-xl font-bold gradient-text">10-30%</p>
|
||||
<p className="text-white/40 text-xs">Commission par vente</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/candidature" onClick={handleClose}>
|
||||
<Button size="lg" className="w-full pulse-glow mb-3">
|
||||
Découvrir le programme
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-white/30 text-xs hover:text-white/50 transition-colors cursor-pointer"
|
||||
>
|
||||
Non merci
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ScrollReveal from "@/components/animations/ScrollReveal";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border py-10 md:py-12 bg-bg-white">
|
||||
<footer className="bg-navy text-white pt-12 pb-6">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<Link href="/" className="flex items-center gap-2 mb-3" aria-label="HookLab - Accueil">
|
||||
<div className="w-8 h-8 bg-navy rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">H</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-navy">
|
||||
Hook<span className="text-orange">Lab</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-text-light text-sm leading-relaxed max-w-xs">
|
||||
Création de sites internet pour artisans.
|
||||
</p>
|
||||
<p className="text-text-muted text-xs mt-3">
|
||||
59148 Flines-lez-Raches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expertises SEO */}
|
||||
<div>
|
||||
<h4 className="text-navy font-semibold text-sm mb-4">
|
||||
Expertises
|
||||
</h4>
|
||||
<ul className="space-y-2 text-text-light text-sm">
|
||||
<li>Site internet Couvreur</li>
|
||||
<li>SEO Maçonnerie</li>
|
||||
<li>Webmaster Paysagiste</li>
|
||||
<li>Visibilité Menuisier</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h4 className="text-navy font-semibold text-sm mb-4">Légal</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link href="/mentions-legales" className="text-text-light hover:text-navy text-sm transition-colors">
|
||||
Mentions légales
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/confidentialite" className="text-text-light hover:text-navy text-sm transition-colors">
|
||||
Politique de Confidentialité
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 pb-10 border-b border-white/10">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-10 h-10 bg-orange rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold text-xs">OBC</span>
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-white font-bold text-base leading-none">OBC</span>
|
||||
<span className="text-orange-light font-bold text-base leading-none">Maçonnerie</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/70 text-sm leading-relaxed mb-4 max-w-xs">
|
||||
Benoît Colin, maçon expert en construction de maison, rénovation et gros œuvre dans le Nord. De la première pierre à la remise des clés.
|
||||
</p>
|
||||
<a
|
||||
href="tel:0674453089"
|
||||
className="inline-flex items-center gap-2 text-orange-light font-bold text-base hover:text-white 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="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 74 45 30 89
|
||||
</a>
|
||||
<p className="text-white/40 text-xs mt-2">
|
||||
221 Route de Saint-Amand, 59310 Mouchin
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Bottom SEO */}
|
||||
<div className="border-t border-border mt-8 pt-6 flex flex-col md:flex-row items-center justify-between gap-3">
|
||||
<p className="text-text-muted text-xs">
|
||||
© {new Date().getFullYear()} HookLab — Enguerrand Ozano · SIREN 994 538 932
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h4 className="text-white font-semibold text-sm mb-4 uppercase tracking-wide">Services</h4>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
{ href: "/construction-maison", label: "Construction de maison" },
|
||||
{ href: "/renovation", label: "Rénovation" },
|
||||
{ href: "/assainissement", label: "Assainissement" },
|
||||
{ href: "/creation-acces", label: "Création d'accès" },
|
||||
{ href: "/demolition", label: "Démolition" },
|
||||
].map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-white/60 hover:text-white text-sm transition-colors">
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div>
|
||||
<h4 className="text-white font-semibold text-sm mb-4 uppercase tracking-wide">Navigation</h4>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
{ href: "/", label: "Accueil" },
|
||||
{ href: "/realisations", label: "Réalisations" },
|
||||
{ href: "/partenaires", label: "Partenaires" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
{ href: "/blog", label: "Blog" },
|
||||
].map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-white/60 hover:text-white text-sm transition-colors">
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h4 className="text-white font-semibold text-sm mb-3 mt-5 uppercase tracking-wide">Légal</h4>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
{ href: "/mentions-legales", label: "Mentions légales" },
|
||||
{ href: "/confidentialite", label: "Confidentialité" },
|
||||
{ href: "/cgv", label: "CGV" },
|
||||
].map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-white/60 hover:text-white text-sm transition-colors">
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="pt-6 flex flex-col md:flex-row items-center justify-between gap-3">
|
||||
<p className="text-white/40 text-xs text-center md:text-left">
|
||||
© {new Date().getFullYear()} OBC Maçonnerie — Benoît Colin · SIREN 531 827 871
|
||||
</p>
|
||||
<p className="text-text-muted text-xs text-center md:text-right">
|
||||
Intervention : Douai · Orchies · Arleux · Valenciennes
|
||||
<p className="text-white/40 text-xs text-center md:text-right">
|
||||
Orchies · Mouchin · Douai · Valenciennes · Saint-Amand-les-Eaux —{" "}
|
||||
<span className="text-white/30">Site réalisé par HookLab</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
148
components/marketing/LocalSEOPage.tsx
Normal file
148
components/marketing/LocalSEOPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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";
|
||||
|
||||
interface LocalSEOPageProps {
|
||||
ville: string;
|
||||
departement?: string;
|
||||
servicesPrincipaux: string[];
|
||||
description: string;
|
||||
texteIntro: string;
|
||||
texteLocal: string;
|
||||
distanceMouchin?: string;
|
||||
}
|
||||
|
||||
const services = [
|
||||
{ icon: "🏠", label: "Construction de maison", href: "/construction-maison" },
|
||||
{ icon: "🔨", label: "Rénovation", href: "/renovation" },
|
||||
{ icon: "💧", label: "Assainissement", href: "/assainissement" },
|
||||
{ icon: "🚧", label: "Création d'accès", href: "/creation-acces" },
|
||||
{ icon: "🏗️", label: "Démolition", href: "/demolition" },
|
||||
];
|
||||
|
||||
export default function LocalSEOPage({
|
||||
ville,
|
||||
departement = "Nord (59)",
|
||||
servicesPrincipaux,
|
||||
description,
|
||||
texteIntro,
|
||||
texteLocal,
|
||||
distanceMouchin,
|
||||
}: LocalSEOPageProps) {
|
||||
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">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-orange">📍</span>
|
||||
<span className="text-white/60 text-sm">{ville} — {departement}</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-white mb-4 leading-tight">
|
||||
Maçon {ville} — Construction & Rénovation
|
||||
</h1>
|
||||
<p className="text-white/70 text-lg mb-8">{texteIntro}</p>
|
||||
{distanceMouchin && (
|
||||
<p className="text-white/40 text-sm mb-6 italic">
|
||||
{distanceMouchin} de Mouchin (siège OBC Maçonnerie)
|
||||
</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>
|
||||
|
||||
{/* Services */}
|
||||
<section className="py-14 bg-bg">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6">
|
||||
<ScrollReveal direction="up">
|
||||
<h2 className="text-2xl font-bold text-navy mb-6 text-center">
|
||||
Nos services à {ville}
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{services.map((s, i) => (
|
||||
<ScrollReveal key={s.label} direction="up" delay={i * 60}>
|
||||
<Link
|
||||
href={s.href}
|
||||
className={`group block bg-bg-white border rounded-xl p-4 text-center transition-all hover:shadow-md ${
|
||||
servicesPrincipaux.includes(s.label)
|
||||
? "border-orange"
|
||||
: "border-border hover:border-orange"
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">{s.icon}</div>
|
||||
<p className="text-navy font-semibold text-xs group-hover:text-orange transition-colors leading-snug">
|
||||
{s.label}
|
||||
</p>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Texte SEO local */}
|
||||
<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-5">
|
||||
OBC Maçonnerie intervient à {ville}
|
||||
</h2>
|
||||
<div className="text-text-light text-sm leading-relaxed space-y-4">
|
||||
{texteLocal.split("\n").map((para, i) => (
|
||||
<p key={i}>{para}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-bg-white border border-border rounded-xl p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-navy rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold text-xs">OBC</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-navy font-bold text-sm">Benoît Colin — OBC Maçonnerie</p>
|
||||
<p className="text-text-muted text-xs">221 Route de Saint-Amand, 59310 Mouchin</p>
|
||||
<a href="tel:0674453089" className="text-orange font-bold text-sm hover:underline">
|
||||
06 74 45 30 89
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Formulaire */}
|
||||
<section className="py-14 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 à {ville}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,46 +3,58 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/services", label: "Nos services" },
|
||||
{ href: "/realisations", label: "Réalisations" },
|
||||
{ href: "/partenaires", label: "Partenaires" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-bg-white/90 backdrop-blur-md border-b border-border" role="navigation" aria-label="Navigation principale">
|
||||
<nav
|
||||
className="sticky top-0 z-50 bg-bg-white/95 backdrop-blur-md border-b border-border"
|
||||
role="navigation"
|
||||
aria-label="Navigation principale"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2" aria-label="HookLab - Accueil">
|
||||
<div className="w-9 h-9 bg-navy rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-base">H</span>
|
||||
<Link href="/" className="flex items-center gap-2.5" aria-label="OBC Maçonnerie - Accueil">
|
||||
<div className="w-9 h-9 bg-navy rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold text-sm">OBC</span>
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-navy font-bold text-sm leading-none">OBC</span>
|
||||
<span className="text-orange font-bold text-sm leading-none">Maçonnerie</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-navy">
|
||||
Hook<span className="text-orange">Lab</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<a href="#methode" className="text-text-light hover:text-navy text-sm font-medium transition-colors">
|
||||
Notre Méthode
|
||||
</a>
|
||||
<a href="#exemples" className="text-text-light hover:text-navy text-sm font-medium transition-colors">
|
||||
Exemples
|
||||
</a>
|
||||
<a href="#qui-suis-je" className="text-text-light hover:text-navy text-sm font-medium transition-colors">
|
||||
Qui suis-je
|
||||
</a>
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-text-light hover:text-navy text-sm font-medium transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA desktop - Phone */}
|
||||
{/* CTA desktop */}
|
||||
<div className="hidden md:block">
|
||||
<a
|
||||
href="tel:+33604408157"
|
||||
className="inline-flex items-center gap-2 bg-orange text-white font-bold text-sm px-5 py-2.5 rounded-xl hover:bg-orange/90 transition-colors"
|
||||
href="tel:0674453089"
|
||||
className="inline-flex items-center gap-2 bg-orange text-white font-bold text-sm px-5 py-2.5 rounded-xl hover:bg-orange-hover transition-colors pulse-glow"
|
||||
>
|
||||
<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
|
||||
06 74 45 30 89
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -67,26 +79,29 @@ export default function Navbar() {
|
||||
|
||||
{/* Mobile menu */}
|
||||
{open && (
|
||||
<div className="md:hidden border-t border-border py-4 space-y-3">
|
||||
<a href="#methode" onClick={() => setOpen(false)} className="block text-text-light hover:text-navy text-sm font-medium py-2 transition-colors">
|
||||
Notre Méthode
|
||||
</a>
|
||||
<a href="#exemples" onClick={() => setOpen(false)} className="block text-text-light hover:text-navy text-sm font-medium py-2 transition-colors">
|
||||
Exemples
|
||||
</a>
|
||||
<a href="#qui-suis-je" onClick={() => setOpen(false)} className="block text-text-light hover:text-navy text-sm font-medium py-2 transition-colors">
|
||||
Qui suis-je
|
||||
</a>
|
||||
<a
|
||||
href="tel:+33604408157"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center justify-center gap-2 bg-orange text-white font-bold text-sm px-5 py-3 rounded-xl mt-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 className="md:hidden border-t border-border py-4 space-y-1">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block text-text-light hover:text-navy text-sm font-medium py-2.5 px-2 rounded-lg hover:bg-bg-muted transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<a
|
||||
href="tel:0674453089"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center justify-center gap-2 bg-orange text-white font-bold text-sm px-5 py-3 rounded-xl mt-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>
|
||||
Appeler Benoît — 06 74 45 30 89
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import Card from "@/components/ui/Card";
|
||||
|
||||
const personas = [
|
||||
{
|
||||
id: "jeune",
|
||||
emoji: "🎓",
|
||||
title: "Étudiant / Jeune actif",
|
||||
subtitle: "18-25 ans",
|
||||
description:
|
||||
"Tu veux générer tes premiers revenus en ligne tout en étudiant ou en début de carrière. TikTok Shop est le levier parfait.",
|
||||
benefits: [
|
||||
"Flexibilité totale, travaille quand tu veux",
|
||||
"Pas besoin de stock ni d'investissement",
|
||||
"Compétences marketing valorisables sur ton CV",
|
||||
"Communauté de jeunes entrepreneurs motivés",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parent",
|
||||
emoji: "👨👩👧",
|
||||
title: "Parent / Reconversion",
|
||||
subtitle: "25-45 ans",
|
||||
description:
|
||||
"Tu cherches un complément de revenus ou une reconversion flexible depuis chez toi. TikTok Shop s'adapte à ton emploi du temps.",
|
||||
benefits: [
|
||||
"2h par jour suffisent pour démarrer",
|
||||
"Travaille depuis chez toi, à ton rythme",
|
||||
"Revenus complémentaires dès le premier mois",
|
||||
"Accompagnement personnalisé et bienveillant",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function PersonaCards() {
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-dark-light/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
|
||||
Pour qui ?
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
|
||||
Un programme adapté à{" "}
|
||||
<span className="gradient-text">ton profil</span>
|
||||
</h2>
|
||||
<p className="text-white/60 text-lg">
|
||||
Que tu sois étudiant ou parent, notre méthode s'adapte à toi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{personas.map((p) => (
|
||||
<Card key={p.id} hover className="relative overflow-hidden">
|
||||
{/* Gradient accent top */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 gradient-bg" />
|
||||
|
||||
<div className="pt-2">
|
||||
{/* Emoji + Title */}
|
||||
<div className="text-4xl mb-4">{p.emoji}</div>
|
||||
<h3 className="text-xl font-bold text-white mb-1">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="text-primary text-sm font-medium mb-3">
|
||||
{p.subtitle}
|
||||
</p>
|
||||
<p className="text-white/60 text-sm mb-6 leading-relaxed">
|
||||
{p.description}
|
||||
</p>
|
||||
|
||||
{/* Benefits */}
|
||||
<ul className="space-y-3">
|
||||
{p.benefits.map((b, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-success mt-0.5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white/70 text-sm">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Card from "@/components/ui/Card";
|
||||
|
||||
const coachingFeatures = [
|
||||
"8 semaines de coaching intensif",
|
||||
"Acc\u00e8s \u00e0 tous les modules vid\u00e9o",
|
||||
"Templates et scripts de contenu",
|
||||
"Appels de groupe hebdomadaires",
|
||||
"Support WhatsApp illimit\u00e9",
|
||||
"Communaut\u00e9 priv\u00e9e d\u2019entrepreneurs",
|
||||
"Mises \u00e0 jour \u00e0 vie du contenu",
|
||||
"Certification HookLab",
|
||||
];
|
||||
|
||||
const bonuses = [
|
||||
"Liste de 50 produits gagnants TikTok Shop",
|
||||
"Guide de l\u2019algorithme TikTok 2025-2026",
|
||||
"Templates Canva pour miniatures",
|
||||
];
|
||||
|
||||
const suiviFeatures = [
|
||||
"R\u00e9ponses \u00e0 tes questions vid\u00e9os",
|
||||
"Id\u00e9es de concepts et tendances",
|
||||
"Coaching motivation au quotidien",
|
||||
"Acc\u00e8s aux nouvelles mises \u00e0 jour",
|
||||
"Groupe WhatsApp alumni",
|
||||
];
|
||||
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id="tarif" className="py-20 md:py-32">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
|
||||
Tarifs
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
|
||||
Investis dans ta{" "}
|
||||
<span className="gradient-text">formation TikTok Shop</span>
|
||||
</h2>
|
||||
<p className="text-white/60 text-lg">
|
||||
Un programme complet avec accompagnement personnalisé, et une
|
||||
option de suivi mensuel pour continuer à progresser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
{/* Coaching card */}
|
||||
<Card className="relative overflow-hidden border-primary/30">
|
||||
{/* Popular badge */}
|
||||
<div className="absolute top-0 left-0 right-0 gradient-bg py-2.5 text-center">
|
||||
<span className="text-white text-sm font-semibold">
|
||||
Programme principal — Places limitées
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-14">
|
||||
<h3 className="text-lg font-bold text-white mb-1">
|
||||
Formation + Coaching
|
||||
</h3>
|
||||
<p className="text-white/40 text-sm mb-6">
|
||||
Programme intensif de 8 semaines
|
||||
</p>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-baseline justify-center gap-2 mb-1">
|
||||
<span className="text-white/40 text-2xl line-through">
|
||||
690€
|
||||
</span>
|
||||
<span className="text-5xl md:text-6xl font-bold text-white">
|
||||
490€
|
||||
</span>
|
||||
<span className="text-white/40 text-lg">/mois</span>
|
||||
</div>
|
||||
<p className="text-white/40 mt-2">
|
||||
x2 mois (980€ total) — Paiement sécurisé
|
||||
via Stripe
|
||||
</p>
|
||||
<div className="inline-flex items-center mt-3 px-3 py-1 bg-success/10 border border-success/20 rounded-full">
|
||||
<span className="text-success text-sm font-medium">
|
||||
Économise 400€ avec l'offre de lancement
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-dark-border my-6" />
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-6">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-4 font-medium">
|
||||
Inclus dans le programme
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{coachingFeatures.map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-success/10 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="w-3 h-3 text-success"
|
||||
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/80 text-sm">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Bonus */}
|
||||
<div className="bg-primary/5 border border-primary/10 rounded-2xl p-4 mb-6">
|
||||
<p className="text-primary text-xs uppercase tracking-wider mb-3 font-medium">
|
||||
Bonus inclus
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{bonuses.map((b, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-primary shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white/70 text-sm">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<Link href="/candidature">
|
||||
<Button size="lg" className="w-full pulse-glow">
|
||||
Candidater pour rejoindre HookLab
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Guarantee */}
|
||||
<div className="flex items-center justify-center gap-2 mt-5">
|
||||
<svg
|
||||
className="w-5 h-5 text-success"
|
||||
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>
|
||||
<span className="text-white/40 text-sm">
|
||||
Garantie satisfait ou remboursé 14 jours
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-white/25 text-xs mt-3">
|
||||
Candidature soumise à validation. Réponse sous
|
||||
24h.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Suivi card */}
|
||||
<Card className="relative overflow-hidden border-dark-border">
|
||||
<div className="absolute top-0 left-0 right-0 bg-dark-lighter py-2.5 text-center border-b border-dark-border">
|
||||
<span className="text-white/60 text-sm font-semibold">
|
||||
Après la formation
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-14">
|
||||
<h3 className="text-lg font-bold text-white mb-1">
|
||||
Suivi continu
|
||||
</h3>
|
||||
<p className="text-white/40 text-sm mb-6">
|
||||
Pour ceux qui ont terminé le programme
|
||||
</p>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-baseline justify-center gap-2 mb-1">
|
||||
<span className="text-4xl md:text-5xl font-bold text-white">
|
||||
49€
|
||||
</span>
|
||||
<span className="text-white/40 text-lg">/mois</span>
|
||||
</div>
|
||||
<p className="text-white/40 mt-2">
|
||||
Sans engagement — Annulable à tout moment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-dark-border my-6" />
|
||||
|
||||
{/* Condition */}
|
||||
<div className="bg-warning/5 border border-warning/15 rounded-xl p-3 mb-6">
|
||||
<p className="text-warning text-xs font-medium flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Accessible uniquement après avoir terminé les 8 semaines de coaching
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-6">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-4 font-medium">
|
||||
Inclus dans le suivi
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{suiviFeatures.map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-success/10 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="w-3 h-3 text-success"
|
||||
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/80 text-sm">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-dark border border-dark-border rounded-2xl p-4 mb-6">
|
||||
<p className="text-white/50 text-sm leading-relaxed">
|
||||
Le suivi mensuel te permet de continuer à progresser
|
||||
après ta formation initiale. Pose tes questions, reçois
|
||||
des idées de contenus et reste motivé avec la
|
||||
communauté d'anciens élèves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA disabled-style */}
|
||||
<div className="opacity-60">
|
||||
<Button size="lg" variant="secondary" className="w-full" disabled>
|
||||
Disponible après la formation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-white/25 text-xs mt-3">
|
||||
Le lien de souscription sera envoyé à la fin de
|
||||
tes 8 semaines.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const notifications = [
|
||||
{ name: "Mehdi L.", action: "a candidat\u00e9", time: "il y a 3 min" },
|
||||
{ name: "Laura B.", action: "a rejoint le programme", time: "il y a 12 min" },
|
||||
{ name: "Yanis K.", action: "a candidat\u00e9", time: "il y a 18 min" },
|
||||
{ name: "Sarah M.", action: "a g\u00e9n\u00e9r\u00e9 sa 1\u00e8re commission", time: "il y a 1h" },
|
||||
{ name: "Thomas D.", action: "a candidat\u00e9", time: "il y a 2h" },
|
||||
{ name: "Amina K.", action: "a atteint 1 000\u20ac de commissions", time: "il y a 3h" },
|
||||
{ name: "Julien R.", action: "a candidat\u00e9", time: "il y a 4h" },
|
||||
{ name: "Fatima N.", action: "a rejoint le programme", time: "il y a 5h" },
|
||||
];
|
||||
|
||||
export default function SocialProofTicker() {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const showTimeout = setTimeout(() => setVisible(true), 5000);
|
||||
return () => clearTimeout(showTimeout);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const interval = setInterval(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
setCurrent((prev) => (prev + 1) % notifications.length);
|
||||
setVisible(true);
|
||||
}, 500);
|
||||
}, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, [visible]);
|
||||
|
||||
const n = notifications[current];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 left-4 z-50 transition-all duration-500 ${
|
||||
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-dark-light border border-dark-border rounded-2xl p-4 shadow-2xl max-w-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full gradient-bg flex items-center justify-center text-sm font-bold text-white shrink-0">
|
||||
{n.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">
|
||||
{n.name} <span className="text-white/60 font-normal">{n.action}</span>
|
||||
</p>
|
||||
<p className="text-white/40 text-xs">{n.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next();
|
||||
|
||||
// ── X-Content-Type-Options ─────────────────────────────────────────────────
|
||||
// Empêche le navigateur de "deviner" le Content-Type (MIME-sniffing).
|
||||
// Sans ce header, un fichier .jpg contenant du HTML pourrait être exécuté.
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
|
||||
// ── X-Frame-Options ────────────────────────────────────────────────────────
|
||||
// Bloque l'intégration de la page dans une iframe externe (clickjacking).
|
||||
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
||||
|
||||
// ── Referrer-Policy ────────────────────────────────────────────────────────
|
||||
// Limite les infos envoyées dans le header Referer aux requêtes cross-origin.
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
// ── Permissions-Policy ─────────────────────────────────────────────────────
|
||||
// Désactive les APIs navigateur sensibles non utilisées par le site.
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), payment=(self)"
|
||||
);
|
||||
|
||||
// ── Content-Security-Policy ────────────────────────────────────────────────
|
||||
// Whitelist explicite des origines autorisées pour chaque type de ressource.
|
||||
const supabaseHost = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).host
|
||||
: "*.supabase.co";
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
|
||||
// Next.js 15 (App Router + RSC) nécessite 'unsafe-inline' et 'unsafe-eval'
|
||||
// pour le bundle client et l'hydration côté navigateur.
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://m.stripe.com",
|
||||
|
||||
// Tailwind CSS + styles inline de composants React
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
|
||||
// Images : self, data URIs (placeholders SVG), blob (prévisualisations upload),
|
||||
// Unsplash (images par défaut), Supabase Storage (signed URLs), Sanity CDN
|
||||
`img-src 'self' data: blob: https://images.unsplash.com https://${supabaseHost} https://cdn.sanity.io`,
|
||||
|
||||
// API calls : Supabase (REST + WebSocket realtime), Stripe
|
||||
`connect-src 'self' https://${supabaseHost} wss://${supabaseHost} https://api.stripe.com https://r.stripe.com`,
|
||||
|
||||
// Polices web : uniquement self (pas de Google Fonts)
|
||||
"font-src 'self'",
|
||||
|
||||
// Iframes : uniquement Stripe (paiement sécurisé)
|
||||
"frame-src https://js.stripe.com",
|
||||
|
||||
// Aucun plugin (Flash, Java, etc.)
|
||||
"object-src 'none'",
|
||||
|
||||
// Empêche l'injection de <base> qui pourrait détourner les URLs relatives
|
||||
"base-uri 'self'",
|
||||
|
||||
// Les formulaires ne peuvent soumettre qu'à l'origine actuelle
|
||||
"form-action 'self'",
|
||||
|
||||
// Force HTTPS pour toutes les sous-ressources (utile si déployé en HTTP accidentellement)
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Appliquer sur toutes les routes sauf les assets statiques Next.js
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon\\.ico|.*\\.svg|.*\\.ico|.*\\.png|.*\\.jpg|.*\\.jpeg|.*\\.webp|.*\\.gif).*)",
|
||||
],
|
||||
};
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Allow Sanity image CDN
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.sanity.io",
|
||||
},
|
||||
],
|
||||
remotePatterns: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1,8 +0,0 @@
|
||||
import { defineCliConfig } from "sanity/cli";
|
||||
|
||||
export default defineCliConfig({
|
||||
api: {
|
||||
projectId: "4r409ts6",
|
||||
dataset: "production",
|
||||
},
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "sanity";
|
||||
import { structureTool } from "sanity/structure";
|
||||
import { portfolioSchema, siteSettingsSchema } from "./sanity/schemas";
|
||||
|
||||
export default defineConfig({
|
||||
name: "hooklab",
|
||||
title: "HookLab",
|
||||
projectId: "4r409ts6",
|
||||
dataset: "production",
|
||||
plugins: [structureTool()],
|
||||
schema: {
|
||||
types: [portfolioSchema, siteSettingsSchema],
|
||||
},
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { portfolioSchema } from "./portfolio";
|
||||
export { siteSettingsSchema } from "./siteSettings";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user