Compare commits
33 Commits
main
...
claude/obc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfaaf8a263 | ||
|
|
d91cfd9f53 | ||
|
|
a13ac539e2 | ||
|
|
78aa17b8da | ||
|
|
97379506e4 | ||
|
|
79ba505e70 | ||
|
|
65f6d1c4d6 | ||
|
|
dfa2c8743f | ||
|
|
238708bd7c | ||
|
|
a48468ae45 | ||
|
|
7651a45586 | ||
|
|
7f7a2f4edd | ||
|
|
2d330160e9 | ||
|
|
cdb50e0414 | ||
|
|
fc881e5178 | ||
|
|
2895ae0cd3 | ||
|
|
26c916b940 | ||
|
|
54d2512be0 | ||
|
|
34629b228d | ||
|
|
e6a982e735 | ||
|
|
65a222fbc7 | ||
|
|
0cafc29408 | ||
|
|
4f71670212 | ||
|
|
05284eab72 | ||
|
|
6028dec0d8 | ||
|
|
bb6e367184 | ||
|
|
f72b5fbc38 | ||
|
|
956c7ece01 | ||
|
|
8ed671cb9c | ||
|
|
5493d6a660 | ||
|
|
a133f195c2 | ||
|
|
15c60a274c | ||
|
|
3adcec00b7 |
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*
|
||||
100
app/(app)/api/contact/route.ts
Normal file
100
app/(app)/api/contact/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
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 (!nom || !telephone || !typeProjet) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nom, téléphone et type de projet sont requis." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
// 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 || "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 de devis — ${nom} (${typeProjet})`,
|
||||
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;">
|
||||
<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;">${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:#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;">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:#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>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error("Erreur API contact OBC:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur. Appelez le 06 74 45 30 89." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/(app)/assainissement/page.tsx
Normal file
51
app/(app)/assainissement/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import ServicePageLayout from "@/components/marketing/ServicePageLayout";
|
||||
import { getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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"],
|
||||
alternates: { canonical: `${config.url}/assainissement` },
|
||||
};
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ 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 async function AssainissementPage() {
|
||||
const config = await getSiteConfig();
|
||||
const { phone, phoneRaw } = config;
|
||||
return (
|
||||
<ServicePageLayout
|
||||
label="Assainissement"
|
||||
title="Assainissement dans le Nord"
|
||||
subtitle="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."
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
items={items}
|
||||
itemsSectionTitle="Nos prestations assainissement"
|
||||
seoTitle="Assainissement dans le Nord (59)"
|
||||
seoText={
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
contactTitle="Votre projet d'assainissement"
|
||||
/>
|
||||
);
|
||||
}
|
||||
174
app/(app)/blog/[slug]/page.tsx
Normal file
174
app/(app)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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";
|
||||
import { getBlogPost, getBlogPosts, getSiteConfig } from "@/lib/content";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
// Corps des articles — FUTURE: champ rich text Payload CMS
|
||||
const articleContenu: Record<string, string[]> = {
|
||||
"combien-coute-construction-maison-nord": [
|
||||
"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": [
|
||||
"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": [
|
||||
"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": [
|
||||
"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": [
|
||||
"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": [
|
||||
"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() {
|
||||
// FUTURE: utilise getBlogPosts() pour générer les slugs depuis Payload CMS
|
||||
const posts = await getBlogPosts();
|
||||
return posts.map((p) => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: Props
|
||||
): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const [post, config] = await Promise.all([getBlogPost(slug), getSiteConfig()]);
|
||||
if (!post) return { title: "Article introuvable" };
|
||||
return {
|
||||
title: post.titre,
|
||||
description: post.extrait,
|
||||
alternates: { canonical: `${config.url}/blog/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogArticlePage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const post = await getBlogPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const contenu = articleContenu[slug] ?? [];
|
||||
|
||||
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">
|
||||
{post.cat}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">{post.date}</span>
|
||||
<span className="text-white/40 text-xs">· {post.readTime} de lecture</span>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl font-bold text-white leading-tight">
|
||||
{post.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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
116
app/(app)/blog/page.tsx
Normal file
116
app/(app)/blog/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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 { getBlogPosts, getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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: `${config.url}/blog` },
|
||||
};
|
||||
}
|
||||
|
||||
const cats = ["Tous", "Construction", "Rénovation", "Assainissement"];
|
||||
|
||||
export default async function BlogPage() {
|
||||
const articles = await getBlogPosts();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
111
app/(app)/cgv/page.tsx
Normal file
111
app/(app)/cgv/page.tsx
Normal file
@@ -0,0 +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 | OBC Maçonnerie",
|
||||
description:
|
||||
"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 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-navy mb-10">Conditions Générales de Vente</h1>
|
||||
|
||||
<div className="space-y-8 text-text-light text-sm leading-relaxed">
|
||||
|
||||
<section>
|
||||
<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 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-lg font-bold text-navy mb-3">Article 2 — Devis et commandes</h2>
|
||||
<p>
|
||||
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><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-lg font-bold text-navy mb-3">Article 6 — Responsabilité</h2>
|
||||
<p>
|
||||
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-lg font-bold text-navy mb-3">Article 7 — Réception des travaux</h2>
|
||||
<p>
|
||||
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-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-orange hover:underline">
|
||||
politique de confidentialité
|
||||
</Link>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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 en priorité. À défaut, le tribunal compétent sera celui 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>
|
||||
);
|
||||
}
|
||||
133
app/(app)/confidentialite/page.tsx
Normal file
133
app/(app)/confidentialite/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/marketing/Navbar";
|
||||
import Footer from "@/components/marketing/Footer";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Politique de Confidentialité | OBC Maçonnerie",
|
||||
description:
|
||||
"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 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-navy mb-10">Politique de Confidentialité</h1>
|
||||
|
||||
<div className="space-y-8 text-text-light text-sm leading-relaxed">
|
||||
|
||||
<section>
|
||||
<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:${siteConfig.phoneRaw}`} className="text-orange hover:underline">{siteConfig.phone}</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-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>
|
||||
<p className="mt-3">
|
||||
<strong className="text-text">Aucune donnée bancaire</strong> n'est collectée sur ce site.
|
||||
</p>
|
||||
</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-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-lg font-bold text-navy mb-3">5. Partage des données</h2>
|
||||
<p>
|
||||
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">
|
||||
<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-lg font-bold text-navy mb-3">8. Cookies</h2>
|
||||
<p>
|
||||
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>
|
||||
|
||||
<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/(app)/construction-maison-douai/page.tsx
Normal file
24
app/(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/(app)/construction-maison-orchies/page.tsx
Normal file
24
app/(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/(app)/construction-maison-valenciennes/page.tsx
Normal file
24
app/(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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
197
app/(app)/construction-maison/page.tsx
Normal file
197
app/(app)/construction-maison/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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";
|
||||
import { getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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: `${config.url}/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 async function ConstructionMaisonPage() {
|
||||
const config = await getSiteConfig();
|
||||
const { phone, phoneRaw } = config;
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-24 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center gap-2 text-white/40 hover:text-white text-xs font-bold uppercase tracking-widest mb-8 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" 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-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Gros œuvre
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white uppercase leading-none tracking-tight mb-5 max-w-3xl">
|
||||
Construction de maison dans le Nord
|
||||
</h1>
|
||||
<p className="text-white/55 text-base md:text-lg max-w-xl mb-10">
|
||||
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="btn btn-fill px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>Devis gratuit</span>
|
||||
<span>
|
||||
<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>
|
||||
</span>
|
||||
</Link>
|
||||
<a href={`tel:${phoneRaw}`} className="btn btn-outline-light px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>{phone}</span>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="bg-stone-bg border-b border-border py-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ 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="border-l-2 border-orange pl-4">
|
||||
<div className="text-2xl font-black text-orange">{s.val}</div>
|
||||
<div className="text-text-muted text-xs uppercase tracking-wider mt-0.5">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Étapes */}
|
||||
<section className="py-16 md:py-20 bg-navy-light">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Notre méthode
|
||||
</span>
|
||||
<h2 className="text-white font-black text-2xl md:text-4xl uppercase leading-tight tracking-tight mb-12">
|
||||
Comment se déroule<br />votre construction ?
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-px bg-white/5">
|
||||
{etapes.map((e, i) => (
|
||||
<ScrollReveal key={e.num} direction="up" delay={i * 80}>
|
||||
<div className="service-card-dark bg-white/[0.03] p-7 h-full">
|
||||
<span className="text-orange font-black text-4xl leading-none block mb-4">{e.num}</span>
|
||||
<h3 className="text-white font-black text-base uppercase tracking-wide mb-3">{e.title}</h3>
|
||||
<p className="text-white/50 text-sm leading-relaxed">{e.desc}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SEO text */}
|
||||
<section className="py-14 bg-stone-bg border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-start">
|
||||
<div>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Notre expertise
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-2xl md:text-3xl uppercase leading-tight tracking-tight mb-6">
|
||||
Votre maçon constructeur<br />dans le Nord (59)
|
||||
</h2>
|
||||
</div>
|
||||
<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.
|
||||
</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>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact split */}
|
||||
<section className="grid lg:grid-cols-2">
|
||||
<div className="bg-navy texture-dark py-16 md:py-20 px-8 md:px-12 lg:px-16 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<ScrollReveal direction="left">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Votre projet de construction
|
||||
</span>
|
||||
<h2 className="text-white font-black text-2xl uppercase tracking-tight mb-6">
|
||||
Parlons de votre<br />future maison
|
||||
</h2>
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-8 max-w-sm">
|
||||
Benoît se déplace gratuitement pour évaluer votre terrain et vous remettre un devis détaillé sous 24h.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-orange" />
|
||||
<span className="text-white/60 text-sm">Devis gratuit & déplacement offert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-orange" />
|
||||
<span className="text-white/60 text-sm">Réponse sous 24h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-orange" />
|
||||
<span className="text-white/60 text-sm">Sans engagement</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
<div className="bg-stone-bg py-16 md:py-20 px-8 md:px-12 lg:px-16">
|
||||
<ScrollReveal direction="right">
|
||||
<ContactForm />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
145
app/(app)/contact/page.tsx
Normal file
145
app/(app)/contact/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
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";
|
||||
import { getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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: `${config.url}/contact` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ContactPage() {
|
||||
const config = await getSiteConfig();
|
||||
const { phone, phoneRaw, email, address, zones, zoneDescription } = config;
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Page hero */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-20 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Devis gratuit — Réponse sous 24h
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white uppercase leading-none tracking-tight">
|
||||
Parlons de<br />votre projet
|
||||
</h1>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Split content */}
|
||||
<section className="grid lg:grid-cols-2">
|
||||
{/* Gauche — infos */}
|
||||
<div className="bg-navy-light py-16 md:py-20 px-8 md:px-12 lg:px-16">
|
||||
<ScrollReveal direction="left">
|
||||
<h2 className="text-white font-black text-2xl uppercase tracking-tight mb-8">
|
||||
Nos coordonnées
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-10">
|
||||
{/* Téléphone */}
|
||||
<a href={`tel:${phoneRaw}`} className="flex items-center gap-4 group">
|
||||
<div className="w-11 h-11 border border-orange/40 flex items-center justify-center shrink-0 group-hover:border-orange group-hover:bg-orange/10 transition-all">
|
||||
<svg className="w-5 h-5 text-orange" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-0.5">Téléphone</p>
|
||||
<p className="text-white font-bold text-xl group-hover:text-orange transition-colors">{phone}</p>
|
||||
<p className="text-white/30 text-xs mt-0.5">Lun–Sam 7h–19h</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Email */}
|
||||
<a href={`mailto:${email}`} className="flex items-center gap-4 group">
|
||||
<div className="w-11 h-11 border border-white/15 flex items-center justify-center shrink-0 group-hover:border-orange group-hover:bg-orange/10 transition-all">
|
||||
<svg className="w-5 h-5 text-white/40 group-hover:text-orange transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-0.5">Email</p>
|
||||
<p className="text-white/80 font-bold text-sm group-hover:text-orange transition-colors">{email}</p>
|
||||
<p className="text-white/30 text-xs mt-0.5">Réponse sous 24h</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Adresse */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-11 h-11 border border-white/15 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5 text-white/40" 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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-0.5">Siège</p>
|
||||
<p className="text-white/70 text-sm">{address}</p>
|
||||
<p className="text-white/30 text-xs mt-0.5">Rayon d'intervention : {zoneDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zones */}
|
||||
<div className="border-t border-white/10 pt-8">
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-4">Zone d'intervention</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zones.map((z) => (
|
||||
<span
|
||||
key={z}
|
||||
className="inline-flex items-center gap-1.5 border border-white/15 text-white/60 text-xs font-bold px-3 py-1.5 uppercase tracking-wide"
|
||||
>
|
||||
<span className="w-1 h-1 rounded-full bg-orange shrink-0" />
|
||||
{z}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Garanties */}
|
||||
<div className="mt-10 grid grid-cols-2 gap-5 border-t border-white/10 pt-8">
|
||||
{[
|
||||
{ val: "Gratuit", label: "Devis + déplacement" },
|
||||
{ val: "24h", label: "Délai de réponse" },
|
||||
{ val: "15+", label: "Ans d'expérience" },
|
||||
{ val: "Sans engagement", label: "Aucune obligation" },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-xl font-black text-orange">{s.val}</div>
|
||||
<div className="text-white/35 text-xs uppercase tracking-wider mt-0.5 leading-tight">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Droite — formulaire */}
|
||||
<div className="bg-stone-bg py-16 md:py-20 px-8 md:px-12 lg:px-16">
|
||||
<ScrollReveal direction="right">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Demande de devis
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-2xl uppercase tracking-tight mb-8">
|
||||
Votre projet
|
||||
</h2>
|
||||
<ContactForm />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
51
app/(app)/creation-acces/page.tsx
Normal file
51
app/(app)/creation-acces/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import ServicePageLayout from "@/components/marketing/ServicePageLayout";
|
||||
import { getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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"],
|
||||
alternates: { canonical: `${config.url}/creation-acces` },
|
||||
};
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ 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 async function CreationAccesPage() {
|
||||
const config = await getSiteConfig();
|
||||
const { phone, phoneRaw } = config;
|
||||
return (
|
||||
<ServicePageLayout
|
||||
label="Voiries & accès"
|
||||
title="Création d'accès dans le Nord"
|
||||
subtitle="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."
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
items={items}
|
||||
itemsSectionTitle="Nos réalisations d'accès"
|
||||
seoTitle="Voirie & accès dans le Nord (59)"
|
||||
seoText={
|
||||
<>
|
||||
<p>
|
||||
OBC Maçonnerie réalise vos <strong className="text-text">travaux de voirie et création d'accès dans le Nord</strong>. Entrées de propriété en béton imprimé, chemins ruraux en gravier compacté ou voiries privées — Benoît Colin s'adapte à votre terrain et vos envies.
|
||||
</p>
|
||||
<p>
|
||||
Chaque projet commence par une étude du terrain pour choisir les matériaux et la technique les mieux adaptés : béton, pavés, gravier, béton désactivé. L'objectif : un accès durable, esthétique et parfaitement drainé.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
contactTitle="Votre projet d'accès"
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
app/(app)/demolition/page.tsx
Normal file
51
app/(app)/demolition/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import ServicePageLayout from "@/components/marketing/ServicePageLayout";
|
||||
import { getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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"],
|
||||
alternates: { canonical: `${config.url}/demolition` },
|
||||
};
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ 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 async function DemolitionPage() {
|
||||
const config = await getSiteConfig();
|
||||
const { phone, phoneRaw } = config;
|
||||
return (
|
||||
<ServicePageLayout
|
||||
label="Démolition"
|
||||
title="Démolition dans le Nord"
|
||||
subtitle="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."
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
items={items}
|
||||
itemsSectionTitle="Nos prestations de démolition"
|
||||
seoTitle="Démolition dans le Nord (59)"
|
||||
seoText={
|
||||
<>
|
||||
<p>
|
||||
OBC Maçonnerie intervient pour toutes vos <strong className="text-text">opérations de démolition dans le Nord</strong>. Qu'il s'agisse de détruire un bâtiment entier, d'ouvrir un mur porteur ou de curer l'intérieur avant rénovation, Benoît Colin prend en charge votre chantier avec rigueur.
|
||||
</p>
|
||||
<p>
|
||||
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. <strong className="text-text">La sécurité du chantier et de ses riverains est une priorité absolue.</strong>
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
contactTitle="Votre projet de démolition"
|
||||
/>
|
||||
);
|
||||
}
|
||||
487
app/(app)/globals.css
Normal file
487
app/(app)/globals.css
Normal file
@@ -0,0 +1,487 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ================================================
|
||||
DA OBC Maçonnerie — Rouge brique + Gris ardoise + Blanc cassé
|
||||
Évoque la brique du Nord, le béton, la pierre, le sérieux artisan
|
||||
================================================ */
|
||||
|
||||
@theme inline {
|
||||
/* ── Couleur sombre (sections hero, navbar, CTA band) ── */
|
||||
/* Charbon chaud : quasi-noir avec légère tonalité terreuse */
|
||||
--color-navy: #1C1A18;
|
||||
--color-navy-light: #2E2B28;
|
||||
--color-navy-dark: #100F0D;
|
||||
|
||||
/* ── Accent principal : Rouge brique du logo ── */
|
||||
--color-orange: #8B1A1A;
|
||||
--color-orange-hover: #6B1414;
|
||||
--color-orange-light: #A52424;
|
||||
|
||||
/* ── Ardoise (gris secondaire du logo) ── */
|
||||
--color-stone: #6B7B7A;
|
||||
--color-stone-light: #8B9A9A;
|
||||
--color-stone-bg: #F0EDEA;
|
||||
|
||||
/* ── Fonds ── */
|
||||
--color-bg: #F8F6F4;
|
||||
--color-bg-white: #FFFFFF;
|
||||
--color-bg-card: #FFFFFF;
|
||||
--color-bg-muted: #F0EDEA;
|
||||
|
||||
/* ── Texte ── */
|
||||
--color-text: #1C1C1C;
|
||||
--color-text-light: #4A4A4A;
|
||||
--color-text-muted: #6B7B7A;
|
||||
|
||||
/* ── Bordures ── */
|
||||
--color-border: #DDD8D3;
|
||||
--color-border-light: #EAE7E4;
|
||||
|
||||
/* ── Statuts ── */
|
||||
--color-success: #4A7C59;
|
||||
--color-warning: #C9832A;
|
||||
--color-error: #C0392B;
|
||||
|
||||
/* ── Aliases ── */
|
||||
--color-primary: #8B1A1A;
|
||||
--color-primary-hover: #6B1414;
|
||||
|
||||
/* ── Typographie ── */
|
||||
--font-sans: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
BASE
|
||||
================================================ */
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
CARD HOVER — ombre teintée brique
|
||||
================================================ */
|
||||
.card-hover {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(139, 26, 26, 0.10);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
CTA PULSE — glow rouge brique
|
||||
================================================ */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 16px rgba(139, 26, 26, 0.35);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 36px rgba(139, 26, 26, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-glow {
|
||||
animation: pulse-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
SÉLECTION DE TEXTE
|
||||
================================================ */
|
||||
::selection {
|
||||
background: rgba(139, 26, 26, 0.15);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
SKIP TO CONTENT
|
||||
================================================ */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
z-index: 999;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--color-navy);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
border-radius: 0 0 8px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
FOCUS VISIBLE
|
||||
================================================ */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
SCROLLBAR
|
||||
================================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-stone);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
ACCENT DIAGONALE — élément décoratif hero
|
||||
Évoque une maçonnerie en brique posée en diagonale
|
||||
================================================ */
|
||||
.hero-accent {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 45%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, transparent 40%, rgba(139, 26, 26, 0.07) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-accent-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, transparent, #8B1A1A 30%, #8B1A1A 70%, transparent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
SECTION LABEL — étiquette catégorie
|
||||
================================================ */
|
||||
.section-label {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-orange);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-label-light {
|
||||
color: rgba(139, 26, 26, 0.75);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
BRICK DIVIDER — séparateur rouge brique
|
||||
================================================ */
|
||||
.brick-divider {
|
||||
width: 3rem;
|
||||
height: 3px;
|
||||
background: var(--color-orange);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
STAT GLOW — compteur chiffres clés
|
||||
================================================ */
|
||||
@keyframes stat-glow {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 10px rgba(139, 26, 26, 0.25);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 22px rgba(139, 26, 26, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-stat-glow {
|
||||
animation: stat-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
HERO TEXT ANIMATIONS — staggered reveal
|
||||
================================================ */
|
||||
@keyframes hero-text-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-hero-text-1 {
|
||||
opacity: 0;
|
||||
animation: hero-text-appear 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
||||
}
|
||||
|
||||
.animate-hero-text-2 {
|
||||
opacity: 0;
|
||||
animation: hero-text-appear 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.3s forwards;
|
||||
}
|
||||
|
||||
.animate-hero-text-3 {
|
||||
opacity: 0;
|
||||
animation: hero-text-appear 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
FADE IN ANIMATIONS
|
||||
================================================ */
|
||||
@keyframes fade-in-down {
|
||||
0% { opacity: 0; transform: translateY(-20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
opacity: 0;
|
||||
animation: fade-in-down 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.animation-delay-200 { animation-delay: 200ms; }
|
||||
.animation-delay-400 { animation-delay: 400ms; }
|
||||
.animation-delay-600 { animation-delay: 600ms; }
|
||||
.animation-delay-800 { animation-delay: 800ms; }
|
||||
.animation-delay-1000 { animation-delay: 1000ms; }
|
||||
|
||||
/* ================================================
|
||||
UNDERLINE GROW
|
||||
================================================ */
|
||||
@keyframes underline-grow {
|
||||
0% { width: 0; }
|
||||
100% { width: 100%; }
|
||||
}
|
||||
|
||||
.animate-underline-grow {
|
||||
animation: underline-grow 1s cubic-bezier(0.16, 1, 0.3, 1) 0.8s forwards;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
BOUNCE SLOW
|
||||
================================================ */
|
||||
@keyframes bounce-slow {
|
||||
0%, 100% { transform: translate(-50%, 0); }
|
||||
50% { transform: translate(-50%, 8px); }
|
||||
}
|
||||
|
||||
.animate-bounce-slow {
|
||||
animation: bounce-slow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
SCROLL REVEAL
|
||||
================================================ */
|
||||
.scroll-reveal-up,
|
||||
.scroll-reveal-down,
|
||||
.scroll-reveal-left,
|
||||
.scroll-reveal-right,
|
||||
.scroll-reveal-fade {
|
||||
opacity: 0;
|
||||
transition-property: opacity, transform;
|
||||
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.scroll-reveal-up { transform: translateY(40px); }
|
||||
.scroll-reveal-down { transform: translateY(-40px); }
|
||||
.scroll-reveal-left { transform: translateX(-40px); }
|
||||
.scroll-reveal-right { transform: translateX(40px); }
|
||||
.scroll-reveal-fade { transform: scale(0.95); }
|
||||
|
||||
.scroll-revealed {
|
||||
opacity: 1 !important;
|
||||
transform: translateY(0) translateX(0) scale(1) !important;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
TEXTURE BÉTON — overlay sur sections sombres
|
||||
Subtile granularité qui évoque le béton brut
|
||||
================================================ */
|
||||
.texture-dark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.texture-dark::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
|
||||
background-size: 200px 200px;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.texture-dark > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
GRADIENT UTILITIES (legacy — non utilisé OBC)
|
||||
================================================ */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #8B1A1A, #C0392B);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
BOUTONS — animations premium slide-fill
|
||||
================================================ */
|
||||
.btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.btn:active { transform: scale(0.97) !important; }
|
||||
.btn > span, .btn > svg { position: relative; z-index: 1; }
|
||||
|
||||
/* Primaire rouge brique — slide depuis la gauche */
|
||||
.btn-fill { background: var(--color-orange); color: #fff; }
|
||||
.btn-fill::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: var(--color-orange-hover);
|
||||
transform: translateX(-101%);
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); z-index: 0;
|
||||
}
|
||||
.btn-fill:hover::before { transform: translateX(0); }
|
||||
.btn-fill:hover { transform: translateY(-3px); box-shadow: 0 12px 32px rgba(139, 26, 26, 0.40); }
|
||||
|
||||
/* Outline dark — s'inverse en navy */
|
||||
.btn-outline-dark { background: transparent; color: var(--color-navy); border: 2px solid var(--color-navy); }
|
||||
.btn-outline-dark::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: var(--color-navy);
|
||||
transform: translateX(-101%);
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); z-index: 0;
|
||||
}
|
||||
.btn-outline-dark:hover::before { transform: translateX(0); }
|
||||
.btn-outline-dark:hover { color: #fff; transform: translateY(-2px); }
|
||||
.btn-outline-dark > span, .btn-outline-dark > svg { position: relative; z-index: 1; }
|
||||
|
||||
/* Outline light — pour fonds sombres */
|
||||
.btn-outline-light { background: transparent; color: #fff; border: 2px solid rgba(255,255,255,0.35); }
|
||||
.btn-outline-light::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: rgba(255,255,255,0.12);
|
||||
transform: translateX(-101%);
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); z-index: 0;
|
||||
}
|
||||
.btn-outline-light:hover::before { transform: translateX(0); }
|
||||
.btn-outline-light:hover { border-color: rgba(255,255,255,0.65); transform: translateY(-2px); }
|
||||
.btn-outline-light > span, .btn-outline-light > svg { position: relative; z-index: 1; }
|
||||
|
||||
/* Fill blanc — pour sections colorées */
|
||||
.btn-fill-white { background: #fff; color: var(--color-navy); }
|
||||
.btn-fill-white::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: var(--color-bg-muted);
|
||||
transform: translateX(-101%);
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); z-index: 0;
|
||||
}
|
||||
.btn-fill-white:hover::before { transform: translateX(0); }
|
||||
.btn-fill-white:hover { transform: translateY(-3px); box-shadow: 0 10px 28px rgba(0,0,0,0.25); }
|
||||
.btn-fill-white > span, .btn-fill-white > svg { position: relative; z-index: 1; }
|
||||
|
||||
/* Lien-flèche animé */
|
||||
.btn-arrow { display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 700; transition: color 0.2s ease; }
|
||||
.btn-arrow .arrow-icon { display: inline-flex; transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.btn-arrow:hover .arrow-icon { transform: translateX(7px); }
|
||||
|
||||
/* ================================================
|
||||
NAVBAR — underline animé
|
||||
================================================ */
|
||||
.nav-link { position: relative; }
|
||||
.nav-link::after {
|
||||
content: ""; position: absolute; bottom: -2px; left: 0;
|
||||
width: 0; height: 2px; background: var(--color-orange);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.nav-link:hover::after, .nav-link.active::after { width: 100%; }
|
||||
|
||||
/* ================================================
|
||||
RÉALISATION CARD — slide-up overlay
|
||||
================================================ */
|
||||
.realisation-overlay {
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.realisation-card:hover .realisation-overlay { transform: translateY(0); }
|
||||
|
||||
/* ================================================
|
||||
HERO — panneau diagonal brique
|
||||
================================================ */
|
||||
.hero-diagonal-panel {
|
||||
position: absolute; top: 0; right: 0;
|
||||
width: 44%; height: 100%;
|
||||
background: var(--color-orange);
|
||||
clip-path: polygon(14% 0%, 100% 0%, 100% 100%, 0% 100%);
|
||||
opacity: 0.13;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-diagonal-border {
|
||||
position: absolute; top: 0; right: 0;
|
||||
width: 44%; height: 100%;
|
||||
clip-path: polygon(14% 0%, 100% 0%, 100% 100%, 0% 100%);
|
||||
border-left: 3px solid rgba(139,26,26,0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
SERVICE CARD — bordure top rouge brique
|
||||
================================================ */
|
||||
.service-card-dark {
|
||||
border-top: 3px solid var(--color-orange);
|
||||
transition: background 0.3s ease, transform 0.2s ease;
|
||||
}
|
||||
.service-card-dark:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
FAQ — icône rotation
|
||||
================================================ */
|
||||
.faq-icon { transition: transform 0.3s ease; }
|
||||
details[open] .faq-icon { transform: rotate(45deg); }
|
||||
195
app/(app)/layout.tsx
Normal file
195
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { Metadata } from "next";
|
||||
import CookieBanner from "@/components/CookieBanner";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import "./globals.css";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || siteConfig.url;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(BASE_URL),
|
||||
title: {
|
||||
default: "OBC Maçonnerie | Constructeur & Maçon à Orchies (Nord 59)",
|
||||
template: "%s | OBC Maçonnerie",
|
||||
},
|
||||
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.",
|
||||
keywords: [
|
||||
"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: "OBC Maçonnerie - Benoît Colin" }],
|
||||
creator: "OBC Maçonnerie",
|
||||
publisher: "OBC Maçonnerie",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
||||
apple: [
|
||||
{ url: "/apple-touch-icon.svg", type: "image/svg+xml", sizes: "180x180" },
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fr_FR",
|
||||
url: BASE_URL,
|
||||
siteName: "OBC Maçonnerie",
|
||||
title: "OBC Maçonnerie | Constructeur & Maçon dans le Nord (59)",
|
||||
description:
|
||||
"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.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OBC Maçonnerie - Construction et rénovation dans le Nord",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OBC Maçonnerie | Maçon constructeur dans le Nord (59)",
|
||||
description:
|
||||
"Construction de maison, rénovation, assainissement. Orchies, Douai, Valenciennes.",
|
||||
images: ["/og-image.jpg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
},
|
||||
verification: {
|
||||
google: process.env.GOOGLE_SITE_VERIFICATION || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const jsonLdBusiness = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/#business`,
|
||||
name: "OBC Maçonnerie",
|
||||
description:
|
||||
"Construction de maison, rénovation, assainissement et gros œuvre dans le Nord",
|
||||
telephone: siteConfig.phone,
|
||||
email: siteConfig.email,
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/icon-512.svg`,
|
||||
image: `${BASE_URL}/og-image.jpg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "221 Route de Saint-Amand",
|
||||
addressLocality: "Mouchin",
|
||||
postalCode: "59310",
|
||||
addressRegion: "Hauts-de-France",
|
||||
addressCountry: "FR",
|
||||
},
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: 50.4817,
|
||||
longitude: 3.3342,
|
||||
},
|
||||
areaServed: [
|
||||
{ "@type": "City", name: "Orchies" },
|
||||
{ "@type": "City", name: "Mouchin" },
|
||||
{ "@type": "City", name: "Douai" },
|
||||
{ "@type": "City", name: "Valenciennes" },
|
||||
{ "@type": "City", name: "Flines-lès-Raches" },
|
||||
{ "@type": "City", name: "Saint-Amand-les-Eaux" },
|
||||
{ "@type": "City", name: "Château-l'Abbaye" },
|
||||
{ "@type": "City", name: "Mérignies" },
|
||||
],
|
||||
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: "07:00",
|
||||
closes: "19:00",
|
||||
},
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
telephone: siteConfig.phone,
|
||||
contactType: "customer service",
|
||||
availableLanguage: "French",
|
||||
},
|
||||
founder: {
|
||||
"@type": "Person",
|
||||
name: "Benoît Colin",
|
||||
jobTitle: "Maçon - Gérant OBC Maçonnerie",
|
||||
},
|
||||
sameAs: [],
|
||||
};
|
||||
|
||||
const jsonLdWebSite = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": `${BASE_URL}/#website`,
|
||||
name: "OBC Maçonnerie",
|
||||
url: BASE_URL,
|
||||
description:
|
||||
"Site officiel d'OBC Maçonnerie, entreprise de construction et rénovation dans le Nord (59).",
|
||||
publisher: {
|
||||
"@id": `${BASE_URL}/#business`,
|
||||
},
|
||||
inLanguage: "fr-FR",
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta name="theme-color" content="#1B2A4A" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify([jsonLdBusiness, jsonLdWebSite]),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<a href="#main-content" className="skip-to-content">
|
||||
Aller au contenu principal
|
||||
</a>
|
||||
{children}
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
24
app/(app)/macon-flines-lez-raches/page.tsx
Normal file
24
app/(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/(app)/macon-mouchin/page.tsx
Normal file
23
app/(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/(app)/macon-saint-amand-les-eaux/page.tsx
Normal file
24
app/(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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
app/(app)/mentions-legales/page.tsx
Normal file
102
app/(app)/mentions-legales/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/marketing/Navbar";
|
||||
import Footer from "@/components/marketing/Footer";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mentions Légales | OBC Maçonnerie",
|
||||
description:
|
||||
"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 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-navy mb-10">Mentions Légales</h1>
|
||||
|
||||
<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>
|
||||
<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:${siteConfig.phoneRaw}`} className="text-orange hover:underline">{siteConfig.phone}</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>
|
||||
<p className="mt-3"><strong className="text-text">Directeur de la publication :</strong> Benoît COLIN</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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>
|
||||
<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>
|
||||
<h2 className="text-xl font-bold text-navy mb-4">4. Propriété intellectuelle</h2>
|
||||
<p>
|
||||
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>
|
||||
<h2 className="text-xl font-bold text-navy mb-4">5. Données personnelles</h2>
|
||||
<p>
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
604
app/(app)/page.tsx
Normal file
604
app/(app)/page.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
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";
|
||||
import {
|
||||
getSiteConfig,
|
||||
getServices,
|
||||
getTestimonials,
|
||||
getFAQ,
|
||||
getValues,
|
||||
getPartners,
|
||||
getRealisations,
|
||||
} from "@/lib/content";
|
||||
import type { Service, Testimonial, FAQItem } from "@/types/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
title: config.seo.title,
|
||||
description: config.seo.description,
|
||||
alternates: { canonical: config.url },
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Arrow SVG ── */
|
||||
function Arrow({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} 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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Service card — light bg, numbered ── */
|
||||
function ServiceCard({ service, index }: { service: Service; index: number }) {
|
||||
const href = service.slug === "conseil" ? "/contact" : `/${service.slug}`;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group block bg-white border border-border p-8 hover:border-orange hover:shadow-xl transition-all duration-300"
|
||||
>
|
||||
<div className="text-5xl font-black text-border group-hover:text-orange/25 transition-colors mb-6 leading-none tracking-tighter">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<h3 className="text-navy font-black text-base uppercase tracking-wider mb-3 group-hover:text-orange transition-colors">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-text-light text-sm leading-relaxed mb-7">{service.shortDescription}</p>
|
||||
<span className="btn-arrow text-orange text-xs uppercase tracking-widest">
|
||||
Découvrir
|
||||
<span className="arrow-icon">
|
||||
<Arrow className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Testimonial card — light bg ── */
|
||||
function TestimonialCard({ t }: { t: Testimonial }) {
|
||||
const serviceLabel: Record<string, string> = {
|
||||
"construction-maison": "Construction",
|
||||
renovation: "Rénovation",
|
||||
assainissement: "Assainissement",
|
||||
"creation-acces": "Création d'accès",
|
||||
demolition: "Démolition",
|
||||
};
|
||||
return (
|
||||
<div className="bg-white border border-border p-7 flex flex-col h-full">
|
||||
<div className="flex gap-0.5 mb-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<svg
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 ${i < t.rating ? "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>
|
||||
<p className="text-text-light text-sm leading-relaxed flex-1 italic mb-6">
|
||||
“{t.text}”
|
||||
</p>
|
||||
<div className="border-t border-border pt-5">
|
||||
<p className="text-navy font-bold text-sm">{t.name}</p>
|
||||
<p className="text-text-muted text-xs uppercase tracking-wider mt-0.5">
|
||||
{t.ville} — {serviceLabel[t.service] ?? t.service}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── FAQ item ── */
|
||||
function FAQItem({ item }: { item: FAQItem }) {
|
||||
return (
|
||||
<details className="group border-b border-border last:border-0">
|
||||
<summary className="flex items-center justify-between py-5 cursor-pointer list-none select-none">
|
||||
<span className="text-navy font-bold text-sm pr-6 group-open:text-orange transition-colors">
|
||||
{item.question}
|
||||
</span>
|
||||
<span className="faq-icon shrink-0 w-5 h-5 flex items-center justify-center text-orange">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="pb-5 text-text-light text-sm leading-relaxed -mt-1">
|
||||
{item.answer}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/* Photos Unsplash construction / maçonnerie */
|
||||
const HERO_PHOTO =
|
||||
"https://images.unsplash.com/photo-1504307651254-35680f356dfd?auto=format&fit=crop&w=1920&q=80";
|
||||
const CTA_PHOTO =
|
||||
"https://images.unsplash.com/photo-1541888946425-d81bb19240f5?auto=format&fit=crop&w=1920&q=80";
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
PAGE PRINCIPALE
|
||||
══════════════════════════════════════════════════ */
|
||||
export default async function HomePage() {
|
||||
const [config, services, testimonials, faqItems, values, partners, realisations] =
|
||||
await Promise.all([
|
||||
getSiteConfig(),
|
||||
getServices(),
|
||||
getTestimonials(),
|
||||
getFAQ(),
|
||||
getValues(),
|
||||
getPartners(),
|
||||
getRealisations(),
|
||||
]);
|
||||
|
||||
const { hero, zones, zoneDescription, phone, phoneRaw } = config;
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* ══ 1 — HERO PHOTO ══ */}
|
||||
<section className="relative min-h-screen flex items-center overflow-hidden">
|
||||
{/* Photo de fond */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div
|
||||
className="w-full h-full bg-center bg-cover"
|
||||
style={{ backgroundImage: `url('${HERO_PHOTO}')` }}
|
||||
/>
|
||||
{/* Gradient gauche → droite pour lisibilité du texte */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/85 via-black/60 to-black/20" />
|
||||
{/* Gradient bas → haut pour les stats */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/55 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full py-24 md:py-32">
|
||||
<div className="grid lg:grid-cols-12 gap-12 items-center">
|
||||
|
||||
{/* Contenu gauche */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="animate-hero-text-1 flex items-center gap-3 mb-7">
|
||||
<div className="w-8 h-px bg-orange" />
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em]">
|
||||
Maçon & Constructeur · Nord 59
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="animate-hero-text-2 text-[clamp(2.8rem,7vw,5.5rem)] font-black text-white leading-[0.9] tracking-tight uppercase mb-8">
|
||||
Maçon<br />
|
||||
<span className="text-orange">&</span>{" "}
|
||||
Construc<span className="text-white/30">teur</span>
|
||||
</h1>
|
||||
|
||||
<p className="animate-hero-text-3 text-white/70 text-base md:text-lg max-w-md leading-relaxed mb-10">
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="animate-hero-text-3 flex flex-col sm:flex-row gap-4">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="btn btn-fill px-8 py-4 text-xs uppercase tracking-[0.2em]"
|
||||
>
|
||||
<span>{hero.cta}</span>
|
||||
<span className="relative z-10"><Arrow className="w-4 h-4" /></span>
|
||||
</Link>
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
className="btn btn-outline-light px-8 py-4 text-xs uppercase tracking-[0.2em]"
|
||||
>
|
||||
<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="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>{phone}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-14 pt-10 border-t border-white/15 flex gap-10 flex-wrap">
|
||||
{hero.stats.map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-3xl md:text-4xl font-black text-white">{s.val}</div>
|
||||
<div className="text-white/40 text-xs uppercase tracking-widest mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carte glassmorphisme droite */}
|
||||
<div className="hidden lg:block lg:col-span-5">
|
||||
<div className="bg-white/10 backdrop-blur-sm border border-white/20 p-8">
|
||||
<p className="text-white/55 text-xs font-bold uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
|
||||
<span className="w-5 h-px bg-orange" />
|
||||
Pourquoi OBC Maçonnerie ?
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
{values.slice(0, 3).map((v) => (
|
||||
<div key={v.title} className="flex items-start gap-4">
|
||||
<div className="w-8 h-8 border border-orange/50 bg-orange/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="w-2 h-2 bg-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-bold text-sm uppercase tracking-wider">{v.title}</p>
|
||||
<p className="text-white/55 text-xs mt-1 leading-relaxed">{v.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 2 — NOS SERVICES (fond blanc) ══ */}
|
||||
<section className="bg-white py-20 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="flex items-end justify-between mb-12 gap-6 flex-wrap">
|
||||
<div>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-3">
|
||||
Nos savoir-faire
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-3xl md:text-5xl uppercase leading-tight tracking-tight">
|
||||
Nos<br />services
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/services"
|
||||
className="btn-arrow text-text-muted hover:text-navy text-xs uppercase tracking-widest"
|
||||
>
|
||||
Tous les services
|
||||
<span className="arrow-icon"><Arrow className="w-3.5 h-3.5" /></span>
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((s, i) => (
|
||||
<ScrollReveal key={s.slug} direction="up" delay={i * 60}>
|
||||
<ServiceCard service={s} index={i} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 3 — BANDE CTA (photo + overlay rouge brique) ══ */}
|
||||
<section className="relative py-16 md:py-20 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url('${CTA_PHOTO}')`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-orange/90" />
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: "repeating-linear-gradient(45deg, #fff 0px, #fff 1px, transparent 1px, transparent 50%)",
|
||||
backgroundSize: "20px 20px",
|
||||
}} />
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<ScrollReveal direction="up">
|
||||
<p className="text-white/70 text-xs font-bold uppercase tracking-[0.3em] mb-4">
|
||||
Devis gratuit — Réponse sous 24h
|
||||
</p>
|
||||
<h2 className="text-white font-black text-3xl md:text-5xl uppercase leading-tight tracking-tight mb-8">
|
||||
Votre projet mérite<br />
|
||||
un artisan de confiance
|
||||
</h2>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="btn btn-fill-white px-8 py-4 text-xs uppercase tracking-[0.2em]"
|
||||
>
|
||||
<span>Obtenir mon devis gratuit</span>
|
||||
<span><Arrow className="w-4 h-4" /></span>
|
||||
</Link>
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
className="btn btn-outline-light px-8 py-4 text-xs uppercase tracking-[0.2em]"
|
||||
>
|
||||
<span>Appeler Benoît — {phone}</span>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 4 — RÉALISATIONS ══ */}
|
||||
<section className="py-20 md:py-24 bg-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="flex items-end justify-between mb-12 flex-wrap gap-6">
|
||||
<div>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-3">
|
||||
Nos chantiers
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-3xl md:text-5xl uppercase leading-tight tracking-tight">
|
||||
Réalisations
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/realisations"
|
||||
className="btn-arrow text-text-muted hover:text-navy text-xs uppercase tracking-widest"
|
||||
>
|
||||
Voir tout
|
||||
<span className="arrow-icon"><Arrow className="w-3.5 h-3.5" /></span>
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{realisations.map((r, i) => {
|
||||
const bgColors = [
|
||||
"bg-stone-500", "bg-zinc-600", "bg-stone-600",
|
||||
"bg-zinc-500", "bg-stone-700", "bg-zinc-700",
|
||||
];
|
||||
return (
|
||||
<ScrollReveal key={r.title} direction="up" delay={i * 80}>
|
||||
<div className="realisation-card relative overflow-hidden aspect-[4/3] cursor-pointer group">
|
||||
<div className={`${bgColors[i % bgColors.length]} w-full h-full flex items-center justify-center`}>
|
||||
<span className="text-white/10 font-black text-9xl select-none">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Barre orange repos */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-orange/50 transition-all duration-500 group-hover:h-full group-hover:bg-orange/0" />
|
||||
{/* Infos au repos */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5 bg-gradient-to-t from-black/75 to-transparent translate-y-0 group-hover:opacity-0 transition-opacity duration-300">
|
||||
<span className="text-white/50 text-xs uppercase tracking-widest block mb-1">{r.ville}</span>
|
||||
<h3 className="text-white font-black text-sm uppercase tracking-wide">{r.title}</h3>
|
||||
</div>
|
||||
{/* Overlay hover */}
|
||||
<div className="realisation-overlay absolute inset-0 bg-orange flex flex-col justify-end p-6">
|
||||
<span className="text-white/60 text-xs uppercase tracking-[0.2em] mb-2">{r.ville}</span>
|
||||
<h3 className="text-white font-black text-lg uppercase tracking-tight mb-2">{r.title}</h3>
|
||||
<p className="text-white/75 text-xs leading-relaxed">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 5 — TÉMOIGNAGES (fond pierre clair) ══ */}
|
||||
<section className="py-20 md:py-24 bg-stone-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="mb-14">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-3">
|
||||
Ce qu'ils en disent
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-3xl md:text-5xl uppercase leading-tight tracking-tight">
|
||||
Clients<br />satisfaits
|
||||
</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{testimonials.map((t, i) => (
|
||||
<ScrollReveal key={t.name} direction="up" delay={i * 100}>
|
||||
<TestimonialCard t={t} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 6 — ZONE D'INTERVENTION ══ */}
|
||||
<section className="py-20 md:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<ScrollReveal direction="left">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Secteur d'activité
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-3xl md:text-5xl uppercase leading-tight tracking-tight mb-6">
|
||||
On intervient<br />dans tout le Nord
|
||||
</h2>
|
||||
<p className="text-text-light text-sm leading-relaxed mb-8 max-w-md">
|
||||
OBC Maçonnerie rayonne sur {zoneDescription} autour de Mouchin — de Douai à Valenciennes, Orchies et Saint-Amand-les-Eaux.
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="btn btn-outline-dark px-7 py-3.5 text-xs uppercase tracking-widest"
|
||||
>
|
||||
<span>Vérifier ma commune</span>
|
||||
<span><Arrow className="w-4 h-4" /></span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal direction="right">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zones.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1.5 bg-bg border border-border text-navy font-bold text-xs px-4 py-2.5 uppercase tracking-wider hover:border-orange hover:text-orange transition-all duration-200 cursor-default"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-orange shrink-0" />
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
<span className="inline-flex items-center gap-1.5 bg-orange/8 border border-orange/25 text-orange font-bold text-xs px-4 py-2.5 uppercase tracking-wider">
|
||||
+ communes voisines
|
||||
</span>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 7 — PARTENAIRES ══ */}
|
||||
<section className="py-16 bg-bg border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 mb-10">
|
||||
<div>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-2">
|
||||
Un réseau solide
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-xl md:text-2xl uppercase tracking-tight">
|
||||
Nos partenaires
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/partenaires"
|
||||
className="btn-arrow text-text-muted hover:text-navy text-xs uppercase tracking-widest"
|
||||
>
|
||||
Découvrir le réseau
|
||||
<span className="arrow-icon"><Arrow className="w-3.5 h-3.5" /></span>
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-3">
|
||||
{partners.map((p, i) => (
|
||||
<ScrollReveal key={p.label} direction="up" delay={i * 40}>
|
||||
<div className="group bg-white border border-border p-4 text-center hover:border-orange transition-all duration-200 cursor-default">
|
||||
<span className="text-navy font-bold text-xs uppercase tracking-wide group-hover:text-orange transition-colors leading-tight block">
|
||||
{p.label}
|
||||
</span>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 8 — FAQ ══ */}
|
||||
<section className="py-20 md:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-16">
|
||||
<ScrollReveal direction="left">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Questions fréquentes
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-3xl md:text-5xl uppercase leading-tight tracking-tight mb-6">
|
||||
On répond<br />à vos questions
|
||||
</h2>
|
||||
<p className="text-text-light text-sm leading-relaxed mb-8 max-w-sm">
|
||||
Pas de réponse à votre question ? Appelez directement Benoît au{" "}
|
||||
<a href={`tel:${phoneRaw}`} className="text-orange font-bold hover:underline">{phone}</a>.
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="btn btn-fill px-7 py-3.5 text-xs uppercase tracking-widest"
|
||||
>
|
||||
<span>Poser ma question</span>
|
||||
<span><Arrow className="w-4 h-4" /></span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal direction="right">
|
||||
<div>
|
||||
{faqItems.map((f) => (
|
||||
<FAQItem key={f.question} item={f} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══ 9 — CONTACT SPLIT ══ */}
|
||||
<section id="contact" className="grid lg:grid-cols-2">
|
||||
{/* Gauche — sombre avec photo en arrière-plan */}
|
||||
<div className="relative bg-navy py-16 md:py-20 px-8 md:px-12 lg:px-16 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-15"
|
||||
style={{
|
||||
backgroundImage: `url('${HERO_PHOTO}')`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center right",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-navy/80" />
|
||||
<div className="relative z-10">
|
||||
<ScrollReveal direction="left">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Contactez-nous
|
||||
</span>
|
||||
<h2 className="text-white font-black text-3xl md:text-4xl uppercase leading-tight tracking-tight mb-8">
|
||||
Parlons de<br />votre projet
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-10">
|
||||
<a href={`tel:${phoneRaw}`} className="flex items-center gap-4 group">
|
||||
<div className="w-11 h-11 border border-orange/40 flex items-center justify-center shrink-0 group-hover:border-orange group-hover:bg-orange/10 transition-all">
|
||||
<svg className="w-5 h-5 text-orange" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-0.5">Téléphone</p>
|
||||
<p className="text-white font-bold text-lg group-hover:text-orange transition-colors">{phone}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 border border-white/15 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5 text-white/40" 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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-0.5">Siège</p>
|
||||
<p className="text-white/70 text-sm">{config.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 border border-white/15 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs uppercase tracking-widest mb-0.5">Délai de réponse</p>
|
||||
<p className="text-white/70 text-sm">Sous 24h — Du lundi au samedi</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Garanties */}
|
||||
<div className="border-t border-white/10 pt-8 grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ val: "Gratuit", label: "Devis" },
|
||||
{ val: "24h", label: "Réponse" },
|
||||
{ val: "15+", label: "Ans d'expérience" },
|
||||
{ val: "100%", label: "Satisfaction" },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-2xl font-black text-orange">{s.val}</div>
|
||||
<div className="text-white/35 text-xs uppercase tracking-wider mt-0.5">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Droite — formulaire */}
|
||||
<div className="bg-stone-bg py-16 md:py-20 px-8 md:px-12 lg:px-16">
|
||||
<ScrollReveal direction="right">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Devis gratuit
|
||||
</span>
|
||||
<h3 className="text-navy font-black text-2xl uppercase tracking-tight mb-8">
|
||||
Votre demande
|
||||
</h3>
|
||||
<ContactForm />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
132
app/(app)/partenaires/page.tsx
Normal file
132
app/(app)/partenaires/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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 { getPartners, getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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: `${config.url}/partenaires` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PartenairesPage() {
|
||||
const partenaires = await getPartners();
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-20 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Un réseau de confiance
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white uppercase leading-none tracking-tight">
|
||||
Nos<br />partenaires
|
||||
</h1>
|
||||
<p className="text-white/50 text-base mt-5 max-w-2xl">
|
||||
Seul on va vite, ensemble on va plus loin. Benoît coordonne un réseau d'artisans sélectionnés pour que votre maison prenne forme de A à Z.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Message interlocuteur unique */}
|
||||
<section className="bg-stone-bg border-b border-border py-12 md:py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-center">
|
||||
<div>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Notre approche
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-2xl md:text-3xl uppercase leading-tight tracking-tight mb-4">
|
||||
Un seul interlocuteur<br />pour tout votre projet
|
||||
</h2>
|
||||
<p className="text-text-light text-sm leading-relaxed">
|
||||
Benoît Colin sélectionne et coordonne des artisans avec lesquels il travaille depuis des années. Vous n'avez qu'un seul contact pour piloter l'intégralité de votre chantier — dans les délais et le budget convenus.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ val: "10+", label: "Corps de métier" },
|
||||
{ val: "15+", label: "Ans de partenariat" },
|
||||
{ val: "100%", label: "Artisans qualifiés" },
|
||||
{ val: "1 seul", label: "Interlocuteur" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="border-l-2 border-orange pl-4">
|
||||
<div className="text-2xl font-black text-orange">{s.val}</div>
|
||||
<div className="text-text-muted text-xs uppercase tracking-wider mt-0.5">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Grille partenaires */}
|
||||
<section className="py-16 md:py-20 bg-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-3">
|
||||
Corps de métier
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-2xl md:text-3xl uppercase tracking-tight mb-10">
|
||||
Nos domaines d'expertise
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{partenaires.map((p, i) => (
|
||||
<ScrollReveal key={p.label} direction="up" delay={i * 60}>
|
||||
<div className="group bg-bg-white border border-border hover:border-orange p-6 h-full transition-all duration-200 cursor-default">
|
||||
<div className="text-3xl mb-4 grayscale group-hover:grayscale-0 transition-all">{p.icon}</div>
|
||||
<h3 className="text-navy font-black text-base uppercase tracking-wide mb-2 group-hover:text-orange transition-colors">
|
||||
{p.label}
|
||||
</h3>
|
||||
<p className="text-text-light text-xs leading-relaxed">{p.desc}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA dark */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-20 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Projet de A à Z
|
||||
</span>
|
||||
<h2 className="text-white font-black text-3xl md:text-4xl uppercase leading-tight tracking-tight mb-8">
|
||||
Que vous construisiez ou rénowiez,<br />
|
||||
OBC orchestre chaque corps de métier
|
||||
</h2>
|
||||
<Link href="/contact" className="btn btn-fill px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>Parler de mon projet à Benoît</span>
|
||||
<span>
|
||||
<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>
|
||||
</span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
130
app/(app)/realisations/page.tsx
Normal file
130
app/(app)/realisations/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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 { getRealisations, getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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: `${config.url}/realisations` },
|
||||
};
|
||||
}
|
||||
|
||||
const cats = ["Tous", "Construction neuve", "Rénovation", "Assainissement", "Création d'accès", "Démolition"];
|
||||
|
||||
export default async function RealisationsPage() {
|
||||
const [realisations, config] = await Promise.all([getRealisations(), getSiteConfig()]);
|
||||
const { phone, phoneRaw } = config;
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-20 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Portfolio — Chantiers Nord (59)
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white uppercase leading-none tracking-tight">
|
||||
Nos<br />réalisations
|
||||
</h1>
|
||||
<p className="text-white/50 text-base mt-5 max-w-xl">
|
||||
Chaque chantier est unique. Découvrez quelques-unes de nos réalisations dans le Nord.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filtres */}
|
||||
<section className="bg-bg border-b border-border py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cats.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className={`text-xs font-bold uppercase tracking-wider px-4 py-2 cursor-default transition-colors ${
|
||||
cat === "Tous"
|
||||
? "bg-navy text-white"
|
||||
: "bg-bg-white border border-border text-text-light hover:border-orange hover:text-orange"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Galerie */}
|
||||
<section className="py-16 md:py-20 bg-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{realisations.map((r, i) => {
|
||||
const bgColors = ["bg-navy", "bg-stone", "bg-navy-light", "bg-stone", "bg-navy", "bg-navy-light"];
|
||||
return (
|
||||
<ScrollReveal key={r.title} direction="up" delay={i * 80}>
|
||||
<div className="realisation-card relative overflow-hidden aspect-[4/3] group cursor-pointer">
|
||||
{/* Fond */}
|
||||
<div className={`${bgColors[i % bgColors.length]} w-full h-full flex items-center justify-center`}>
|
||||
<span className="text-white/8 font-black text-9xl select-none">0{i + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Badge catégorie */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<span className="bg-orange text-white text-xs font-bold px-3 py-1 uppercase tracking-wider">
|
||||
{r.categorie}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Infos au repos */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5 bg-gradient-to-t from-black/70 to-transparent group-hover:opacity-0 transition-opacity duration-300">
|
||||
<span className="text-white/50 text-xs uppercase tracking-widest block mb-1">{r.ville}</span>
|
||||
<h3 className="text-white font-black text-sm uppercase tracking-wide">{r.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Overlay hover */}
|
||||
<div className="realisation-overlay absolute inset-0 bg-orange flex flex-col justify-end p-6">
|
||||
<span className="text-white/60 text-xs uppercase tracking-[0.2em] mb-2">{r.ville}</span>
|
||||
<h3 className="text-white font-black text-lg uppercase tracking-tight mb-2">{r.title}</h3>
|
||||
<p className="text-white/75 text-sm leading-relaxed">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA bottom */}
|
||||
<ScrollReveal direction="up" delay={200}>
|
||||
<div className="mt-16 bg-navy py-12 px-8 md:px-12 text-center">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-3">
|
||||
Un projet similaire ?
|
||||
</span>
|
||||
<h2 className="text-white font-black text-2xl md:text-3xl uppercase tracking-tight mb-6">
|
||||
Benoît se déplace gratuitement<br />pour évaluer votre chantier
|
||||
</h2>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/contact" className="btn btn-fill px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>Demander un devis gratuit</span>
|
||||
</Link>
|
||||
<a href={`tel:${phoneRaw}`} className="btn btn-outline-light px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>{phone}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
24
app/(app)/renovation-maison-douai/page.tsx
Normal file
24
app/(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/(app)/renovation-maison-orchies/page.tsx
Normal file
24
app/(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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
app/(app)/renovation/page.tsx
Normal file
54
app/(app)/renovation/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import ServicePageLayout from "@/components/marketing/ServicePageLayout";
|
||||
import { getSiteConfig } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getSiteConfig();
|
||||
return {
|
||||
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"],
|
||||
alternates: { canonical: `${config.url}/renovation` },
|
||||
};
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ 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 async function RenovationPage() {
|
||||
const config = await getSiteConfig();
|
||||
const { phone, phoneRaw } = config;
|
||||
return (
|
||||
<ServicePageLayout
|
||||
label="Rénovation"
|
||||
title="Rénovation maison & appartement dans le Nord"
|
||||
subtitle="Chaque rénovation est unique. Benoît Colin s'adapte à votre projet, votre budget et vos envies pour transformer votre logement."
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
items={items}
|
||||
itemsSectionTitle="Nos spécialités en rénovation"
|
||||
seoTitle="Maçon rénovation dans le Nord (59)"
|
||||
seoText={
|
||||
<>
|
||||
<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.
|
||||
</p>
|
||||
<p>
|
||||
Sa passion : adapter les espaces. Modifier une cage d'escalier, abattre une cloison, 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>
|
||||
</>
|
||||
}
|
||||
contactTitle="Votre projet de rénovation"
|
||||
/>
|
||||
);
|
||||
}
|
||||
139
app/(app)/services/page.tsx
Normal file
139
app/(app)/services/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
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 { getServices, getSiteConfig } from "@/lib/content";
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ServicesPage() {
|
||||
const [services, config] = await Promise.all([getServices(), getSiteConfig()]);
|
||||
const { phone, phoneRaw } = config;
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-20 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
OBC Maçonnerie — Nord (59)
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white uppercase leading-none tracking-tight">
|
||||
Nos services<br />de maçonnerie
|
||||
</h1>
|
||||
<p className="text-white/50 text-base mt-5 max-w-xl">
|
||||
Construction, rénovation, assainissement et gros œuvre — Benoît Colin vous accompagne de A à Z.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services list — dark editorial */}
|
||||
<section className="bg-navy-light py-16 md:py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="space-y-px">
|
||||
{services.map((s, i) => {
|
||||
const href = s.slug === "conseil" ? "/contact" : `/${s.slug}`;
|
||||
return (
|
||||
<ScrollReveal key={s.slug} direction="up" delay={i * 60}>
|
||||
<div className="group bg-white/[0.02] border-t border-white/8 hover:bg-white/[0.05] transition-all duration-300">
|
||||
<div className="max-w-7xl mx-auto px-0 py-8 grid md:grid-cols-12 gap-6 items-start">
|
||||
|
||||
{/* Numéro + icône */}
|
||||
<div className="md:col-span-2 flex items-center gap-4 md:flex-col md:items-start md:gap-2">
|
||||
<span className="text-white/15 font-black text-3xl md:text-4xl leading-none">
|
||||
0{i + 1}
|
||||
</span>
|
||||
<span className="text-2xl">{s.icon}</span>
|
||||
</div>
|
||||
|
||||
{/* Titre + description */}
|
||||
<div className="md:col-span-7">
|
||||
<h2 className="text-white font-black text-xl md:text-2xl uppercase tracking-tight mb-3 group-hover:text-orange transition-colors">
|
||||
{s.title}
|
||||
</h2>
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-4">
|
||||
{s.longDescription}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{s.keywords.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="border border-white/10 text-white/35 text-xs font-bold px-3 py-1 uppercase tracking-wide"
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="md:col-span-3 flex md:justify-end md:pt-1">
|
||||
<Link
|
||||
href={href}
|
||||
className="btn-arrow text-orange text-xs uppercase tracking-widest"
|
||||
>
|
||||
En savoir plus
|
||||
<span className="arrow-icon"><Arrow /></span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA band */}
|
||||
<section className="bg-orange py-16 md:py-20 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: "repeating-linear-gradient(45deg, #fff 0px, #fff 1px, transparent 1px, transparent 50%)",
|
||||
backgroundSize: "20px 20px"
|
||||
}} />
|
||||
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<ScrollReveal direction="up">
|
||||
<h2 className="text-white font-black text-3xl md:text-4xl uppercase leading-tight tracking-tight mb-8">
|
||||
Vous avez un projet ?<br />
|
||||
Parlons-en.
|
||||
</h2>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/contact" className="btn btn-fill-white px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>Devis gratuit</span>
|
||||
<span>
|
||||
<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>
|
||||
</span>
|
||||
</Link>
|
||||
<a href={`tel:${phoneRaw}`} className="btn btn-outline-light px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>{phone}</span>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
app/(payload)/gestion59/[[...segments]]/page.tsx
Normal file
36
app/(payload)/gestion59/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||
import type { Metadata } from 'next';
|
||||
import config from '../../../../payload.config';
|
||||
import { importMap } from '../importMap';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type PayloadRouteParams = {
|
||||
segments?: string[];
|
||||
};
|
||||
|
||||
type PayloadRouteSearchParams = {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
params: Promise<PayloadRouteParams>;
|
||||
searchParams: Promise<PayloadRouteSearchParams>;
|
||||
};
|
||||
|
||||
export const generateMetadata = (props: Props): Promise<Metadata> => {
|
||||
return generatePageMetadata({
|
||||
config,
|
||||
params: props.params as Promise<Record<string, string | string[]>>,
|
||||
searchParams: props.searchParams as Promise<Record<string, string | string[]>>,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page(props: Props) {
|
||||
return RootPage({
|
||||
config,
|
||||
importMap,
|
||||
params: props.params as Promise<{ segments: string[] }>,
|
||||
searchParams: props.searchParams as Promise<Record<string, string | string[]>>,
|
||||
});
|
||||
}
|
||||
7
app/(payload)/gestion59/api/[...slug]/route.ts
Normal file
7
app/(payload)/gestion59/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
|
||||
import config from '../../../../../payload.config'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
1
app/(payload)/gestion59/importMap.ts
Normal file
1
app/(payload)/gestion59/importMap.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const importMap = {}
|
||||
23
app/(payload)/layout.tsx
Normal file
23
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts';
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import React from 'react';
|
||||
import config from '../../payload.config';
|
||||
import { importMap } from './gestion59/importMap';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const serverFunction: ServerFunctionClient = async (args) => {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
return RootLayout({
|
||||
config,
|
||||
importMap,
|
||||
serverFunction,
|
||||
children,
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
if (!name || !phone || !metier || !ville) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tous les champs sont requis." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "Service email non configuré." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `Nouvelle demande d'audit - ${name} (${metier})`,
|
||||
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 24px 0;color:#111827;font-size:20px;">Nouvelle demande d'audit gratuit</h2>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error("Erreur API contact:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur. Veuillez réessayer." },
|
||||
{ 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("");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
136
app/cgv/page.tsx
136
app/cgv/page.tsx
@@ -1,136 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Conditions Générales de Vente",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Article 2 - Description du service</h2>
|
||||
<p>
|
||||
HookLab est un programme de coaching en ligne d'une durée de 8 semaines, comprenant :
|
||||
</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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Article 3 - Prix et modalités de paiement</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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Article 4 - Processus de candidature</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é.
|
||||
</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>
|
||||
<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>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-white/40 pt-4 border-t border-dark-border">
|
||||
Dernière mise à jour : février 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Politique de Confidentialité | HookLab",
|
||||
description:
|
||||
"Politique de confidentialité et protection des données personnelles du site HookLab.eu, conformément au RGPD.",
|
||||
alternates: {
|
||||
canonical: "https://hooklab.eu/confidentialite",
|
||||
},
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">8. Vos droits (RGPD)</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.
|
||||
</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>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">9. 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-white/40 pt-4 border-t border-dark-border">
|
||||
Dernière mise à jour : Février 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
413
app/globals.css
413
app/globals.css
@@ -1,413 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--color-navy: #1B2A4A;
|
||||
--color-navy-light: #2A3D66;
|
||||
--color-navy-dark: #111D36;
|
||||
|
||||
--color-orange: #E8772E;
|
||||
--color-orange-hover: #D06522;
|
||||
--color-orange-light: #F5A623;
|
||||
|
||||
--color-bg: #F7F8FA;
|
||||
--color-bg-white: #FFFFFF;
|
||||
--color-bg-card: #FFFFFF;
|
||||
--color-bg-muted: #F0F2F5;
|
||||
|
||||
--color-text: #1A1A2E;
|
||||
--color-text-light: #6B7280;
|
||||
--color-text-muted: #9CA3AF;
|
||||
|
||||
--color-border: #E5E7EB;
|
||||
--color-border-light: #F3F4F6;
|
||||
|
||||
--color-success: #10B981;
|
||||
--color-warning: #F59E0B;
|
||||
--color-error: #EF4444;
|
||||
|
||||
--color-primary: #6D5EF6;
|
||||
--color-primary-hover: #5B4FDB;
|
||||
|
||||
--color-dark: #0B0F19;
|
||||
--color-dark-bg: #0B0F19;
|
||||
--color-dark-light: #1A1F2E;
|
||||
--color-dark-lighter: #252A3A;
|
||||
--color-dark-border: #2A2F3F;
|
||||
|
||||
--font-sans: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Gradient utilities (used by login, admin, candidature pages) */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #6D5EF6, #9D8FF9);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #6D5EF6, #9D8FF9);
|
||||
}
|
||||
|
||||
/* Card hover */
|
||||
.card-hover {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(27, 42, 74, 0.12);
|
||||
}
|
||||
|
||||
/* Orange CTA glow */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 16px rgba(232, 119, 46, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 32px rgba(232, 119, 46, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-glow {
|
||||
animation: pulse-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgba(27, 42, 74, 0.15);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Skip to content */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
z-index: 999;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--color-navy);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
border-radius: 0 0 8px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Focus visible */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-navy);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
HERO ANIMATIONS - Staggered text reveal
|
||||
================================================ */
|
||||
@keyframes hero-text-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-hero-text-1 {
|
||||
opacity: 0;
|
||||
animation: hero-text-appear 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
||||
}
|
||||
|
||||
.animate-hero-text-2 {
|
||||
opacity: 0;
|
||||
animation: hero-text-appear 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.3s forwards;
|
||||
}
|
||||
|
||||
.animate-hero-text-3 {
|
||||
opacity: 0;
|
||||
animation: hero-text-appear 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
FADE IN ANIMATIONS
|
||||
================================================ */
|
||||
@keyframes fade-in-down {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
opacity: 0;
|
||||
animation: fade-in-down 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.animation-delay-200 { animation-delay: 200ms; }
|
||||
.animation-delay-400 { animation-delay: 400ms; }
|
||||
.animation-delay-600 { animation-delay: 600ms; }
|
||||
.animation-delay-800 { animation-delay: 800ms; }
|
||||
.animation-delay-1000 { animation-delay: 1000ms; }
|
||||
|
||||
/* ================================================
|
||||
UNDERLINE GROW ANIMATION
|
||||
================================================ */
|
||||
@keyframes underline-grow {
|
||||
0% {
|
||||
width: 0;
|
||||
}
|
||||
100% {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-underline-grow {
|
||||
animation: underline-grow 1s cubic-bezier(0.16, 1, 0.3, 1) 0.8s forwards;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
BOUNCE SLOW (scroll indicator)
|
||||
================================================ */
|
||||
@keyframes bounce-slow {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-slow {
|
||||
animation: bounce-slow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
FLOATING ELEMENTS
|
||||
================================================ */
|
||||
@keyframes float-slow {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) scale(1.1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-medium {
|
||||
0%, 100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-15px) translateX(10px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-25px) translateX(-5px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-fast {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-12px) rotate(180deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float-slow {
|
||||
animation: float-slow 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-medium {
|
||||
animation: float-medium 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-fast {
|
||||
animation: float-fast 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
ROCKET FLAME ANIMATION
|
||||
================================================ */
|
||||
@keyframes flame-flicker {
|
||||
0%, 100% {
|
||||
transform: scaleY(1) scaleX(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
25% {
|
||||
transform: scaleY(1.1) scaleX(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(0.9) scaleX(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
transform: scaleY(1.05) scaleX(0.97);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-flame {
|
||||
transform-origin: center top;
|
||||
animation: flame-flicker 0.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
PARTICLE ANIMATIONS (rocket trail)
|
||||
================================================ */
|
||||
@keyframes particle-1 {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(8px) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particle-2 {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(12px) scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particle-3 {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(10px) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-particle-1 { animation: particle-1 1.2s ease-in-out infinite; }
|
||||
.animate-particle-2 { animation: particle-2 1.5s ease-in-out infinite 0.2s; }
|
||||
.animate-particle-3 { animation: particle-3 1.3s ease-in-out infinite 0.4s; }
|
||||
|
||||
/* ================================================
|
||||
SCROLL REVEAL ANIMATIONS
|
||||
================================================ */
|
||||
.scroll-reveal-up,
|
||||
.scroll-reveal-down,
|
||||
.scroll-reveal-left,
|
||||
.scroll-reveal-right,
|
||||
.scroll-reveal-fade {
|
||||
opacity: 0;
|
||||
transition-property: opacity, transform;
|
||||
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.scroll-reveal-up {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
|
||||
.scroll-reveal-down {
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
|
||||
.scroll-reveal-left {
|
||||
transform: translateX(-40px);
|
||||
}
|
||||
|
||||
.scroll-reveal-right {
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
.scroll-reveal-fade {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Revealed state */
|
||||
.scroll-revealed {
|
||||
opacity: 1 !important;
|
||||
transform: translateY(0) translateX(0) scale(1) !important;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
ANIMATED COUNTER GLOW
|
||||
================================================ */
|
||||
@keyframes stat-glow {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 10px rgba(232, 119, 46, 0.3);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 20px rgba(232, 119, 46, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-stat-glow {
|
||||
animation: stat-glow 2s ease-in-out infinite;
|
||||
}
|
||||
232
app/layout.tsx
232
app/layout.tsx
@@ -1,232 +0,0 @@
|
||||
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";
|
||||
|
||||
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",
|
||||
},
|
||||
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.",
|
||||
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",
|
||||
],
|
||||
authors: [{ name: "HookLab - Enguerrand Ozano" }],
|
||||
creator: "HookLab",
|
||||
publisher: "HookLab",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/apple-touch-icon.svg", type: "image/svg+xml", sizes: "180x180" },
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fr_FR",
|
||||
url: BASE_URL,
|
||||
siteName: "HookLab",
|
||||
title:
|
||||
"HookLab | Sites web pour artisans du b\u00e2timent dans le Nord",
|
||||
description:
|
||||
"Transformez votre bouche-\u00e0-oreille en syst\u00e8me automatique. Sites web et r\u00e9f\u00e9rencement Google pour artisans \u00e0 Douai, Orchies, Valenciennes.",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "HookLab - Sites web pour artisans du Nord",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "HookLab | Sites web pour artisans du Nord",
|
||||
description:
|
||||
"Agence web locale pour artisans du b\u00e2timent. Douai, Orchies, Valenciennes.",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
},
|
||||
verification: {
|
||||
google: process.env.GOOGLE_SITE_VERIFICATION || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Schema.org LocalBusiness
|
||||
const jsonLdOrganization = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/#organization`,
|
||||
name: "HookLab",
|
||||
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",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "35 rue Mo\u00efse Lambert",
|
||||
addressLocality: "Flines-lez-Raches",
|
||||
postalCode: "59148",
|
||||
addressRegion: "Hauts-de-France",
|
||||
addressCountry: "FR",
|
||||
},
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: 50.4267,
|
||||
longitude: 3.2372,
|
||||
},
|
||||
areaServed: [
|
||||
{ "@type": "City", name: "Douai" },
|
||||
{ "@type": "City", name: "Orchies" },
|
||||
{ "@type": "City", name: "Valenciennes" },
|
||||
{ "@type": "City", name: "Arleux" },
|
||||
{ "@type": "City", name: "Flines-lez-Raches" },
|
||||
{ "@type": "City", name: "Saint-Amand-les-Eaux" },
|
||||
{ "@type": "City", name: "Denain" },
|
||||
],
|
||||
priceRange: "$$",
|
||||
openingHoursSpecification: {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
opens: "09:00",
|
||||
closes: "18:00",
|
||||
},
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
telephone: "+33604408157",
|
||||
contactType: "customer service",
|
||||
availableLanguage: "French",
|
||||
},
|
||||
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",
|
||||
url: BASE_URL,
|
||||
description:
|
||||
"Cr\u00e9ation de sites internet et r\u00e9f\u00e9rencement Google pour artisans du b\u00e2timent dans le Nord (59).",
|
||||
publisher: {
|
||||
"@id": `${BASE_URL}/#organization`,
|
||||
},
|
||||
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 (
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta name="theme-color" content="#1B2A4A" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify([jsonLdOrganization, jsonLdWebSite, jsonLdNavigation]),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<a href="#main-content" className="skip-to-content">
|
||||
Aller au contenu principal
|
||||
</a>
|
||||
{children}
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mentions Légales | HookLab",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
export default function MentionsLegales() {
|
||||
return (
|
||||
<main className="min-h-screen py-20 md:py-32 bg-dark-bg">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6">
|
||||
{/* Bouton retour */}
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 mb-10 text-white/40 hover:text-white text-sm transition-colors group"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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.
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
<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.
|
||||
</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>
|
||||
<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>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-white/40 pt-8 border-t border-white/10 text-xs">
|
||||
Dernière mise à jour : Février 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
48
app/page.tsx
48
app/page.tsx
@@ -1,48 +0,0 @@
|
||||
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";
|
||||
|
||||
// Revalider les images toutes les 60 secondes
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function LandingPage() {
|
||||
const images = await getSiteImages();
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
{/* Navigation */}
|
||||
<Navbar />
|
||||
|
||||
{/* Hero - Le Choc Visuel */}
|
||||
<Hero images={images} />
|
||||
|
||||
{/* La Problématique - L'Identification */}
|
||||
<Problematique />
|
||||
|
||||
{/* Le Triptyque HookLab - Les 3 Piliers */}
|
||||
<Process images={images} />
|
||||
|
||||
{/* Démos Live - 3 Dossiers de Confiance */}
|
||||
<DemosLive images={images} />
|
||||
|
||||
{/* Qui suis-je - Ancrage Local */}
|
||||
<AboutMe images={images} />
|
||||
|
||||
{/* FAQ - Objections */}
|
||||
<FAQ />
|
||||
|
||||
{/* Contact / Audit CTA */}
|
||||
<Contact />
|
||||
|
||||
{/* Footer SEO */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://hooklab.eu";
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || siteConfig.url;
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
@@ -8,7 +9,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`,
|
||||
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
121
app/sitemap.ts
121
app/sitemap.ts
@@ -1,95 +1,50 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://hooklab.eu";
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || siteConfig.url;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
145
components/marketing/ContactForm.tsx
Normal file
145
components/marketing/ContactForm.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"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: "",
|
||||
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: "", zone: "" });
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<div className="border-t-4 border-success bg-bg-white p-8 text-center">
|
||||
<div className="w-12 h-12 bg-success/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-navy font-black text-lg uppercase tracking-wide mb-2">Demande envoyée !</h3>
|
||||
<p className="text-text-light text-sm">
|
||||
Benoît vous rappelle sous 24h. En cas d'urgence :{" "}
|
||||
<a href="tel:0674453089" className="text-orange font-bold hover:underline">
|
||||
06 74 45 30 89
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ic = "w-full border border-border bg-bg-white px-4 py-3 text-sm text-text focus:outline-none focus:border-orange transition-colors";
|
||||
const lc = "block text-xs font-bold uppercase tracking-widest text-navy mb-2";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="nom" className={lc}>Nom <span className="text-orange">*</span></label>
|
||||
<input id="nom" name="nom" type="text" value={form.nom} onChange={handleChange}
|
||||
placeholder="Votre nom" className={ic} required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="telephone" className={lc}>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={ic} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className={lc}>Email</label>
|
||||
<input id="email" name="email" type="email" value={form.email} onChange={handleChange}
|
||||
placeholder="votre@email.fr" className={ic} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="typeProjet" className={lc}>Type de projet <span className="text-orange">*</span></label>
|
||||
<select id="typeProjet" name="typeProjet" value={form.typeProjet} onChange={handleChange}
|
||||
className={ic} 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={lc}>Description du projet</label>
|
||||
<textarea id="description" name="description" value={form.description} onChange={handleChange}
|
||||
rows={4} placeholder="Surface, localisation, contraintes particulières..."
|
||||
className={`${ic} resize-none`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="zone" className={lc}>Commune / Zone</label>
|
||||
<input id="zone" name="zone" type="text" value={form.zone} onChange={handleChange}
|
||||
placeholder="ex : Orchies, Douai..." className={ic} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-error text-xs bg-stone-bg border border-error/30 px-4 py-3 uppercase tracking-wide">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={status === "sending"}
|
||||
className="btn btn-fill w-full py-4 text-xs uppercase tracking-[0.2em] disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<span>{status === "sending" ? "Envoi en cours..." : "Envoyer ma demande"}</span>
|
||||
</button>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-error text-xs text-center">
|
||||
Erreur. Appelez 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 uppercase tracking-widest">
|
||||
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,130 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ScrollReveal from "@/components/animations/ScrollReveal";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export default function Footer() {
|
||||
const {
|
||||
name,
|
||||
dirigeant,
|
||||
phone,
|
||||
phoneRaw,
|
||||
address,
|
||||
siren,
|
||||
footerServicesNav,
|
||||
footerMainNav,
|
||||
footerLegalNav,
|
||||
} = siteConfig;
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border py-10 md:py-12 bg-bg-white">
|
||||
<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">
|
||||
<footer className="bg-navy-dark text-white">
|
||||
{/* Top band */}
|
||||
<div className="border-b border-white/8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-10 md: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 className="md:col-span-4">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-11 h-11 bg-orange flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-black text-xs tracking-wider">OBC</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
|
||||
<div className="flex flex-col leading-none gap-0.5">
|
||||
<span className="text-white font-black text-sm tracking-[0.18em] uppercase">OBC</span>
|
||||
<span className="text-orange-light font-bold text-xs tracking-[0.18em] uppercase">Maçonnerie</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-6 max-w-xs">
|
||||
{dirigeant}, maçon expert en construction de maison, rénovation et gros œuvre dans le Nord (59).
|
||||
</p>
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
className="inline-flex items-center gap-2 text-orange font-bold text-base hover:text-white transition-colors group"
|
||||
>
|
||||
<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="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>
|
||||
{phone}
|
||||
</a>
|
||||
<p className="text-white/30 text-xs mt-2">{address}</p>
|
||||
</div>
|
||||
|
||||
{/* Expertises SEO */}
|
||||
<div>
|
||||
<h4 className="text-navy font-semibold text-sm mb-4">
|
||||
Expertises
|
||||
{/* Services */}
|
||||
<div className="md:col-span-3">
|
||||
<h4 className="text-white font-black text-xs uppercase tracking-[0.2em] mb-5 flex items-center gap-3">
|
||||
<span className="w-4 h-px bg-orange" />
|
||||
Services
|
||||
</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 className="space-y-2.5">
|
||||
{footerServicesNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-white/45 hover:text-white text-sm transition-colors hover:translate-x-1 inline-block duration-200"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</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>
|
||||
{/* Navigation */}
|
||||
<div className="md:col-span-2">
|
||||
<h4 className="text-white font-black text-xs uppercase tracking-[0.2em] mb-5 flex items-center gap-3">
|
||||
<span className="w-4 h-px bg-orange" />
|
||||
Navigation
|
||||
</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{footerMainNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-white/45 hover:text-white text-sm transition-colors hover:translate-x-1 inline-block duration-200"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Légal + CTA */}
|
||||
<div className="md:col-span-3">
|
||||
<h4 className="text-white font-black text-xs uppercase tracking-[0.2em] mb-5 flex items-center gap-3">
|
||||
<span className="w-4 h-px bg-orange" />
|
||||
Légal
|
||||
</h4>
|
||||
<ul className="space-y-2.5 mb-8">
|
||||
{footerLegalNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-white/45 hover:text-white text-sm transition-colors hover:translate-x-1 inline-block duration-200"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="btn btn-fill text-xs uppercase tracking-widest px-5 py-3 w-full justify-center"
|
||||
>
|
||||
<span>Devis gratuit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
{/* Bottom bar */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-5">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-3 text-white/25 text-xs">
|
||||
<p>
|
||||
© {new Date().getFullYear()} {name} — {dirigeant}{" "}
|
||||
· SIREN {siren.replace(/(\d{3})(\d{3})(\d{3})/, "$1 $2 $3")}
|
||||
</p>
|
||||
<p className="text-text-muted text-xs text-center md:text-right">
|
||||
Intervention : Douai · Orchies · Arleux · Valenciennes
|
||||
<p className="text-center">
|
||||
Orchies · Mouchin · Douai · Valenciennes · Saint-Amand-les-Eaux
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
255
components/marketing/LocalSEOPage.tsx
Normal file
255
components/marketing/LocalSEOPage.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
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";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
interface LocalSEOPageProps {
|
||||
ville: string;
|
||||
departement?: string;
|
||||
servicesPrincipaux: string[];
|
||||
description: string;
|
||||
texteIntro: string;
|
||||
texteLocal: string;
|
||||
distanceMouchin?: string;
|
||||
}
|
||||
|
||||
// Liens services statiques (évite toute inférence TypeScript complexe au niveau module)
|
||||
const SERVICE_LINKS: { label: string; href: string }[] = [
|
||||
{ label: "Construction de maison", href: "/construction-maison" },
|
||||
{ label: "Rénovation", href: "/renovation" },
|
||||
{ label: "Assainissement", href: "/assainissement" },
|
||||
{ label: "Création d'accès", href: "/creation-acces" },
|
||||
{ label: "Démolition", href: "/demolition" },
|
||||
];
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LocalSEOPage({
|
||||
ville,
|
||||
departement = "Nord (59)",
|
||||
servicesPrincipaux,
|
||||
description,
|
||||
texteIntro,
|
||||
texteLocal,
|
||||
distanceMouchin,
|
||||
}: LocalSEOPageProps) {
|
||||
const { phone, phoneRaw, zones } = siteConfig;
|
||||
|
||||
// Paragraphes du texte local (séparés par double saut de ligne)
|
||||
const paragraphes = texteLocal.split(/\n\n+/).filter(Boolean);
|
||||
|
||||
return (
|
||||
<main id="main-content" className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-navy texture-dark py-16 md:py-24 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center gap-2 text-white/40 hover:text-white text-xs font-bold uppercase tracking-widest mb-8 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Nos services
|
||||
</Link>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
{departement}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white uppercase leading-none tracking-tight mb-5 max-w-3xl">
|
||||
Maçon &<br />
|
||||
<span className="text-orange">{ville}</span>
|
||||
</h1>
|
||||
<p className="text-white/55 text-base md:text-lg max-w-xl mb-10">{description}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link href="/contact" className="btn btn-fill px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>Devis gratuit</span>
|
||||
<span><Arrow /></span>
|
||||
</Link>
|
||||
<a href={`tel:${phoneRaw}`} className="btn btn-outline-light px-8 py-4 text-xs uppercase tracking-[0.2em]">
|
||||
<span>{phone}</span>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="bg-stone-bg border-b border-border py-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ val: "Gratuit", label: "Devis + déplacement" },
|
||||
{ val: "24h", label: "Délai de réponse" },
|
||||
{ val: distanceMouchin ?? "< 30km", label: "de notre siège" },
|
||||
{ val: "15+", label: "Ans d'expérience" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="border-l-2 border-orange pl-4">
|
||||
<div className="text-xl font-black text-orange">{s.val}</div>
|
||||
<div className="text-text-muted text-xs uppercase tracking-wider mt-0.5 leading-tight">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services à cette ville */}
|
||||
<section className="bg-navy-light py-14 md:py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Nos prestations à {ville}
|
||||
</span>
|
||||
<h2 className="text-white font-black text-2xl md:text-3xl uppercase leading-tight tracking-tight mb-8">
|
||||
Services disponibles
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SERVICE_LINKS.map((s) => {
|
||||
const isPrimary = servicesPrincipaux.includes(s.label);
|
||||
return (
|
||||
<Link
|
||||
key={s.label}
|
||||
href={s.href}
|
||||
className={`group flex items-center gap-3 px-5 py-3 transition-all duration-200 border ${
|
||||
isPrimary
|
||||
? "border-orange bg-orange/10"
|
||||
: "border-white/15 hover:border-orange"
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-orange shrink-0" />
|
||||
<span className="text-white/70 group-hover:text-white text-sm font-bold uppercase tracking-wide transition-colors">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="text-orange group-hover:translate-x-1 transition-transform">
|
||||
<Arrow />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Texte intro + local */}
|
||||
<section className="py-16 md:py-20 bg-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 md:gap-16">
|
||||
<ScrollReveal direction="left">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Maçonnerie à {ville}
|
||||
</span>
|
||||
<h2 className="text-navy font-black text-2xl md:text-3xl uppercase leading-tight tracking-tight mb-6">
|
||||
Votre maçon local<br />dans le Nord
|
||||
</h2>
|
||||
<p className="text-text-light text-sm leading-relaxed mb-8">{texteIntro}</p>
|
||||
{distanceMouchin && (
|
||||
<p className="text-text-muted text-xs uppercase tracking-widest mb-6">
|
||||
{distanceMouchin} de notre siège (Mouchin, 59310)
|
||||
</p>
|
||||
)}
|
||||
<Link href="/contact" className="btn btn-outline-dark px-7 py-3.5 text-xs uppercase tracking-widest">
|
||||
<span>Prendre contact</span>
|
||||
<span><Arrow /></span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal direction="right">
|
||||
<div className="space-y-5">
|
||||
{paragraphes.map((p, i) => (
|
||||
<p key={i} className="text-text-light text-sm leading-relaxed border-l-2 border-border pl-5">
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Zone communes voisines */}
|
||||
<section className="py-12 bg-stone-bg border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal direction="up">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-2">
|
||||
Zone d'intervention
|
||||
</span>
|
||||
<h3 className="text-navy font-black text-xl uppercase tracking-tight">
|
||||
On intervient aussi dans les communes voisines
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zones
|
||||
.filter((z) => z !== ville)
|
||||
.slice(0, 6)
|
||||
.map((z) => (
|
||||
<span
|
||||
key={z}
|
||||
className="inline-flex items-center gap-1.5 border border-border text-navy text-xs font-bold px-3 py-1.5 uppercase tracking-wide hover:border-orange hover:text-orange transition-all cursor-default"
|
||||
>
|
||||
<span className="w-1 h-1 rounded-full bg-orange shrink-0" />
|
||||
{z}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact split */}
|
||||
<section className="grid lg:grid-cols-2">
|
||||
<div className="bg-navy texture-dark py-16 md:py-20 px-8 md:px-12 lg:px-16 relative overflow-hidden">
|
||||
<div className="hero-diagonal-panel" />
|
||||
<ScrollReveal direction="left">
|
||||
<span className="text-orange text-xs font-bold uppercase tracking-[0.25em] block mb-4">
|
||||
Devis gratuit à {ville}
|
||||
</span>
|
||||
<h2 className="text-white font-black text-2xl uppercase tracking-tight mb-6">
|
||||
Votre projet<br />mérite le meilleur
|
||||
</h2>
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-8 max-w-sm">
|
||||
Benoît Colin se déplace gratuitement à {ville} pour évaluer votre chantier et vous remettre un devis sous 24h.
|
||||
</p>
|
||||
<a href={`tel:${phoneRaw}`} className="flex items-center gap-3 group mb-6">
|
||||
<div className="w-10 h-10 border border-orange/40 flex items-center justify-center shrink-0 group-hover:border-orange group-hover:bg-orange/10 transition-all">
|
||||
<svg className="w-4 h-4 text-orange" 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>
|
||||
</div>
|
||||
<span className="text-white font-bold text-lg group-hover:text-orange transition-colors">{phone}</span>
|
||||
</a>
|
||||
<div className="space-y-3">
|
||||
{["Devis gratuit & sans engagement", "Déplacement offert", "Réponse sous 24h"].map((item) => (
|
||||
<div key={item} className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-orange" />
|
||||
<span className="text-white/60 text-sm">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
<div className="bg-stone-bg py-16 md:py-20 px-8 md:px-12 lg:px-16">
|
||||
<ScrollReveal direction="right">
|
||||
<ContactForm />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Navbar from "@/components/marketing/Navbar";
|
||||
import Footer from "@/components/marketing/Footer";
|
||||
|
||||
interface LocalSeoPageProps {
|
||||
ville: string;
|
||||
villeSlug: string;
|
||||
codePostal: string;
|
||||
voisines: string[];
|
||||
}
|
||||
|
||||
export default function LocalSeoPage({ ville, codePostal, voisines }: LocalSeoPageProps) {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero local */}
|
||||
<section className="py-20 md:py-28 bg-navy text-center">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<span className="inline-block px-3 py-1.5 bg-orange/20 border border-orange/30 rounded-full text-orange text-xs font-semibold mb-6">
|
||||
{ville} ({codePostal}) et environs
|
||||
</span>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-white leading-tight mb-6">
|
||||
Création de site internet pour{" "}
|
||||
<span className="text-orange">artisans à {ville}</span>
|
||||
</h1>
|
||||
<p className="text-white/60 text-lg max-w-2xl mx-auto mb-8">
|
||||
Vous êtes artisan à {ville} ou dans le secteur de {voisines[0]} / {voisines[1]} ?
|
||||
Je crée votre site web professionnel et votre présence Google pour générer
|
||||
des chantiers qualifiés. Basé à Flines-lez-Raches, je suis votre voisin.
|
||||
</p>
|
||||
<Link href="/#contact">
|
||||
<Button size="lg" className="pulse-glow">
|
||||
DÉMARRER MON AUDIT GRATUIT
|
||||
</Button>
|
||||
</Link>
|
||||
<p className="mt-4 text-white/40 text-sm">
|
||||
Réponse sous 24h · 100% gratuit · Sans engagement
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* M\u00e9tiers couverts */}
|
||||
<section className="py-16 md:py-24 bg-bg">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-navy text-center mb-10">
|
||||
Sites web pour <span className="text-orange">tous les métiers du bâtiment</span> à {ville}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
"Couvreur", "Ma\u00e7on", "Paysagiste",
|
||||
"Plombier", "\u00c9lectricien", "Menuisier",
|
||||
"Charpentier", "Peintre", "Serrurier",
|
||||
].map((metier) => (
|
||||
<div key={metier} className="bg-bg-white border border-border rounded-xl p-4 text-center hover:shadow-md transition-shadow">
|
||||
<p className="text-navy font-semibold text-sm">Site internet {metier}</p>
|
||||
<p className="text-text-muted text-xs mt-1">{ville} et environs</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pourquoi HookLab */}
|
||||
<section className="py-16 md:py-24 bg-bg-white">
|
||||
<div className="max-w-3xl mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-6">
|
||||
Pourquoi choisir <span className="text-orange">HookLab</span> à {ville} ?
|
||||
</h2>
|
||||
<div className="space-y-4 text-left">
|
||||
{[
|
||||
{
|
||||
title: "Proximit\u00e9 locale",
|
||||
desc: `Bas\u00e9 \u00e0 Flines-lez-Raches, je connais ${ville} et ses artisans. On peut se voir en vrai.`,
|
||||
},
|
||||
{
|
||||
title: "Technologie des g\u00e9ants",
|
||||
desc: "Sites ultra-rapides avec la m\u00eame technologie que Netflix. Google adore la vitesse.",
|
||||
},
|
||||
{
|
||||
title: "R\u00e9sultats concrets",
|
||||
desc: "Pas un site pour faire joli. Un syst\u00e8me qui fait sonner votre t\u00e9l\u00e9phone avec des vrais clients.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-4 bg-bg border border-border rounded-xl p-5">
|
||||
<div className="w-8 h-8 bg-orange/10 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-navy font-bold text-base mb-1">{item.title}</p>
|
||||
<p className="text-text-light text-sm leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* D\u00e9mos */}
|
||||
<section className="py-16 md:py-24 bg-bg">
|
||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-navy mb-6">
|
||||
Testez nos <span className="text-orange">modèles</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
<Link href="/macon" className="bg-navy text-white font-bold text-sm px-6 py-3 rounded-xl hover:bg-navy/90 transition-colors">
|
||||
Démo Maçon / Couvreur
|
||||
</Link>
|
||||
<Link href="/paysagiste" className="bg-green-600 text-white font-bold text-sm px-6 py-3 rounded-xl hover:bg-green-700 transition-colors">
|
||||
Démo Paysagiste
|
||||
</Link>
|
||||
<Link href="/plombier" className="bg-[#3b82f6] text-white font-bold text-sm px-6 py-3 rounded-xl hover:bg-[#2563eb] transition-colors">
|
||||
Démo Plombier
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Zone */}
|
||||
<section className="py-16 bg-navy text-center">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Aussi disponible à {voisines.join(", ")}
|
||||
</h2>
|
||||
<p className="text-white/60 mb-6">
|
||||
Je travaille avec des artisans dans tout le Douaisis, l’Orchésien et le Valenciennois.
|
||||
</p>
|
||||
<Link href="/#contact">
|
||||
<Button size="lg" className="pulse-glow">
|
||||
Réserver Mon Audit Gratuit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,91 +2,131 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { phone, phoneRaw, nav } = siteConfig;
|
||||
const pathname = usePathname();
|
||||
|
||||
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">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<nav
|
||||
className="sticky top-0 z-50 bg-bg-white border-b border-border"
|
||||
role="navigation"
|
||||
aria-label="Navigation principale"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16 md:h-[68px]">
|
||||
|
||||
{/* 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-3 shrink-0" aria-label="OBC Maçonnerie — Accueil">
|
||||
<div className="w-10 h-10 bg-navy flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-black text-xs tracking-wider">OBC</span>
|
||||
</div>
|
||||
<div className="flex flex-col leading-none gap-0.5">
|
||||
<span className="text-navy font-black text-sm tracking-[0.18em] uppercase">OBC</span>
|
||||
<span className="text-orange font-bold text-xs tracking-[0.18em] uppercase">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>
|
||||
{/* Desktop nav links */}
|
||||
<div className="hidden lg:flex items-center gap-7">
|
||||
{nav.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== "/" && pathname.startsWith(link.href));
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`nav-link text-xs font-bold uppercase tracking-[0.14em] transition-colors pb-0.5 ${
|
||||
active ? "text-orange active" : "text-text-light hover:text-navy"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA desktop - Phone */}
|
||||
<div className="hidden md:block">
|
||||
{/* Right — phone + CTA */}
|
||||
<div className="hidden lg:flex items-center gap-5">
|
||||
<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:${phoneRaw}`}
|
||||
className="flex items-center gap-2 text-navy font-bold text-sm hover:text-orange transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-orange shrink-0" 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
|
||||
{phone}
|
||||
</a>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="btn btn-fill text-xs uppercase tracking-widest px-5 py-2.5"
|
||||
>
|
||||
<span>Devis gratuit</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
{/* Mobile burger */}
|
||||
<button
|
||||
className="md:hidden p-2 text-text-light hover:text-navy transition-colors cursor-pointer"
|
||||
className="lg:hidden p-2 text-navy transition-colors"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
<div className={`w-5 space-y-1.5 transition-all ${open ? "opacity-0 scale-75" : ""}`}>
|
||||
{!open ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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="lg:hidden border-t border-border pb-5">
|
||||
<div className="pt-2">
|
||||
{nav.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== "/" && pathname.startsWith(link.href));
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`flex items-center justify-between text-xs font-bold uppercase tracking-widest py-3.5 px-1 border-b border-border-light transition-colors ${
|
||||
active ? "text-orange" : "text-text-light hover:text-navy"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
{active && <span className="w-1.5 h-1.5 rounded-full bg-orange" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
className="flex items-center gap-2 text-navy font-bold text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4 text-orange" 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>
|
||||
{phone}
|
||||
</a>
|
||||
<Link
|
||||
href="/contact"
|
||||
onClick={() => setOpen(false)}
|
||||
className="btn btn-fill text-xs uppercase tracking-widest px-5 py-3.5 w-full justify-center"
|
||||
>
|
||||
<span>Demander un devis gratuit</span>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user