SPB Git

spb/phi-conseil

Public

PHI Conseil : site Next.js 16 fidèle à la maquette (accueil 7 sections, pages internes, contenu JSON validé, formulaire de contact sécurisé, tests, docs)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 8, 2026) parent 0d135ae Browse files

90 changed files +5,294 −139

90 changed files
Unified Split
added .github/workflows/ci.yml +37 −0
@@ -0,0 +1,37 @@
1 +name: CI
2 +
3 +on:
4 + push:
5 + branches: [main]
6 + pull_request:
7 +
8 +jobs:
9 + check:
10 + runs-on: ubuntu-latest
11 + env:
12 + CONTACT_TRANSPORT: log
13 + NEXT_PUBLIC_SITE_URL: http://127.0.0.1:3100
14 + steps:
15 + - uses: actions/checkout@v4
16 + - uses: pnpm/action-setup@v4
17 + with:
18 + version: 11
19 + - uses: actions/setup-node@v4
20 + with:
21 + node-version: 22
22 + cache: pnpm
23 + - run: pnpm install --frozen-lockfile
24 + - run: pnpm lint
25 + - run: pnpm typecheck
26 + - run: pnpm test
27 + - run: pnpm build
28 + - run: pnpm exec playwright install --with-deps chromium webkit
29 + - run: pnpm test:e2e
30 + - uses: actions/upload-artifact@v4
31 + if: always()
32 + with:
33 + name: playwright-report
34 + path: |
35 + playwright-report
36 + docs/screenshots
37 + retention-days: 14
modified .gitignore +7 −0
@@ -39,3 +39,10 @@ yarn-error.log*
39 39 # typescript
40 40 *.tsbuildinfo
41 41 next-env.d.ts
42 +
43 +# tests & captures
44 +/test-results/
45 +/playwright-report/
46 +/docs/screenshots/*.png
47 +!/docs/screenshots/.gitkeep
48 +.env.local
added .prettierignore +6 −0
@@ -0,0 +1,6 @@
1 +.next
2 +node_modules
3 +pnpm-lock.yaml
4 +test-results
5 +playwright-report
6 +docs/screenshots
added .prettierrc +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "semi": true,
3 + "singleQuote": false,
4 + "trailingComma": "all",
5 + "printWidth": 100,
6 + "plugins": ["prettier-plugin-tailwindcss"]
7 +}
modified README.md +41 −22
@@ -1,36 +1,55 @@
1 −This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
1 +# PHI Conseil — site web
2 2
3 −## Getting Started
3 +Site vitrine de PHI Conseil (« Stratégies · Financement · Réussite »), reproduit fidèlement à partir de la maquette client (`references/phi-conseil-homepage.jpeg`) et conçu comme un produit de production : Next.js 16 (App Router), React 19, TypeScript strict, Tailwind CSS 4, contenu administrable validé par Zod, formulaire de contact sécurisé, tests unitaires et de bout en bout.
4 4
5 −First, run the development server:
5 +## Démarrage
6 6
7 7 ```bash
8 −npm run dev
9 −# or
10 −yarn dev
11 −# or
12 −pnpm dev
13 −# or
14 −bun dev
8 +pnpm install
9 +cp .env.example .env.local # ajuster au besoin (transport courriel, URL)
10 +pnpm dev # http://localhost:3000
15 11 ```
16 12
17 −Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
13 +Prérequis : Node.js ≥ 20 (testé avec 26), pnpm ≥ 10.
18 14
19 −You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
15 +## Scripts
20 16
21 −This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
17 +| Commande | Rôle |
18 +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19 +| `pnpm dev` | serveur de développement |
20 +| `pnpm build` / `pnpm start` | build de production / serveur de production (`-p <port>`) |
21 +| `pnpm lint` | ESLint (config Next + règles React Compiler) |
22 +| `pnpm typecheck` | `tsc --noEmit` |
23 +| `pnpm test` | tests unitaires Vitest (schémas, validation, sécurité, API, composants) |
24 +| `pnpm test:e2e` | Playwright : navigation, menu mobile, formulaire, débordements, axe, captures (`docs/screenshots/`) ; nécessite `pnpm build` au préalable et `pnpm exec playwright install chromium webkit` la première fois |
25 +| `pnpm check` | lint + typecheck + tests + build |
26 +| `pnpm icons` | régénère favicon / icônes à partir du logo (spirale dorée) |
27 +| `pnpm format` | Prettier |
22 28
23 −## Learn More
29 +## Structure
24 30
25 −To learn more about Next.js, take a look at the following resources:
26 −
27 −- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 −- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
31 +```
32 +content/ contenu administrable (site.json, home.json, pages.json) — voir docs/CLIENT_GUIDE.md
33 +src/app/ routes (accueil, services, a-propos, temoignages, nous-joindre, confidentialite, api/contact)
34 +src/components/ layout/ sections/ ui/ graphics/ forms/
35 +src/lib/ content.ts (schémas + chargeur), env.ts, validation.ts, security.ts, mail.ts, seo.ts, spiral.ts
36 +src/styles/ tokens.css (design tokens : couleurs échantillonnées, typo, espacements…)
37 +tests/unit, tests/e2e
38 +docs/ plan, déploiement, guide client, rapport QA, registre des actifs, captures
39 +references/ maquette client
40 +```
29 41
30 −You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
42 +## Principes
31 43
32 −## Deploy on Vercel
44 +- **Fidélité** : couleurs échantillonnées sur la maquette, structure DOM = ordre visuel sur tous les écrans, motifs (spirale de Fibonacci, vagues, feuillage) en SVG.
45 +- **Honnêteté du contenu** : aucune coordonnée, témoignage ou réalisation inventés. Les témoignages ne sont publiés que s'ils sont `approved: true` et la section `visible: true` ; les photos de la maquette sont remplacées par des illustrations vectorielles tant que le client n'a pas fourni d'images sous licence (`docs/ASSET_REGISTER.md`).
46 +- **Sécurité** : CSP, en-têtes durcis, validation Zod client + serveur, honeypot, délai anti-robot, limitation de débit, dédoublonnage, journalisation sans donnée personnelle, secrets uniquement côté serveur.
47 +- **Accessibilité** : WCAG 2.2 AA visé (axe sans violation sur toutes les pages), navigation clavier, `prefers-reduced-motion`, lien d'évitement, états focus visibles.
33 48
34 −The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
49 +## Documentation
35 50
36 −Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
51 +- `docs/IMPLEMENTATION_PLAN.md` — audit, architecture, décisions, critères d'acceptation
52 +- `docs/DEPLOYMENT.md` — hébergement, DNS, HTTPS, variables, sauvegardes, rollback
53 +- `docs/CLIENT_GUIDE.md` — modifier textes, services, témoignages, images, coordonnées
54 +- `docs/QA_REPORT.md` — résultats réels des vérifications, limites, points à valider
55 +- `docs/ASSET_REGISTER.md` — provenance, licence et statut de chaque actif
added content/home.json +80 −0
@@ -0,0 +1,80 @@
1 +{
2 + "hero": {
3 + "titleLines": ["Transformer", "vos idées en projets", "concrets"],
4 + "subtitle": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.",
5 + "cta": { "label": "Découvrir nos services", "href": "/services" },
6 + "image": {
7 + "src": "",
8 + "alt": "Bureau lumineux avec une plante, une tasse et un ordinateur portable"
9 + }
10 + },
11 + "services": {
12 + "visible": true,
13 + "title": "Nos services",
14 + "items": [
15 + {
16 + "slug": "recherche-de-subventions",
17 + "icon": "search",
18 + "title": "Recherche de subventions",
19 + "summary": "Trouvez les programmes adaptés à vos projets.",
20 + "description": ""
21 + },
22 + {
23 + "slug": "redaction-et-revision-de-demandes",
24 + "icon": "document-edit",
25 + "title": "Rédaction et révision de demandes",
26 + "summary": "Présentez des projets solides et convaincants.",
27 + "description": ""
28 + },
29 + {
30 + "slug": "redaction-et-revision-de-rapports",
31 + "icon": "chart",
32 + "title": "Rédaction et révision de rapports",
33 + "summary": "Communiquez efficacement vos résultats.",
34 + "description": ""
35 + },
36 + {
37 + "slug": "reddition-de-comptes",
38 + "icon": "check-circle",
39 + "title": "Reddition de comptes",
40 + "summary": "Assurez une gestion rigoureuse et conforme.",
41 + "description": ""
42 + }
43 + ]
44 + },
45 + "about": {
46 + "visible": true,
47 + "overtitle": "À propos de PHI Conseil",
48 + "title": "Une approche stratégique pour maximiser vos opportunités",
49 + "paragraph": "Forte d'une expertise en financement et en gestion de projets, Phi Conseil vous accompagne avec rigueur, créativité et bienveillance, pour donner vie à vos ambitions.",
50 + "cta": { "label": "En savoir plus", "href": "/a-propos" },
51 + "benefits": [
52 + "Une expertise reconnue",
53 + "Une approche personnalisée",
54 + "Un accompagnement de A à Z",
55 + "Un engagement envers votre réussite"
56 + ],
57 + "image": {
58 + "src": "",
59 + "alt": "Vase transparent avec feuillage posé sur des livres, lumière douce"
60 + }
61 + },
62 + "testimonials": {
63 + "visible": false,
64 + "previewOnly": true,
65 + "items": [
66 + {
67 + "quote": "« Grâce à l'accompagnement de Phi Conseil, nous avons obtenu un financement majeur pour notre projet. Leur professionnalisme et leur disponibilité font toute la différence. »",
68 + "author": "Exemple de maquette (non vérifié)",
69 + "role": "Témoignage fictif de la capture — à remplacer par un avis autorisé",
70 + "approved": false
71 + }
72 + ]
73 + },
74 + "callToAction": {
75 + "visible": true,
76 + "title": "Parlons de vos projets !",
77 + "subtitle": "Contactez-nous dès aujourd'hui pour discuter de vos besoins.",
78 + "cta": { "label": "Nous joindre", "href": "/nous-joindre" }
79 + }
80 +}
added content/pages.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "services": {
3 + "title": "Nos services",
4 + "intro": "Quatre services complémentaires pour financer, documenter et rendre compte de vos projets.",
5 + "seoDescription": "Recherche de subventions, rédaction et révision de demandes et de rapports, reddition de comptes : les services de PHI Conseil."
6 + },
7 + "about": {
8 + "title": "À propos",
9 + "intro": "Une approche stratégique pour maximiser vos opportunités.",
10 + "seoDescription": "Découvrez l'approche de PHI Conseil : expertise, personnalisation, accompagnement de A à Z et engagement envers votre réussite.",
11 + "body": [
12 + "Forte d'une expertise en financement et en gestion de projets, Phi Conseil vous accompagne avec rigueur, créativité et bienveillance, pour donner vie à vos ambitions."
13 + ]
14 + },
15 + "testimonials": {
16 + "title": "Témoignages",
17 + "intro": "Les témoignages publiés sur cette page sont ceux de clients ayant autorisé leur diffusion.",
18 + "emptyMessage": "Aucun témoignage n'est publié pour le moment.",
19 + "seoDescription": "Témoignages de clients de PHI Conseil."
20 + },
21 + "contact": {
22 + "title": "Nous joindre",
23 + "intro": "Décrivez-nous votre projet ou votre besoin : nous vous répondrons dans les meilleurs délais.",
24 + "seoDescription": "Contactez PHI Conseil pour discuter de vos besoins en recherche de subventions, rédaction de demandes et reddition de comptes.",
25 + "subjects": [
26 + "Recherche de subventions",
27 + "Rédaction ou révision d'une demande",
28 + "Rédaction ou révision d'un rapport",
29 + "Reddition de comptes",
30 + "Autre demande"
31 + ],
32 + "consentLabel": "J'accepte que mes renseignements soient utilisés pour traiter ma demande, conformément à la politique de confidentialité.",
33 + "successTitle": "Message envoyé",
34 + "successMessage": "Merci ! Votre message a bien été transmis. Nous vous répondrons dans les meilleurs délais."
35 + },
36 + "privacy": {
37 + "title": "Politique de confidentialité et mentions légales",
38 + "seoDescription": "Politique de confidentialité et mentions légales de PHI Conseil.",
39 + "lastUpdated": "2026-11-08",
40 + "reviewed": false
41 + }
42 +}
added content/site.json +36 −0
@@ -0,0 +1,36 @@
1 +{
2 + "brand": {
3 + "name": "PHI Conseil",
4 + "tagline": "STRATÉGIES · FINANCEMENT · RÉUSSITE",
5 + "footerSignature": "DES STRATÉGIES QUI DONNENT VIE À VOS PROJETS.",
6 + "legalName": "Phi Conseil"
7 + },
8 + "navigation": [
9 + { "label": "Accueil", "href": "/" },
10 + { "label": "Nos services", "href": "/services" },
11 + { "label": "À propos", "href": "/a-propos" },
12 + { "label": "Témoignages", "href": "/temoignages" },
13 + { "label": "Nous joindre", "href": "/nous-joindre" }
14 + ],
15 + "headerCta": { "label": "Contactez-nous", "href": "/nous-joindre" },
16 + "contact": {
17 + "email": "",
18 + "phone": "",
19 + "address": "",
20 + "region": "Québec, Canada"
21 + },
22 + "social": {
23 + "linkedin": ""
24 + },
25 + "legalLinks": [
26 + { "label": "Confidentialité", "href": "/confidentialite" },
27 + { "label": "Mentions légales", "href": "/confidentialite#mentions-legales" }
28 + ],
29 + "seo": {
30 + "siteUrl": "https://www.example.invalid",
31 + "defaultTitle": "PHI Conseil — Stratégies, financement, réussite",
32 + "titleTemplate": "%s | PHI Conseil",
33 + "defaultDescription": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.",
34 + "locale": "fr_CA"
35 + }
36 +}
added docs/ASSET_REGISTER.md +18 −0
@@ -0,0 +1,18 @@
1 +# Registre des actifs — PHI Conseil
2 +
3 +| Actif | Emplacement | Provenance | Licence | Statut |
4 +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------- |
5 +| Maquette de l'accueil | `references/phi-conseil-homepage.jpeg` | fournie par le client (IMG_1079.JPG, 1024 × 1536) | usage interne de référence | référence, non publiée |
6 +| Logo (spirale dorée + rectangle d'or) | `src/components/graphics/Logo.tsx`, `src/lib/spiral.ts`, `public/logo-mark.svg` | **recréé en SVG** d'après la maquette (construction Fibonacci 1-1-2-3-5-8-13-21) | œuvre dérivée de l'identité du client | ⚠️ à valider par le client ; remplacer par le vectoriel officiel s'il existe |
7 +| Favicon / icônes | `src/app/icon.svg`, `src/app/favicon.ico`, `src/app/apple-icon.png`, `public/icon-192.png`, `public/icon-512.png` | générés depuis le logo par `scripts/generate-icons.mts` | idem logo | dérivés du logo, à régénérer si le logo change |
8 +| Image Open Graph | `src/app/opengraph-image.tsx` | générée au build (texte + spirale) | — | OK |
9 +| Photo du hero (bureau, plante, tasse, ordinateur) | `src/components/graphics/Placeholders.tsx` (`DeskScene`) | **illustration vectorielle de remplacement** créée pour le projet ; la photo de la maquette n'a pas été reprise (droits inconnus) | créée pour le projet | ⚠️ placeholder — fournir une photo sous licence |
10 +| Photo « À propos » (vase, feuillage, livres) | `src/components/graphics/Placeholders.tsx` (`VaseScene`) | idem | créée pour le projet | ⚠️ placeholder — fournir une photo sous licence |
11 +| Motifs (vagues, feuillage, spirales décoratives) | `src/components/graphics/*` | créés pour le projet | — | OK |
12 +| Icônes de services et d'interface | `src/components/ui/ServiceIcon.tsx`, `Icons.tsx` | dessinées pour le projet (trait 1,5) | — | OK |
13 +| Police titres **EB Garamond** | `next/font/google` (auto-hébergée au build) | Georg Duffner / Octavio Pardo, Google Fonts | SIL Open Font License 1.1 | OK ; remplacer par la police officielle du client si elle existe |
14 +| Police corps **Figtree** | `next/font/google` | Erik Kennedy, Google Fonts | SIL Open Font License 1.1 | OK |
15 +| Témoignage « Marie-Claude Deschamps, Directrice générale » | `content/home.json` (`approved: false`) | texte de la maquette, **identité non vérifiée** | — | ⚠️ NON PUBLIÉ ; obtenir un avis réel et une autorisation écrite |
16 +| Textes marketing (hero, services, à propos, bandeau) | `content/*.json` | repris mot pour mot de la maquette | client | à confirmer par le client |
17 +| Textes juridiques (confidentialité, mentions légales) | `src/app/(site)/confidentialite/page.tsx`, `pages.json` | gabarit rédigé pour le projet, décrit les pratiques réelles du site | — | ⚠️ à faire valider (`privacy.reviewed=false`) |
18 +| Coordonnées, LinkedIn | `content/site.json` | **vides** (non fournies) | — | à compléter par le client |
added docs/CLIENT_GUIDE.md +47 −0
@@ -0,0 +1,47 @@
1 +# Guide de gestion des contenus — PHI Conseil
2 +
3 +Tous les textes, services, avantages, témoignages, coordonnées et paramètres SEO se trouvent dans trois fichiers du dossier `content/`. Ils se modifient avec n'importe quel éditeur de texte (ou l'interface web du dépôt Git) ; aucune modification du code n'est nécessaire. À chaque mise à jour, le site est reconstruit et vérifié : une faute de structure (guillemet manquant, champ oublié) bloque la publication avec un message précis au lieu de casser le site.
4 +
5 +> Règle d'or : conservez les guillemets `"…"` et les virgules ; modifiez uniquement ce qui se trouve entre les guillemets, ou ajoutez/retirez des éléments complets dans les listes.
6 +
7 +## `content/site.json` — identité et coordonnées
8 +
9 +| Champ | Usage |
10 +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
11 +| `brand.name`, `brand.tagline`, `brand.footerSignature`, `brand.legalName` | nom, signature de l'en-tête, phrase du pied de page, raison sociale du copyright |
12 +| `navigation[]` | liens du menu (ordre = ordre affiché) |
13 +| `headerCta` | bouton « Contactez-nous → » |
14 +| `contact.email`, `contact.phone`, `contact.address`, `contact.region` | coordonnées ; **laissez `""` pour masquer** l'icône courriel / la ligne correspondante |
15 +| `social.linkedin` | URL complète du profil LinkedIn ; `""` = icône masquée |
16 +| `legalLinks[]` | liens juridiques du pied de page |
17 +| `seo.siteUrl`, `seo.defaultTitle`, `seo.titleTemplate`, `seo.defaultDescription` | métadonnées par défaut |
18 +
19 +## `content/home.json` — page d'accueil
20 +
21 +- `hero.titleLines` : les lignes du grand titre (1 à 4). `hero.subtitle`, `hero.cta`.
22 +- `hero.image.src` : chemin d'une photo placée dans `public/images/` (ex. `"/images/hero.jpg"`). Vide = illustration vectorielle de remplacement. Toujours renseigner `alt` (description pour les lecteurs d'écran).
23 +- `services.items[]` : `slug` (identifiant URL, lettres minuscules et tirets), `icon` (`search`, `document-edit`, `chart`, `check-circle`), `title`, `summary` (accueil), `description` (page Services, facultatif). L'ordre du tableau = ordre d'affichage.
24 +- `about` : `overtitle`, `title`, `paragraph`, `cta`, `benefits[]` (coches dorées), `image`.
25 +- `testimonials.visible` : `true` pour afficher la section. **Un témoignage n'apparaît que si `approved: true`** (autorisation écrite de la personne citée). `previewOnly` permet d'afficher les exemples non approuvés uniquement sur un site de démonstration.
26 +- `callToAction` : bandeau bleu final.
27 +- Chaque section possède `visible` pour la masquer sans la supprimer.
28 +
29 +## `content/pages.json` — pages internes
30 +
31 +Titres, introductions, descriptions SEO des pages Services, À propos, Témoignages, Nous joindre (liste `subjects` du menu déroulant, texte de consentement, message de confirmation) et Confidentialité (`lastUpdated` au format AAAA-MM-JJ ; passez `reviewed` à `true` une fois le texte validé juridiquement pour retirer l'avertissement).
32 +
33 +## Images
34 +
35 +1. Préparer un JPEG/WebP de bonne qualité (hero : ≥ 1600 px de large ; À propos : format vertical ≥ 1000 × 1250 px).
36 +2. Le déposer dans `public/images/`.
37 +3. Renseigner `image.src` et `image.alt` dans `home.json`.
38 +4. Noter la provenance et la licence dans `docs/ASSET_REGISTER.md`.
39 + Le site convertit et redimensionne automatiquement (AVIF/WebP, tailles adaptées).
40 +
41 +## Logo et favicon
42 +
43 +Le logo actuel est une recréation SVG de la spirale dorée. Pour utiliser le logo officiel : remplacer le composant `src/components/graphics/Logo.tsx` par le SVG fourni, puis `pnpm icons` pour régénérer favicon et icônes (ou fournir directement `src/app/icon.svg`, `src/app/apple-icon.png`).
44 +
45 +## Publier une modification
46 +
47 +Sur le serveur d'hébergement : `pnpm build && pm2 restart phi-conseil` (voir `docs/DEPLOYMENT.md`), puis `git commit` pour conserver l'historique. Une erreur de contenu s'affiche au build sous la forme `Contenu invalide dans content/home.json : hero.subtitle : …`.
added docs/DEPLOYMENT.md +68 −0
@@ -0,0 +1,68 @@
1 +# Déploiement — PHI Conseil
2 +
3 +## Cible
4 +
5 +Application Node.js (`next start`) derrière un reverse proxy TLS. Aucune base de données. Le build est statique pour toutes les pages ; seule `POST /api/contact` s'exécute à la demande.
6 +
7 +| Besoin | Valeur |
8 +| -------- | ---------------------------------------------------------------- |
9 +| Node.js | ≥ 20 (testé 26) |
10 +| RAM | ~150 Mo au repos |
11 +| Port | `PORT` (défaut 3000) |
12 +| Fichiers | `.next/`, `public/`, `content/`, `node_modules/`, `package.json` |
13 +
14 +## Variables d'environnement
15 +
16 +Voir `.env.example`. En production :
17 +
18 +- `NEXT_PUBLIC_SITE_URL` — URL publique (canonical, sitemap, OG). **Inlinée au build** : rebuild si elle change.
19 +- `NEXT_PUBLIC_CONTENT_PREVIEW` — `0` en production. `1` affiche les témoignages de maquette avec un bandeau « exemple non vérifié » (sites de démonstration uniquement ; inlinée au build).
20 +- `CONTACT_TRANSPORT=smtp` + `CONTACT_TO`, `CONTACT_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS` — sans ces valeurs le formulaire fonctionne mais **n'envoie aucun courriel** (`CONTACT_TRANSPORT=log`) et l'utilisateur en est informé dans l'écran de confirmation.
21 +- `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_S` — anti-abus (défaut 5 / 600 s par IP). Le limiteur est en mémoire : valable pour une instance unique (PM2 `fork`). Pour plusieurs instances, brancher Redis dans `src/lib/security.ts`.
22 +
23 +Le proxy doit transmettre `X-Forwarded-For` (Caddy et nginx le font par défaut) pour que la limitation par IP soit correcte.
24 +
25 +## Procédure générique (VPS / serveur Node)
26 +
27 +```bash
28 +git clone <dépôt> phi-conseil && cd phi-conseil
29 +pnpm install --frozen-lockfile
30 +cp .env.example .env.local && $EDITOR .env.local
31 +pnpm build
32 +pm2 start pnpm --name phi-conseil -- start -p 3000 # ou systemd
33 +```
34 +
35 +Reverse proxy (exemple Caddy) : `phiconseil.example { reverse_proxy 127.0.0.1:3000 }` — Caddy obtient et renouvelle le certificat TLS automatiquement. HSTS est déjà émis par l'application.
36 +
37 +### Vercel (alternative)
38 +
39 +Importer le dépôt, définir les variables d'environnement, framework Next.js détecté automatiquement. Le limiteur en mémoire n'est pas fiable en serverless : remplacer par Upstash/Redis ou le middleware de limitation du fournisseur.
40 +
41 +## Procédure MacLustr (`mld`) — site de démonstration test.spboucher.ai
42 +
43 +1. Manifeste `M1M32:~/dispatch/apps/phi-conseil.json` (dir `~/apps/phi-conseil`, PM2 `pnpm start -p 8520`, `tunnel.domain = test.spboucher.ai`, hooks `post_sync` : `pnpm install --frozen-lockfile && pnpm build`).
44 +2. `mld stage ~/Desktop/phi-conseil phi-conseil` puis `mld deploy phi-conseil`.
45 +3. DNS GoDaddy `A test.spboucher.ai → 51.161.112.61` (passerelle BHS64) — déjà en place ; `mld` pose la route Caddy et vérifie la santé publique.
46 +4. Mises à jour : modifier **sur le nœud** (clone git), `pnpm build && pm2 restart phi-conseil`, puis `git commit/push` depuis le nœud (règle remote-first MacLustr).
47 +
48 +## Sauvegardes et restauration
49 +
50 +- Le code et le contenu (`content/*.json`) sont versionnés dans Git : la sauvegarde est le dépôt distant. Restaurer = `git checkout <tag>` + `pnpm install && pnpm build`.
51 +- Les messages du formulaire ne sont pas stockés par le site : ils vivent dans la boîte courriel destinataire (sauvegarde relevant du fournisseur de messagerie).
52 +- Secrets (`.env.local`) : conserver une copie chiffrée hors du dépôt (gestionnaire de mots de passe).
53 +
54 +## Retour arrière
55 +
56 +1. `git log` → identifier la dernière version saine (chaque mise en production est taguée `vX.Y.Z`).
57 +2. `git checkout vX.Y.Z && pnpm install --frozen-lockfile && pnpm build && pm2 restart phi-conseil`.
58 +3. Vérifier `GET /` (200) et `GET /api/contact` (405 attendu) ; sur MacLustr, `mld status --live phi-conseil`.
59 +
60 +## Surveillance minimale
61 +
62 +- Santé : `GET /` doit répondre 200 (healthcheck PM2/mld).
63 +- Journaux : `pm2 logs phi-conseil` — les lignes `[contact]` ne contiennent qu'une empreinte d'IP, jamais le contenu des messages.
64 +- Erreurs de rendu : digest côté serveur (`console.error`) corrélable à l'écran d'erreur client. Un service d'erreurs (Sentry…) peut être ajouté dans `src/app/error.tsx` et `instrumentation.ts` ; non inclus par défaut pour éviter tout traceur non consenti.
65 +
66 +## Mise à jour des dépendances
67 +
68 +`pnpm outdated` puis `pnpm update --latest` en suivant les notes de migration de Next.js ; relancer `pnpm check` et `pnpm test:e2e` avant mise en production. Audit : `pnpm audit`.
added docs/IMPLEMENTATION_PLAN.md +87 −0
@@ -0,0 +1,87 @@
1 +# Plan d'implémentation — PHI Conseil
2 +
3 +Date : 2026-11-08. Référence visuelle : `references/phi-conseil-homepage.jpeg` (1024 × 1536 px, capture desktop fournie par le client).
4 +
5 +## 1. Audit initial
6 +
7 +| Élément | État constaté |
8 +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9 +| Dépôt | Vide à l'origine (créé aujourd'hui). Scaffold `create-next-app` 16.4.5, TypeScript strict, Tailwind 4, ESLint 9, pnpm 11. |
10 +| Capture | Présente et inspectée. Couleurs échantillonnées par région (médiane / mode / moyenne des pixels sombres) — voir `src/styles/tokens.css`. |
11 +| Actifs client | **Aucun** fichier original fourni : pas de logo vectoriel, pas de photos sous licence, pas de polices, pas de coordonnées, pas de textes juridiques, pas de témoignage autorisé. |
12 +| Hébergement | Non précisé. Cible par défaut : Node 20+ (`next start` derrière un reverse proxy TLS). Un déploiement sur le cluster MacLustr (`mld`) ou sur Vercel est documenté dans `docs/DEPLOYMENT.md`. |
13 +
14 +### Inconnues bloquantes (à valider par le client)
15 +
16 +1. Logo vectoriel officiel (sinon le SVG recréé `src/components/graphics/Logo.tsx` doit être approuvé).
17 +2. Photos sous licence pour le hero et la section À propos (les illustrations livrées sont des **placeholders vectoriels** dans la palette, marqués comme tels dans `docs/ASSET_REGISTER.md`).
18 +3. Coordonnées réelles (courriel, téléphone, adresse, LinkedIn) : laissées vides, les icônes/liens correspondants sont masqués tant que les champs du contenu sont vides.
19 +4. Témoignage(s) autorisé(s) : la section est **masquée en production** (`testimonials.visible=false`) ; le texte de la maquette est conservé uniquement comme exemple de prévisualisation marqué « non vérifié ».
20 +5. Textes juridiques (confidentialité, mentions légales) : gabarit fourni, à faire valider par un conseiller juridique.
21 +6. Fournisseur de messagerie transactionnelle (SMTP) et adresses expéditeur/destinataire.
22 +7. Choix CMS : voir §4.
23 +
24 +## 2. Architecture
25 +
26 +```
27 +content/ ← contenu administrable (JSON validé par Zod au build et à l'exécution)
28 + site.json ← identité, navigation, coordonnées, liens sociaux, SEO par défaut
29 + home.json ← hero, services, à-propos, témoignages, bandeau, visibilité des sections
30 + pages.json ← pages internes (services, à propos, témoignages, nous joindre, confidentialité)
31 +src/
32 + app/
33 + layout.tsx ← polices, métadonnées par défaut, Header/Footer
34 + (site)/page.tsx ← accueil (7 sections)
35 + (site)/services|a-propos|temoignages|nous-joindre|confidentialite/page.tsx
36 + api/contact/route.ts ← POST : validation Zod, honeypot, limitation de débit, envoi SMTP
37 + sitemap.ts, robots.ts, icon.svg, opengraph-image.tsx
38 + components/
39 + layout/ Header, MobileNav, Footer
40 + sections/ Hero, Services, About, Testimonials, CallToAction
41 + graphics/ Logo, GoldenSpiral, WaveDivider, BotanicalMotif, placeholders photo
42 + ui/ Button, Container, SectionTitle, Icon…
43 + forms/ ContactForm (client)
44 + lib/ content.ts (chargeur + schémas), env.ts, validation.ts, security.ts (rate limit, sanitisation), mail.ts, seo.ts
45 + styles/ tokens.css (design tokens), globals.css
46 +tests/
47 + unit/ Vitest + Testing Library (schémas, validation, rate limit, composants)
48 + e2e/ Playwright (navigation, menu mobile, formulaire, liens, axe, captures)
49 +docs/ IMPLEMENTATION_PLAN, DEPLOYMENT, CLIENT_GUIDE, QA_REPORT, ASSET_REGISTER
50 +```
51 +
52 +## 3. Design system
53 +
54 +- Tokens CSS (`src/styles/tokens.css`) : couleurs échantillonnées, échelle typographique `clamp()`, espacements, rayons, ombres, mouvement. Exposés à Tailwind 4 via `@theme`.
55 +- Titres : **EB Garamond** (OFL) ; corps : **Figtree** (OFL). Chargées par `next/font/google` (auto-hébergées au build, `display: swap`). À remplacer par les polices officielles du client si elles existent.
56 +- Motifs : spirale de Fibonacci (carrés + arcs de quart de cercle, ratio 1,618 approché par la suite 1-1-2-3-5-8-13-21), vagues, feuillage — tous en SVG inline.
57 +
58 +## 4. Contenu administrable — décision
59 +
60 +Le cahier des charges recommande un CMS maintenu (Payload / Sanity) tout en demandant d'éviter de surdimensionner si une solution statique convient et de **demander validation**.
61 +
62 +Choix livré : **contenu structuré en fichiers JSON versionnés** (`content/*.json`), validé par Zod, modifiable sans toucher au code applicatif, avec guide client. Raisons : aucun hébergement de base de données ni de compte tiers à gérer, pas de surface d'authentification à sécuriser, sauvegarde = historique Git, coût nul, déploiement statique possible.
63 +
64 +Compromis documentés : l'édition exige un éditeur de texte ou l'interface web Git ; pas d'aperçu WYSIWYG ni de gestion de médias par glisser-déposer. Chemin d'évolution : `src/lib/content.ts` est l'unique point d'accès au contenu ; brancher Payload (SQLite/Postgres, intégré à Next) ou Sanity consiste à remplacer ce module en conservant les schémas Zod. **Décision à valider par le client.**
65 +
66 +## 5. Étapes
67 +
68 +1. Audit + plan (ce document). ✔
69 +2. Design system : tokens, polices, UI de base, logo et motifs SVG.
70 +3. Accueil fidèle : 7 sections, comparaison Playwright à 1024 px (largeur de la capture) puis 1440 px.
71 +4. Pages internes + chargeur de contenu + visibilité des sections.
72 +5. Formulaire de contact + API + messagerie SMTP configurable + protection antispam.
73 +6. Validation : lint, typecheck, Vitest, Playwright (6 largeurs, axe), build de production.
74 +7. Livraison : README, .env.example, docs/DEPLOYMENT, CLIENT_GUIDE, QA_REPORT, ASSET_REGISTER.
75 +
76 +## 6. Critères d'acceptation par section (checklist)
77 +
78 +- [ ] En-tête : logo + nom + signature, 5 liens, CTA bleu arrondi « Contactez-nous → », lien actif souligné, menu mobile accessible, en-tête collant sobre.
79 +- [ ] Hero : fond ivoire, H1 serif 3 lignes, sous-titre, bouton bleu, illustration à droite avec spirale dorée superposée, vagues ivoire/bleu pâle en bas.
80 +- [ ] Services : titre centré entre filets dorés, 4 cartes (disque bleu-gris + icône au trait), textes exacts.
81 +- [ ] À propos : image verticale à gauche avec courbe ivoire, surtitre doré, H2, paragraphe, bouton contour, 4 bénéfices avec coches dorées séparés par un filet vertical.
82 +- [ ] Témoignages : encadré bleu pâle arrondi, guillemets dorés, serif italique, pagination ; slider accessible ; masqué en prod sans avis validé.
83 +- [ ] Bandeau : fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés à droite.
84 +- [ ] Pied de page : logo + nom, liens, icônes LinkedIn/courriel (si fournis), copyright dynamique, signature, liens légaux.
85 +- [ ] Responsive 360/390/768/1024/1440/1920 sans débordement horizontal.
86 +- [ ] Formulaire : validation client/serveur, honeypot, rate limit, SMTP configurable, états succès/erreur, tests.
87 +- [ ] Lint, typecheck, tests, build, axe : verts ou écarts documentés dans `docs/QA_REPORT.md`.
added docs/screenshots/.gitkeep +0 −0
modified next.config.ts +9 −1
@@ -1,9 +1,17 @@
1 1 import type { NextConfig } from "next";
2 +import { securityHeaders } from "./src/lib/security";
2 3
3 4 const nextConfig: NextConfig = {
4 − /* config options here */
5 5 cacheComponents: true,
6 6 partialPrefetching: true,
7 + poweredByHeader: false,
8 + reactStrictMode: true,
9 + images: {
10 + formats: ["image/avif", "image/webp"],
11 + },
12 + async headers() {
13 + return [{ source: "/(.*)", headers: securityHeaders }];
14 + },
7 15 turbopack: {
8 16 rules: {
9 17 "*.css": {
modified package.json +27 −3
@@ -6,22 +6,46 @@
6 6 "dev": "next dev",
7 7 "build": "next build",
8 8 "start": "next start",
9 − "lint": "eslint"
9 + "lint": "eslint",
10 + "typecheck": "tsc --noEmit",
11 + "format": "prettier --write .",
12 + "format:check": "prettier --check .",
13 + "test": "vitest run",
14 + "test:watch": "vitest",
15 + "test:e2e": "playwright test",
16 + "icons": "node --experimental-strip-types scripts/generate-icons.mts",
17 + "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build"
10 18 },
11 19 "dependencies": {
20 + "@hookform/resolvers": "^7.0.3",
12 21 "next": "16.4.5",
22 + "nodemailer": "^10.3.1",
13 23 "react": "19.3.0",
14 − "react-dom": "19.3.0"
24 + "react-dom": "19.3.0",
25 + "react-hook-form": "^7.91.0",
26 + "sharp": "^0.35.5",
27 + "zod": "^4.6.7"
15 28 },
16 29 "devDependencies": {
30 + "@axe-core/playwright": "^4.14.0",
31 + "@playwright/test": "^1.65.0",
17 32 "@tailwindcss/turbopack": "^4",
33 + "@testing-library/jest-dom": "^7.0.1",
34 + "@testing-library/react": "^16.3.3",
35 + "@testing-library/user-event": "^14.6.8",
18 36 "@types/node": "^20",
37 + "@types/nodemailer": "^8.0.2",
19 38 "@types/react": "^19",
20 39 "@types/react-dom": "^19",
40 + "@vitejs/plugin-react": "^6.2.1",
21 41 "eslint": "^9",
22 42 "eslint-config-next": "16.4.5",
43 + "jsdom": "^31.0.0",
44 + "prettier": "^3.9.10",
45 + "prettier-plugin-tailwindcss": "^0.8.1",
23 46 "tailwindcss": "^4",
24 − "typescript": "^5"
47 + "typescript": "^5",
48 + "vitest": "^5.1.0"
25 49 },
26 50 "packageManager": "pnpm@11.1.2"
27 51 }
added playwright.config.ts +26 −0
@@ -0,0 +1,26 @@
1 +import { defineConfig, devices } from "@playwright/test";
2 +
3 +const PORT = process.env.PW_PORT ?? "3100";
4 +const baseURL = `http://127.0.0.1:${PORT}`;
5 +
6 +export default defineConfig({
7 + testDir: "./tests/e2e",
8 + fullyParallel: true,
9 + retries: process.env.CI ? 1 : 0,
10 + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
11 + use: { baseURL, trace: "retain-on-failure", locale: "fr-CA" },
12 + webServer: {
13 + command: `pnpm start -p ${PORT}`,
14 + url: baseURL,
15 + reuseExistingServer: !process.env.CI,
16 + timeout: 120_000,
17 + env: { CONTACT_TRANSPORT: "log", NEXT_PUBLIC_SITE_URL: baseURL },
18 + },
19 + projects: [
20 + {
21 + name: "desktop",
22 + use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
23 + },
24 + { name: "mobile", use: { ...devices["iPhone 13"] } },
25 + ],
26 +});
modified pnpm-lock.yaml +1098 −5
@@ -8,47 +8,104 @@ importers:
8 8
9 9 .:
10 10 dependencies:
11 + '@hookform/resolvers':
12 + specifier: ^7.0.3
13 + version: 7.0.3(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.0))(zod@4.6.7)
11 14 next:
12 15 specifier: 16.4.5
13 − version: 16.4.5(@babel/core@7.29.13)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
16 + version: 16.4.5(@babel/core@7.29.13)(@playwright/test@1.65.0)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
17 + nodemailer:
18 + specifier: ^10.3.1
19 + version: 10.3.1
14 20 react:
15 21 specifier: 19.3.0
16 22 version: 19.3.0
17 23 react-dom:
18 24 specifier: 19.3.0
19 25 version: 19.3.0(react@19.3.0)
26 + react-hook-form:
27 + specifier: ^7.91.0
28 + version: 7.91.0(@types/react@19.3.0)(react@19.3.0)
29 + sharp:
30 + specifier: ^0.35.5
31 + version: 0.35.5(@types/node@20.19.43)
32 + zod:
33 + specifier: ^4.6.7
34 + version: 4.6.7
20 35 devDependencies:
36 + '@axe-core/playwright':
37 + specifier: ^4.14.0
38 + version: 4.14.0(playwright-core@1.65.0)
39 + '@playwright/test':
40 + specifier: ^1.65.0
41 + version: 1.65.0
21 42 '@tailwindcss/turbopack':
22 43 specifier: ^4
23 44 version: 4.3.5
45 + '@testing-library/jest-dom':
46 + specifier: ^7.0.1
47 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)))
48 + '@testing-library/react':
49 + specifier: ^16.3.3
50 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
51 + '@testing-library/user-event':
52 + specifier: ^14.6.8
53 + version: 14.6.8(@testing-library/dom@10.4.2)
24 54 '@types/node':
25 55 specifier: ^20
26 56 version: 20.19.43
57 + '@types/nodemailer':
58 + specifier: ^8.0.2
59 + version: 8.0.2
27 60 '@types/react':
28 61 specifier: ^19
29 62 version: 19.3.0
30 63 '@types/react-dom':
31 64 specifier: ^19
32 65 version: 19.3.0(@types/react@19.3.0)
66 + '@vitejs/plugin-react':
67 + specifier: ^6.2.1
68 + version: 6.2.1(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))
33 69 eslint:
34 70 specifier: ^9
35 71 version: 9.39.5(jiti@2.7.0)
36 72 eslint-config-next:
37 73 specifier: 16.4.5
38 74 version: 16.4.5(@typescript-eslint/parser@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
75 + jsdom:
76 + specifier: ^31.0.0
77 + version: 31.0.0
78 + prettier:
79 + specifier: ^3.9.10
80 + version: 3.9.10
81 + prettier-plugin-tailwindcss:
82 + specifier: ^0.8.1
83 + version: 0.8.1(prettier@3.9.10)
39 84 tailwindcss:
40 85 specifier: ^4
41 86 version: 4.3.5
42 87 typescript:
43 88 specifier: ^5
44 89 version: 5.9.3
90 + vitest:
91 + specifier: ^5.1.0
92 + version: 5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))
45 93
46 94 packages:
47 95
96 + '@adobe/css-tools@4.5.0':
97 + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
98 +
48 99 '@alloc/quick-lru@5.3.0':
49 100 resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
50 101 engines: {node: '>=10'}
51 102
103 + '@axe-core/playwright@4.14.0':
104 + resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==}
105 + engines: {node: '>=22.12.0'}
106 + peerDependencies:
107 + playwright-core: '>= 1.0.0'
108 +
52 109 '@babel/code-frame@7.29.7':
53 110 resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
54 111 engines: {node: '>=6.9.0'}
@@ -104,6 +161,10 @@ packages:
104 161 engines: {node: '>=6.0.0'}
105 162 hasBin: true
106 163
164 + '@babel/runtime@7.29.13':
165 + resolution: {integrity: sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==}
166 + engines: {node: '>=6.9.0'}
167 +
107 168 '@babel/template@7.29.7':
108 169 resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
109 170 engines: {node: '>=6.9.0'}
@@ -163,6 +224,90 @@ packages:
163 224 resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
164 225 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
165 226
227 + '@exodus/bytes@1.16.0':
228 + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
229 + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
230 + peerDependencies:
231 + '@noble/hashes': ^1.8.0 || ^2.0.0
232 + peerDependenciesMeta:
233 + '@noble/hashes':
234 + optional: true
235 +
236 + '@hookform/resolvers@7.0.3':
237 + resolution: {integrity: sha512-beGY1JY6K5cyWlswD4BVf7UTb5MxSjKkjljrRyLPT3KIQzsTU0jOibBKqtdcKqIZYQUUyURed7roLWZtSdAtiQ==}
238 + peerDependencies:
239 + '@standard-schema/spec': ^1.0.0
240 + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
241 + ajv: ^8.12.0
242 + ajv-errors: ^3.0.0
243 + ajv-formats: ^2.1.1
244 + arktype: ^2.0.0
245 + ata-validator: ^1.23.0
246 + class-transformer: '>=0.4.0'
247 + class-validator: '>=0.12.0'
248 + computed-types: ^1.0.0
249 + effect: ^4.0.0
250 + fluentvalidation-ts: ^3.0.0
251 + fp-ts: ^2.7.0
252 + io-ts: ^2.0.0
253 + joi: ^17.0.0 || ^18.0.0
254 + nope-validator: '>=0.12.0'
255 + react-hook-form: ^7.55.0
256 + superstruct: '>=0.12.0'
257 + typanion: ^3.3.2
258 + typebox: ^1.0.0
259 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
260 + vest: '>=6.0.0'
261 + yup: ^1.0.0
262 + zod: ^3.25.0 || ^4.0.0
263 + peerDependenciesMeta:
264 + '@standard-schema/spec':
265 + optional: true
266 + '@vinejs/vine':
267 + optional: true
268 + ajv:
269 + optional: true
270 + ajv-errors:
271 + optional: true
272 + ajv-formats:
273 + optional: true
274 + arktype:
275 + optional: true
276 + ata-validator:
277 + optional: true
278 + class-transformer:
279 + optional: true
280 + class-validator:
281 + optional: true
282 + computed-types:
283 + optional: true
284 + effect:
285 + optional: true
286 + fluentvalidation-ts:
287 + optional: true
288 + fp-ts:
289 + optional: true
290 + io-ts:
291 + optional: true
292 + joi:
293 + optional: true
294 + nope-validator:
295 + optional: true
296 + superstruct:
297 + optional: true
298 + typanion:
299 + optional: true
300 + typebox:
301 + optional: true
302 + valibot:
303 + optional: true
304 + vest:
305 + optional: true
306 + yup:
307 + optional: true
308 + zod:
309 + optional: true
310 +
166 311 '@humanfs/core@0.19.2':
167 312 resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
168 313 engines: {node: '>=18.18.0'}
@@ -435,9 +580,119 @@ packages:
435 580 resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
436 581 engines: {node: '>=12.4.0'}
437 582
583 + '@oxc-project/types@0.156.0':
584 + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==}
585 +
586 + '@playwright/test@1.65.0':
587 + resolution: {integrity: sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==}
588 + engines: {node: '>=20'}
589 + hasBin: true
590 +
591 + '@rolldown/binding-android-arm-eabi@1.2.16':
592 + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==}
593 + engines: {node: ^20.19.0 || >=22.12.0}
594 + cpu: [arm]
595 + os: [android]
596 +
597 + '@rolldown/binding-android-arm64@1.2.16':
598 + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==}
599 + engines: {node: ^20.19.0 || >=22.12.0}
600 + cpu: [arm64]
601 + os: [android]
602 +
603 + '@rolldown/binding-darwin-arm64@1.2.16':
604 + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==}
605 + engines: {node: ^20.19.0 || >=22.12.0}
606 + cpu: [arm64]
607 + os: [darwin]
608 +
609 + '@rolldown/binding-darwin-x64@1.2.16':
610 + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==}
611 + engines: {node: ^20.19.0 || >=22.12.0}
612 + cpu: [x64]
613 + os: [darwin]
614 +
615 + '@rolldown/binding-freebsd-x64@1.2.16':
616 + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==}
617 + engines: {node: ^20.19.0 || >=22.12.0}
618 + cpu: [x64]
619 + os: [freebsd]
620 +
621 + '@rolldown/binding-linux-arm-gnueabihf@1.2.16':
622 + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==}
623 + engines: {node: ^20.19.0 || >=22.12.0}
624 + cpu: [arm]
625 + os: [linux]
626 +
627 + '@rolldown/binding-linux-arm64-gnu@1.2.16':
628 + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==}
629 + engines: {node: ^20.19.0 || >=22.12.0}
630 + cpu: [arm64]
631 + os: [linux]
632 + libc: [glibc]
633 +
634 + '@rolldown/binding-linux-arm64-musl@1.2.16':
635 + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
636 + engines: {node: ^20.19.0 || >=22.12.0}
637 + cpu: [arm64]
638 + os: [linux]
639 + libc: [musl]
640 +
641 + '@rolldown/binding-linux-ppc64-gnu@1.2.16':
642 + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
643 + engines: {node: ^20.19.0 || >=22.12.0}
644 + cpu: [ppc64]
645 + os: [linux]
646 + libc: [glibc]
647 +
648 + '@rolldown/binding-linux-s390x-gnu@1.2.16':
649 + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
650 + engines: {node: ^20.19.0 || >=22.12.0}
651 + cpu: [s390x]
652 + os: [linux]
653 + libc: [glibc]
654 +
655 + '@rolldown/binding-linux-x64-gnu@1.2.16':
656 + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
657 + engines: {node: ^20.19.0 || >=22.12.0}
658 + cpu: [x64]
659 + os: [linux]
660 + libc: [glibc]
661 +
662 + '@rolldown/binding-linux-x64-musl@1.2.16':
663 + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
664 + engines: {node: ^20.19.0 || >=22.12.0}
665 + cpu: [x64]
666 + os: [linux]
667 + libc: [musl]
668 +
669 + '@rolldown/binding-openharmony-arm64@1.2.16':
670 + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
671 + engines: {node: ^20.19.0 || >=22.12.0}
672 + cpu: [arm64]
673 + os: [openharmony]
674 +
675 + '@rolldown/binding-win32-arm64-msvc@1.2.16':
676 + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==}
677 + engines: {node: ^20.19.0 || >=22.12.0}
678 + cpu: [arm64]
679 + os: [win32]
680 +
681 + '@rolldown/binding-win32-x64-msvc@1.2.16':
682 + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==}
683 + engines: {node: ^20.19.0 || >=22.12.0}
684 + cpu: [x64]
685 + os: [win32]
686 +
687 + '@rolldown/pluginutils@1.0.1':
688 + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
689 +
438 690 '@rtsao/scc@1.1.0':
439 691 resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
440 692
693 + '@standard-schema/utils@0.3.0':
694 + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
695 +
441 696 '@swc/helpers@0.5.23':
442 697 resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
443 698
@@ -532,6 +787,50 @@ packages:
532 787 '@tailwindcss/turbopack@4.3.5':
533 788 resolution: {integrity: sha512-0f0rOTyz4yGtPzpSiIP3ppAaYtMnQbGnEsHPGK4Yy8wNbE4xZWlNPjWtD6oyXvQZTg2N0XF4BZEhfsgvJe889g==}
534 789
790 + '@testing-library/dom@10.4.2':
791 + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
792 + engines: {node: '>=18'}
793 +
794 + '@testing-library/jest-dom@7.0.1':
795 + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==}
796 + engines: {node: '>=22', npm: '>=6', yarn: '>=1'}
797 + peerDependencies:
798 + '@testing-library/dom': '>=10 <11'
799 + vitest: '>= 0.32'
800 + peerDependenciesMeta:
801 + vitest:
802 + optional: true
803 +
804 + '@testing-library/react@16.3.3':
805 + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
806 + engines: {node: '>=18'}
807 + peerDependencies:
808 + '@testing-library/dom': ^10.0.0
809 + '@types/react': ^18.0.0 || ^19.0.0
810 + '@types/react-dom': ^18.0.0 || ^19.0.0
811 + react: ^18.0.0 || ^19.0.0
812 + react-dom: ^18.0.0 || ^19.0.0
813 + peerDependenciesMeta:
814 + '@types/react':
815 + optional: true
816 + '@types/react-dom':
817 + optional: true
818 +
819 + '@testing-library/user-event@14.6.8':
820 + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==}
821 + engines: {node: '>=12', npm: '>=6'}
822 + peerDependencies:
823 + '@testing-library/dom': '>=7.21.4'
824 +
825 + '@types/aria-query@5.0.4':
826 + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
827 +
828 + '@types/chai@5.2.3':
829 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
830 +
831 + '@types/deep-eql@4.0.2':
832 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
833 +
535 834 '@types/estree@1.0.10':
536 835 resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==}
537 836
@@ -544,6 +843,9 @@ packages:
544 843 '@types/node@20.19.43':
545 844 resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
546 845
846 + '@types/nodemailer@8.0.2':
847 + resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
848 +
547 849 '@types/react-dom@19.3.0':
548 850 resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
549 851 peerDependencies:
@@ -726,6 +1028,36 @@ packages:
726 1028 cpu: [x64]
727 1029 os: [win32]
728 1030
1031 + '@vitejs/plugin-react@6.2.1':
1032 + resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==}
1033 + engines: {node: ^20.19.0 || >=22.12.0}
1034 + peerDependencies:
1035 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0
1036 + babel-plugin-react-compiler: ^1.0.0
1037 + oxc-transform-react: '>=0.152.0 <0.157.0'
1038 + vite: ^8.0.0
1039 + peerDependenciesMeta:
1040 + '@rolldown/plugin-babel':
1041 + optional: true
1042 + babel-plugin-react-compiler:
1043 + optional: true
1044 + oxc-transform-react:
1045 + optional: true
1046 +
1047 + '@vitest/mocker@5.1.0':
1048 + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==}
1049 + peerDependencies:
1050 + msw: ^2.4.9 || ^3.0.0
1051 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0
1052 + peerDependenciesMeta:
1053 + msw:
1054 + optional: true
1055 + vite:
1056 + optional: true
1057 +
1058 + '@vitest/spy@5.1.0':
1059 + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==}
1060 +
729 1061 acorn-jsx@5.3.2:
730 1062 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
731 1063 peerDependencies:
@@ -739,13 +1071,24 @@ packages:
739 1071 ajv@6.15.0:
740 1072 resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
741 1073
1074 + ansi-regex@5.0.1:
1075 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
1076 + engines: {node: '>=8'}
1077 +
742 1078 ansi-styles@4.3.0:
743 1079 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
744 1080 engines: {node: '>=8'}
745 1081
1082 + ansi-styles@5.2.0:
1083 + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
1084 + engines: {node: '>=10'}
1085 +
746 1086 argparse@2.0.1:
747 1087 resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
748 1088
1089 + aria-query@5.3.0:
1090 + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
1091 +
749 1092 aria-query@5.3.2:
750 1093 resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
751 1094 engines: {node: '>= 0.4'}
@@ -782,6 +1125,10 @@ packages:
782 1125 resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
783 1126 engines: {node: '>= 0.4'}
784 1127
1128 + assertion-error@2.0.1:
1129 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
1130 + engines: {node: '>=12'}
1131 +
785 1132 ast-types-flow@0.0.8:
786 1133 resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
787 1134
@@ -813,6 +1160,12 @@ packages:
813 1160 engines: {node: '>=6.0.0'}
814 1161 hasBin: true
815 1162
1163 + bcp-47-match@2.0.3:
1164 + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
1165 +
1166 + bcp-47@2.1.2:
1167 + resolution: {integrity: sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==}
1168 +
816 1169 brace-expansion@1.1.21:
817 1170 resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
818 1171
@@ -848,6 +1201,10 @@ packages:
848 1201 caniuse-lite@1.0.30001823:
849 1202 resolution: {integrity: sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==}
850 1203
1204 + chai@6.3.0:
1205 + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
1206 + engines: {node: '>=18'}
1207 +
851 1208 chalk@4.1.2:
852 1209 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
853 1210 engines: {node: '>=10'}
@@ -872,12 +1229,19 @@ packages:
872 1229 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
873 1230 engines: {node: '>= 8'}
874 1231
1232 + css.escape@1.5.1:
1233 + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
1234 +
875 1235 csstype@3.2.3:
876 1236 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
877 1237
878 1238 damerau-levenshtein@1.0.8:
879 1239 resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
880 1240
1241 + data-urls@8.0.0:
1242 + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==}
1243 + engines: {node: ^22.14.0 || >=24.0.0}
1244 +
881 1245 data-view-buffer@1.0.2:
882 1246 resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
883 1247 engines: {node: '>= 0.4'}
@@ -907,6 +1271,9 @@ packages:
907 1271 supports-color:
908 1272 optional: true
909 1273
1274 + decimal.js@10.6.0:
1275 + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
1276 +
910 1277 deep-is@0.1.4:
911 1278 resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
912 1279
@@ -918,6 +1285,10 @@ packages:
918 1285 resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
919 1286 engines: {node: '>= 0.4'}
920 1287
1288 + dequal@2.0.3:
1289 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1290 + engines: {node: '>=6'}
1291 +
921 1292 detect-libc@2.1.2:
922 1293 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
923 1294 engines: {node: '>=8'}
@@ -926,6 +1297,12 @@ packages:
926 1297 resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
927 1298 engines: {node: '>=0.10.0'}
928 1299
1300 + dom-accessibility-api@0.5.16:
1301 + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
1302 +
1303 + dom-accessibility-api@0.6.3:
1304 + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
1305 +
929 1306 dunder-proto@1.0.1:
930 1307 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
931 1308 engines: {node: '>= 0.4'}
@@ -940,6 +1317,10 @@ packages:
940 1317 resolution: {integrity: sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==}
941 1318 engines: {node: '>=10.13.0'}
942 1319
1320 + entities@9.0.0:
1321 + resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==}
1322 + engines: {node: '>=20.19.0'}
1323 +
943 1324 es-abstract-get@1.0.0:
944 1325 resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
945 1326 engines: {node: '>= 0.4'}
@@ -960,6 +1341,9 @@ packages:
960 1341 resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
961 1342 engines: {node: '>= 0.4'}
962 1343
1344 + es-module-lexer@2.3.2:
1345 + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
1346 +
963 1347 es-object-atoms@1.1.2:
964 1348 resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
965 1349 engines: {node: '>= 0.4'}
@@ -1101,10 +1485,17 @@ packages:
1101 1485 resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1102 1486 engines: {node: '>=4.0'}
1103 1487
1488 + estree-walker@3.0.3:
1489 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
1490 +
1104 1491 esutils@2.0.3:
1105 1492 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1106 1493 engines: {node: '>=0.10.0'}
1107 1494
1495 + expect-type@1.4.0:
1496 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
1497 + engines: {node: '>=12.0.0'}
1498 +
1108 1499 fast-deep-equal@3.1.3:
1109 1500 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1110 1501
@@ -1153,6 +1544,11 @@ packages:
1153 1544 resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1154 1545 engines: {node: '>= 0.4'}
1155 1546
1547 + fsevents@2.3.3:
1548 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1549 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1550 + os: [darwin]
1551 +
1156 1552 function-bind@1.1.2:
1157 1553 resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1158 1554
@@ -1246,6 +1642,10 @@ packages:
1246 1642 hermes-parser@0.25.1:
1247 1643 resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
1248 1644
1645 + html-encoding-sniffer@7.0.0:
1646 + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==}
1647 + engines: {node: ^22.13.0 || >=24.0.0}
1648 +
1249 1649 ignore@5.3.2:
1250 1650 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1251 1651 engines: {node: '>= 4'}
@@ -1262,10 +1662,20 @@ packages:
1262 1662 resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1263 1663 engines: {node: '>=0.8.19'}
1264 1664
1665 + indent-string@4.0.0:
1666 + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
1667 + engines: {node: '>=8'}
1668 +
1265 1669 internal-slot@1.1.0:
1266 1670 resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1267 1671 engines: {node: '>= 0.4'}
1268 1672
1673 + is-alphabetical@2.0.1:
1674 + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
1675 +
1676 + is-alphanumerical@2.0.1:
1677 + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
1678 +
1269 1679 is-array-buffer@3.0.5:
1270 1680 resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1271 1681 engines: {node: '>= 0.4'}
@@ -1301,6 +1711,9 @@ packages:
1301 1711 resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1302 1712 engines: {node: '>= 0.4'}
1303 1713
1714 + is-decimal@2.0.1:
1715 + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
1716 +
1304 1717 is-document.all@1.0.0:
1305 1718 resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1306 1719 engines: {node: '>= 0.4'}
@@ -1337,6 +1750,9 @@ packages:
1337 1750 resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1338 1751 engines: {node: '>=0.12.0'}
1339 1752
1753 + is-potential-custom-element-name@1.0.1:
1754 + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
1755 +
1340 1756 is-regex@1.2.1:
1341 1757 resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1342 1758 engines: {node: '>= 0.4'}
@@ -1394,6 +1810,15 @@ packages:
1394 1810 resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
1395 1811 hasBin: true
1396 1812
1813 + jsdom@31.0.0:
1814 + resolution: {integrity: sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==}
1815 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
1816 + peerDependencies:
1817 + '@napi-rs/canvas': ^1.0.13
1818 + peerDependenciesMeta:
1819 + '@napi-rs/canvas':
1820 + optional: true
1821 +
1397 1822 jsesc@3.1.0:
1398 1823 resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
1399 1824 engines: {node: '>=6'}
@@ -1520,9 +1945,17 @@ packages:
1520 1945 resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1521 1946 hasBin: true
1522 1947
1948 + lru-cache@11.5.3:
1949 + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
1950 + engines: {node: 20 || >=22}
1951 +
1523 1952 lru-cache@5.1.1:
1524 1953 resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
1525 1954
1955 + lz-string@1.5.0:
1956 + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
1957 + hasBin: true
1958 +
1526 1959 magic-string@1.4.5:
1527 1960 resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}
1528 1961
@@ -1538,6 +1971,10 @@ packages:
1538 1971 resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1539 1972 engines: {node: '>=8.6'}
1540 1973
1974 + min-indent@1.0.1:
1975 + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
1976 + engines: {node: '>=4'}
1977 +
1541 1978 minimatch@10.2.6:
1542 1979 resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1543 1980 engines: {node: 18 || 20 || >=22}
@@ -1593,6 +2030,10 @@ packages:
1593 2030 resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==}
1594 2031 engines: {node: '>=18'}
1595 2032
2033 + nodemailer@10.3.1:
2034 + resolution: {integrity: sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==}
2035 + engines: {node: '>=20.0.0'}
2036 +
1596 2037 object-assign@4.1.1:
1597 2038 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1598 2039 engines: {node: '>=0.10.0'}
@@ -1625,6 +2066,10 @@ packages:
1625 2066 resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
1626 2067 engines: {node: '>= 0.4'}
1627 2068
2069 + obug@3.0.0:
2070 + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
2071 + engines: {node: '>=12.20.0'}
2072 +
1628 2073 optionator@0.9.4:
1629 2074 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1630 2075 engines: {node: '>= 0.8.0'}
@@ -1645,6 +2090,12 @@ packages:
1645 2090 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
1646 2091 engines: {node: '>=6'}
1647 2092
2093 + parse5-parser-stream@8.1.0:
2094 + resolution: {integrity: sha512-58MPfNTA+DsIzro41rU94xghnu/h2N4yEPdPdGPlZVHM/QvA5jlVGYVo7IzlHbFLHYx3cZUnr9s9HBrA0tEYyw==}
2095 +
2096 + parse5@8.1.0:
2097 + resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==}
2098 +
1648 2099 path-exists@4.0.0:
1649 2100 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
1650 2101 engines: {node: '>=8'}
@@ -1667,6 +2118,16 @@ packages:
1667 2118 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
1668 2119 engines: {node: '>=12'}
1669 2120
2121 + playwright-core@1.65.0:
2122 + resolution: {integrity: sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==}
2123 + engines: {node: '>=20'}
2124 + hasBin: true
2125 +
2126 + playwright@1.65.0:
2127 + resolution: {integrity: sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==}
2128 + engines: {node: '>=20'}
2129 + hasBin: true
2130 +
1670 2131 possible-typed-array-names@1.1.0:
1671 2132 resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
1672 2133 engines: {node: '>= 0.4'}
@@ -1675,10 +2136,78 @@ packages:
1675 2136 resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
1676 2137 engines: {node: ^10 || ^12 || >=14}
1677 2138
2139 + postcss@8.5.32:
2140 + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
2141 + engines: {node: ^10 || ^12 || >=14}
2142 +
1678 2143 prelude-ls@1.2.1:
1679 2144 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1680 2145 engines: {node: '>= 0.8.0'}
1681 2146
2147 + prettier-plugin-tailwindcss@0.8.1:
2148 + resolution: {integrity: sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==}
2149 + engines: {node: '>=20.19'}
2150 + peerDependencies:
2151 + '@ianvs/prettier-plugin-sort-imports': '*'
2152 + '@prettier/plugin-hermes': '*'
2153 + '@prettier/plugin-oxc': '*'
2154 + '@prettier/plugin-pug': '*'
2155 + '@shopify/prettier-plugin-liquid': '*'
2156 + '@trivago/prettier-plugin-sort-imports': '*'
2157 + '@zackad/prettier-plugin-twig': '*'
2158 + prettier: ^3.0
2159 + prettier-plugin-astro: '*'
2160 + prettier-plugin-css-order: '*'
2161 + prettier-plugin-jsdoc: '*'
2162 + prettier-plugin-marko: '*'
2163 + prettier-plugin-multiline-arrays: '*'
2164 + prettier-plugin-organize-attributes: '*'
2165 + prettier-plugin-organize-imports: '*'
2166 + prettier-plugin-sort-imports: '*'
2167 + prettier-plugin-svelte: '*'
2168 + peerDependenciesMeta:
2169 + '@ianvs/prettier-plugin-sort-imports':
2170 + optional: true
2171 + '@prettier/plugin-hermes':
2172 + optional: true
2173 + '@prettier/plugin-oxc':
2174 + optional: true
2175 + '@prettier/plugin-pug':
2176 + optional: true
2177 + '@shopify/prettier-plugin-liquid':
2178 + optional: true
2179 + '@trivago/prettier-plugin-sort-imports':
2180 + optional: true
2181 + '@zackad/prettier-plugin-twig':
2182 + optional: true
2183 + prettier-plugin-astro:
2184 + optional: true
2185 + prettier-plugin-css-order:
2186 + optional: true
2187 + prettier-plugin-jsdoc:
2188 + optional: true
2189 + prettier-plugin-marko:
2190 + optional: true
2191 + prettier-plugin-multiline-arrays:
2192 + optional: true
2193 + prettier-plugin-organize-attributes:
2194 + optional: true
2195 + prettier-plugin-organize-imports:
2196 + optional: true
2197 + prettier-plugin-sort-imports:
2198 + optional: true
2199 + prettier-plugin-svelte:
2200 + optional: true
2201 +
2202 + prettier@3.9.10:
2203 + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==}
2204 + engines: {node: '>=14'}
2205 + hasBin: true
2206 +
2207 + pretty-format@27.5.1:
2208 + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
2209 + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
2210 +
1682 2211 prop-types@15.8.1:
1683 2212 resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1684 2213
@@ -1694,13 +2223,30 @@ packages:
1694 2223 peerDependencies:
1695 2224 react: ^19.3.0
1696 2225
2226 + react-hook-form@7.91.0:
2227 + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==}
2228 + engines: {node: '>=18.0.0'}
2229 + peerDependencies:
2230 + '@types/react': '*'
2231 + react: ^16.8.0 || ^17 || ^18 || ^19
2232 + peerDependenciesMeta:
2233 + '@types/react':
2234 + optional: true
2235 +
1697 2236 react-is@16.13.1:
1698 2237 resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1699 2238
2239 + react-is@17.0.2:
2240 + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
2241 +
1700 2242 react@19.3.0:
1701 2243 resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
1702 2244 engines: {node: '>=0.10.0'}
1703 2245
2246 + redent@3.0.0:
2247 + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
2248 + engines: {node: '>=8'}
2249 +
1704 2250 reflect.getprototypeof@1.0.10:
1705 2251 resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
1706 2252 engines: {node: '>= 0.4'}
@@ -1725,6 +2271,11 @@ packages:
1725 2271 resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1726 2272 engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
1727 2273
2274 + rolldown@1.2.16:
2275 + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==}
2276 + engines: {node: ^20.19.0 || >=22.12.0}
2277 + hasBin: true
2278 +
1728 2279 run-parallel@1.2.0:
1729 2280 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1730 2281
@@ -1740,6 +2291,10 @@ packages:
1740 2291 resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
1741 2292 engines: {node: '>= 0.4'}
1742 2293
2294 + saxes@6.0.0:
2295 + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
2296 + engines: {node: '>=v12.22.7'}
2297 +
1743 2298 scheduler@0.28.0:
1744 2299 resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
1745 2300
@@ -1804,6 +2359,9 @@ packages:
1804 2359 stable-hash@0.0.5:
1805 2360 resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
1806 2361
2362 + std-env@4.3.0:
2363 + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
2364 +
1807 2365 stop-iteration-iterator@1.1.0:
1808 2366 resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
1809 2367 engines: {node: '>= 0.4'}
@@ -1835,6 +2393,10 @@ packages:
1835 2393 resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
1836 2394 engines: {node: '>=4'}
1837 2395
2396 + strip-indent@3.0.0:
2397 + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
2398 + engines: {node: '>=8'}
2399 +
1838 2400 strip-json-comments@3.1.1:
1839 2401 resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
1840 2402 engines: {node: '>=8'}
@@ -1867,14 +2429,37 @@ packages:
1867 2429 resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
1868 2430 engines: {node: '>=6'}
1869 2431
2432 + tinybench@6.2.3:
2433 + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==}
2434 + engines: {node: '>=20.0.0'}
2435 +
2436 + tinyexec@1.3.1:
2437 + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
2438 + engines: {node: '>=18'}
2439 +
1870 2440 tinyglobby@0.2.17:
1871 2441 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1872 2442 engines: {node: '>=12.0.0'}
1873 2443
2444 + tldts-core@7.4.20:
2445 + resolution: {integrity: sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==}
2446 +
2447 + tldts@7.4.19:
2448 + resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==}
2449 + hasBin: true
2450 +
1874 2451 to-regex-range@5.0.1:
1875 2452 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1876 2453 engines: {node: '>=8.0'}
1877 2454
2455 + tough-cookie@6.1.0:
2456 + resolution: {integrity: sha512-FTxc8u8Kl2tz0Lat1u8ksvcy8bwHRyEMawwTWybo51JqaC6O7SvfuhAKg+Ld6VqyIe2QRJBzY9HA7y2WyFRT2w==}
2457 + engines: {node: '>=16'}
2458 +
2459 + tr46@7.0.0:
2460 + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
2461 + engines: {node: ^22.14.0 || >=24.0.0}
2462 +
1878 2463 ts-api-utils@2.5.0:
1879 2464 resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
1880 2465 engines: {node: '>=18.12'}
@@ -1926,6 +2511,10 @@ packages:
1926 2511 undici-types@6.21.0:
1927 2512 resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
1928 2513
2514 + undici@8.12.4:
2515 + resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==}
2516 + engines: {node: '>=22.19.0'}
2517 +
1929 2518 unrs-resolver@1.13.0:
1930 2519 resolution: {integrity: sha512-mcPQo9Vm6kXd84d/WqMiYBi2CcA57QHBX/ev5PoUEb8b2oQzUvJ6xYsfseQblnCncgnVmCZGWiFwK7KkS5JZJA==}
1931 2520
@@ -1938,6 +2527,106 @@ packages:
1938 2527 uri-js@4.4.1:
1939 2528 resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
1940 2529
2530 + vite@8.3.6:
2531 + resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==}
2532 + engines: {node: ^20.19.0 || >=22.12.0}
2533 + hasBin: true
2534 + peerDependencies:
2535 + '@types/node': ^20.19.0 || >=22.12.0
2536 + '@vitejs/devtools': ^0.7.1 || ^0.8.0
2537 + esbuild: ^0.27.0 || ^0.28.0
2538 + jiti: '>=1.21.0'
2539 + less: ^4.0.0
2540 + sass: ^1.70.0
2541 + sass-embedded: ^1.70.0
2542 + stylus: '>=0.54.8'
2543 + sugarss: ^5.0.0
2544 + terser: ^5.16.0
2545 + tsx: ^4.8.1
2546 + yaml: ^2.4.2
2547 + peerDependenciesMeta:
2548 + '@types/node':
2549 + optional: true
2550 + '@vitejs/devtools':
2551 + optional: true
2552 + esbuild:
2553 + optional: true
2554 + jiti:
2555 + optional: true
2556 + less:
2557 + optional: true
2558 + sass:
2559 + optional: true
2560 + sass-embedded:
2561 + optional: true
2562 + stylus:
2563 + optional: true
2564 + sugarss:
2565 + optional: true
2566 + terser:
2567 + optional: true
2568 + tsx:
2569 + optional: true
2570 + yaml:
2571 + optional: true
2572 +
2573 + vitest@5.1.0:
2574 + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==}
2575 + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
2576 + hasBin: true
2577 + peerDependencies:
2578 + '@edge-runtime/vm': '*'
2579 + '@opentelemetry/api': ^1.9.0
2580 + '@types/node': ^22.0.0 || >=24.0.0
2581 + '@vitest/browser-playwright': 5.1.0
2582 + '@vitest/browser-preview': 5.1.0
2583 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
2584 + '@vitest/coverage-istanbul': 5.1.0
2585 + '@vitest/coverage-v8': 5.1.0
2586 + '@vitest/ui': 5.1.0
2587 + happy-dom: '*'
2588 + jsdom: '*'
2589 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0
2590 + peerDependenciesMeta:
2591 + '@edge-runtime/vm':
2592 + optional: true
2593 + '@opentelemetry/api':
2594 + optional: true
2595 + '@types/node':
2596 + optional: true
2597 + '@vitest/browser-playwright':
2598 + optional: true
2599 + '@vitest/browser-preview':
2600 + optional: true
2601 + '@vitest/browser-webdriverio':
2602 + optional: true
2603 + '@vitest/coverage-istanbul':
2604 + optional: true
2605 + '@vitest/coverage-v8':
2606 + optional: true
2607 + '@vitest/ui':
2608 + optional: true
2609 + happy-dom:
2610 + optional: true
2611 + jsdom:
2612 + optional: true
2613 +
2614 + w3c-xmlserializer@6.0.0:
2615 + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==}
2616 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
2617 +
2618 + webidl-conversions@8.0.1:
2619 + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
2620 + engines: {node: '>=20'}
2621 +
2622 + whatwg-mimetype@5.0.0:
2623 + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
2624 + engines: {node: '>=20'}
2625 +
2626 + whatwg-url@17.3.0:
2627 + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==}
2628 + engines: {node: ^22.14.0 || >=24.0.0}
2629 +
1941 2630 which-boxed-primitive@1.1.1:
1942 2631 resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
1943 2632 engines: {node: '>= 0.4'}
@@ -1959,10 +2648,22 @@ packages:
1959 2648 engines: {node: '>= 8'}
1960 2649 hasBin: true
1961 2650
2651 + why-is-node-running@3.2.1:
2652 + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
2653 + engines: {node: '>=20.11'}
2654 + hasBin: true
2655 +
1962 2656 word-wrap@1.2.5:
1963 2657 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
1964 2658 engines: {node: '>=0.10.0'}
1965 2659
2660 + xml-name-validator@5.0.0:
2661 + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
2662 + engines: {node: '>=18'}
2663 +
2664 + xmlchars@2.2.0:
2665 + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
2666 +
1966 2667 yallist@3.1.1:
1967 2668 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
1968 2669
@@ -1981,8 +2682,15 @@ packages:
1981 2682
1982 2683 snapshots:
1983 2684
2685 + '@adobe/css-tools@4.5.0': {}
2686 +
1984 2687 '@alloc/quick-lru@5.3.0': {}
1985 2688
2689 + '@axe-core/playwright@4.14.0(playwright-core@1.65.0)':
2690 + dependencies:
2691 + axe-core: 4.14.0
2692 + playwright-core: 1.65.0
2693 +
1986 2694 '@babel/code-frame@7.29.7':
1987 2695 dependencies:
1988 2696 '@babel/helper-validator-identifier': 7.29.7
@@ -2060,6 +2768,8 @@ snapshots:
2060 2768 dependencies:
2061 2769 '@babel/types': 7.29.11
2062 2770
2771 + '@babel/runtime@7.29.13': {}
2772 +
2063 2773 '@babel/template@7.29.7':
2064 2774 dependencies:
2065 2775 '@babel/code-frame': 7.29.7
@@ -2139,6 +2849,15 @@ snapshots:
2139 2849 '@eslint/core': 0.17.0
2140 2850 levn: 0.4.1
2141 2851
2852 + '@exodus/bytes@1.16.0': {}
2853 +
2854 + '@hookform/resolvers@7.0.3(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.0))(zod@4.6.7)':
2855 + dependencies:
2856 + '@standard-schema/utils': 0.3.0
2857 + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.0)
2858 + optionalDependencies:
2859 + zod: 4.6.7
2860 +
2142 2861 '@humanfs/core@0.19.2':
2143 2862 dependencies:
2144 2863 '@humanfs/types': 0.15.0
@@ -2155,8 +2874,7 @@ snapshots:
2155 2874
2156 2875 '@humanwhocodes/retry@0.4.3': {}
2157 2876
2158 − '@img/colour@1.1.0':
2159 − optional: true
2877 + '@img/colour@1.1.0': {}
2160 2878
2161 2879 '@img/sharp-darwin-arm64@0.35.5':
2162 2880 optionalDependencies:
@@ -2328,8 +3046,63 @@ snapshots:
2328 3046
2329 3047 '@nolyfill/is-core-module@1.0.39': {}
2330 3048
3049 + '@oxc-project/types@0.156.0': {}
3050 +
3051 + '@playwright/test@1.65.0':
3052 + dependencies:
3053 + playwright: 1.65.0
3054 +
3055 + '@rolldown/binding-android-arm-eabi@1.2.16':
3056 + optional: true
3057 +
3058 + '@rolldown/binding-android-arm64@1.2.16':
3059 + optional: true
3060 +
3061 + '@rolldown/binding-darwin-arm64@1.2.16':
3062 + optional: true
3063 +
3064 + '@rolldown/binding-darwin-x64@1.2.16':
3065 + optional: true
3066 +
3067 + '@rolldown/binding-freebsd-x64@1.2.16':
3068 + optional: true
3069 +
3070 + '@rolldown/binding-linux-arm-gnueabihf@1.2.16':
3071 + optional: true
3072 +
3073 + '@rolldown/binding-linux-arm64-gnu@1.2.16':
3074 + optional: true
3075 +
3076 + '@rolldown/binding-linux-arm64-musl@1.2.16':
3077 + optional: true
3078 +
3079 + '@rolldown/binding-linux-ppc64-gnu@1.2.16':
3080 + optional: true
3081 +
3082 + '@rolldown/binding-linux-s390x-gnu@1.2.16':
3083 + optional: true
3084 +
3085 + '@rolldown/binding-linux-x64-gnu@1.2.16':
3086 + optional: true
3087 +
3088 + '@rolldown/binding-linux-x64-musl@1.2.16':
3089 + optional: true
3090 +
3091 + '@rolldown/binding-openharmony-arm64@1.2.16':
3092 + optional: true
3093 +
3094 + '@rolldown/binding-win32-arm64-msvc@1.2.16':
3095 + optional: true
3096 +
3097 + '@rolldown/binding-win32-x64-msvc@1.2.16':
3098 + optional: true
3099 +
3100 + '@rolldown/pluginutils@1.0.1': {}
3101 +
2331 3102 '@rtsao/scc@1.1.0': {}
2332 3103
3104 + '@standard-schema/utils@0.3.0': {}
3105 +
2333 3106 '@swc/helpers@0.5.23':
2334 3107 dependencies:
2335 3108 tslib: 2.9.1
@@ -2402,6 +3175,52 @@ snapshots:
2402 3175 '@tailwindcss/oxide': 4.3.5
2403 3176 tailwindcss: 4.3.5
2404 3177
3178 + '@testing-library/dom@10.4.2':
3179 + dependencies:
3180 + '@babel/code-frame': 7.29.7
3181 + '@babel/runtime': 7.29.13
3182 + '@types/aria-query': 5.0.4
3183 + aria-query: 5.3.0
3184 + dom-accessibility-api: 0.5.16
3185 + lz-string: 1.5.0
3186 + picocolors: 1.1.1
3187 + pretty-format: 27.5.1
3188 +
3189 + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)))':
3190 + dependencies:
3191 + '@adobe/css-tools': 4.5.0
3192 + '@testing-library/dom': 10.4.2
3193 + aria-query: 5.3.2
3194 + css.escape: 1.5.1
3195 + dom-accessibility-api: 0.6.3
3196 + picocolors: 1.1.1
3197 + redent: 3.0.0
3198 + optionalDependencies:
3199 + vitest: 5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))
3200 +
3201 + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
3202 + dependencies:
3203 + '@babel/runtime': 7.29.13
3204 + '@testing-library/dom': 10.4.2
3205 + react: 19.3.0
3206 + react-dom: 19.3.0(react@19.3.0)
3207 + optionalDependencies:
3208 + '@types/react': 19.3.0
3209 + '@types/react-dom': 19.3.0(@types/react@19.3.0)
3210 +
3211 + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)':
3212 + dependencies:
3213 + '@testing-library/dom': 10.4.2
3214 +
3215 + '@types/aria-query@5.0.4': {}
3216 +
3217 + '@types/chai@5.2.3':
3218 + dependencies:
3219 + '@types/deep-eql': 4.0.2
3220 + assertion-error: 2.0.1
3221 +
3222 + '@types/deep-eql@4.0.2': {}
3223 +
2405 3224 '@types/estree@1.0.10': {}
2406 3225
2407 3226 '@types/json-schema@7.0.15': {}
@@ -2412,6 +3231,10 @@ snapshots:
2412 3231 dependencies:
2413 3232 undici-types: 6.21.0
2414 3233
3234 + '@types/nodemailer@8.0.2':
3235 + dependencies:
3236 + '@types/node': 20.19.43
3237 +
2415 3238 '@types/react-dom@19.3.0(@types/react@19.3.0)':
2416 3239 dependencies:
2417 3240 '@types/react': 19.3.0
@@ -2574,6 +3397,22 @@ snapshots:
2574 3397 '@unrs/resolver-binding-win32-x64-msvc@1.13.0':
2575 3398 optional: true
2576 3399
3400 + '@vitejs/plugin-react@6.2.1(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))':
3401 + dependencies:
3402 + '@rolldown/pluginutils': 1.0.1
3403 + vite: 8.3.6(@types/node@20.19.43)(jiti@2.7.0)
3404 +
3405 + '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))':
3406 + dependencies:
3407 + '@jridgewell/trace-mapping': 0.3.31
3408 + '@vitest/spy': 5.1.0
3409 + estree-walker: 3.0.3
3410 + magic-string: 1.4.5
3411 + optionalDependencies:
3412 + vite: 8.3.6(@types/node@20.19.43)(jiti@2.7.0)
3413 +
3414 + '@vitest/spy@5.1.0': {}
3415 +
2577 3416 acorn-jsx@5.3.2(acorn@8.19.0):
2578 3417 dependencies:
2579 3418 acorn: 8.19.0
@@ -2587,12 +3426,20 @@ snapshots:
2587 3426 json-schema-traverse: 0.4.1
2588 3427 uri-js: 4.4.1
2589 3428
3429 + ansi-regex@5.0.1: {}
3430 +
2590 3431 ansi-styles@4.3.0:
2591 3432 dependencies:
2592 3433 color-convert: 2.0.1
2593 3434
3435 + ansi-styles@5.2.0: {}
3436 +
2594 3437 argparse@2.0.1: {}
2595 3438
3439 + aria-query@5.3.0:
3440 + dependencies:
3441 + dequal: 2.0.3
3442 +
2596 3443 aria-query@5.3.2: {}
2597 3444
2598 3445 array-buffer-byte-length@1.0.2:
@@ -2662,6 +3509,8 @@ snapshots:
2662 3509 get-intrinsic: 1.3.0
2663 3510 is-array-buffer: 3.0.5
2664 3511
3512 + assertion-error@2.0.1: {}
3513 +
2665 3514 ast-types-flow@0.0.8: {}
2666 3515
2667 3516 async-function@1.0.0: {}
@@ -2680,6 +3529,14 @@ snapshots:
2680 3529
2681 3530 baseline-browser-mapping@2.11.35: {}
2682 3531
3532 + bcp-47-match@2.0.3: {}
3533 +
3534 + bcp-47@2.1.2:
3535 + dependencies:
3536 + is-alphabetical: 2.0.1
3537 + is-alphanumerical: 2.0.1
3538 + is-decimal: 2.0.1
3539 +
2683 3540 brace-expansion@1.1.21:
2684 3541 dependencies:
2685 3542 balanced-match: 1.0.2
@@ -2722,6 +3579,8 @@ snapshots:
2722 3579
2723 3580 caniuse-lite@1.0.30001823: {}
2724 3581
3582 + chai@6.3.0: {}
3583 +
2725 3584 chalk@4.1.2:
2726 3585 dependencies:
2727 3586 ansi-styles: 4.3.0
@@ -2745,10 +3604,19 @@ snapshots:
2745 3604 shebang-command: 2.0.0
2746 3605 which: 2.0.2
2747 3606
3607 + css.escape@1.5.1: {}
3608 +
2748 3609 csstype@3.2.3: {}
2749 3610
2750 3611 damerau-levenshtein@1.0.8: {}
2751 3612
3613 + data-urls@8.0.0:
3614 + dependencies:
3615 + whatwg-mimetype: 5.0.0
3616 + whatwg-url: 17.3.0
3617 + transitivePeerDependencies:
3618 + - '@noble/hashes'
3619 +
2752 3620 data-view-buffer@1.0.2:
2753 3621 dependencies:
2754 3622 call-bound: 1.0.4
@@ -2775,6 +3643,8 @@ snapshots:
2775 3643 dependencies:
2776 3644 ms: 2.1.3
2777 3645
3646 + decimal.js@10.6.0: {}
3647 +
2778 3648 deep-is@0.1.4: {}
2779 3649
2780 3650 define-data-property@1.1.4:
@@ -2789,12 +3659,18 @@ snapshots:
2789 3659 has-property-descriptors: 1.0.2
2790 3660 object-keys: 1.1.1
2791 3661
3662 + dequal@2.0.3: {}
3663 +
2792 3664 detect-libc@2.1.2: {}
2793 3665
2794 3666 doctrine@2.1.0:
2795 3667 dependencies:
2796 3668 esutils: 2.0.3
2797 3669
3670 + dom-accessibility-api@0.5.16: {}
3671 +
3672 + dom-accessibility-api@0.6.3: {}
3673 +
2798 3674 dunder-proto@1.0.1:
2799 3675 dependencies:
2800 3676 call-bind-apply-helpers: 1.0.2
@@ -2810,6 +3686,8 @@ snapshots:
2810 3686 graceful-fs: 4.2.11
2811 3687 tapable: 2.3.3
2812 3688
3689 + entities@9.0.0: {}
3690 +
2813 3691 es-abstract-get@1.0.0:
2814 3692 dependencies:
2815 3693 es-errors: 1.3.0
@@ -2897,6 +3775,8 @@ snapshots:
2897 3775 iterator.prototype: 1.1.5
2898 3776 math-intrinsics: 1.1.0
2899 3777
3778 + es-module-lexer@2.3.2: {}
3779 +
2900 3780 es-object-atoms@1.1.2:
2901 3781 dependencies:
2902 3782 es-errors: 1.3.0
@@ -3128,8 +4008,14 @@ snapshots:
3128 4008
3129 4009 estraverse@5.3.0: {}
3130 4010
4011 + estree-walker@3.0.3:
4012 + dependencies:
4013 + '@types/estree': 1.0.10
4014 +
3131 4015 esutils@2.0.3: {}
3132 4016
4017 + expect-type@1.4.0: {}
4018 +
3133 4019 fast-deep-equal@3.1.3: {}
3134 4020
3135 4021 fast-glob@3.3.1:
@@ -3176,6 +4062,9 @@ snapshots:
3176 4062 dependencies:
3177 4063 is-callable: 1.2.7
3178 4064
4065 + fsevents@2.3.3:
4066 + optional: true
4067 +
3179 4068 function-bind@1.1.2: {}
3180 4069
3181 4070 function.prototype.name@1.2.0:
@@ -3273,6 +4162,12 @@ snapshots:
3273 4162 dependencies:
3274 4163 hermes-estree: 0.25.1
3275 4164
4165 + html-encoding-sniffer@7.0.0:
4166 + dependencies:
4167 + '@exodus/bytes': 1.16.0
4168 + transitivePeerDependencies:
4169 + - '@noble/hashes'
4170 +
3276 4171 ignore@5.3.2: {}
3277 4172
3278 4173 ignore@7.0.14: {}
@@ -3284,12 +4179,21 @@ snapshots:
3284 4179
3285 4180 imurmurhash@0.1.4: {}
3286 4181
4182 + indent-string@4.0.0: {}
4183 +
3287 4184 internal-slot@1.1.0:
3288 4185 dependencies:
3289 4186 es-errors: 1.3.0
3290 4187 hasown: 2.0.4
3291 4188 side-channel: 1.1.1
3292 4189
4190 + is-alphabetical@2.0.1: {}
4191 +
4192 + is-alphanumerical@2.0.1:
4193 + dependencies:
4194 + is-alphabetical: 2.0.1
4195 + is-decimal: 2.0.1
4196 +
3293 4197 is-array-buffer@3.0.5:
3294 4198 dependencies:
3295 4199 call-bind: 1.0.9
@@ -3334,6 +4238,8 @@ snapshots:
3334 4238 call-bound: 1.0.4
3335 4239 has-tostringtag: 1.0.2
3336 4240
4241 + is-decimal@2.0.1: {}
4242 +
3337 4243 is-document.all@1.0.0:
3338 4244 dependencies:
3339 4245 call-bound: 1.0.4
@@ -3367,6 +4273,8 @@ snapshots:
3367 4273
3368 4274 is-number@7.0.0: {}
3369 4275
4276 + is-potential-custom-element-name@1.0.1: {}
4277 +
3370 4278 is-regex@1.2.1:
3371 4279 dependencies:
3372 4280 call-bound: 1.0.4
@@ -3427,6 +4335,29 @@ snapshots:
3427 4335 dependencies:
3428 4336 argparse: 2.0.1
3429 4337
4338 + jsdom@31.0.0:
4339 + dependencies:
4340 + '@exodus/bytes': 1.16.0
4341 + bcp-47: 2.1.2
4342 + bcp-47-match: 2.0.3
4343 + data-urls: 8.0.0
4344 + decimal.js: 10.6.0
4345 + html-encoding-sniffer: 7.0.0
4346 + is-potential-custom-element-name: 1.0.1
4347 + lru-cache: 11.5.3
4348 + parse5: 8.1.0
4349 + parse5-parser-stream: 8.1.0
4350 + saxes: 6.0.0
4351 + tough-cookie: 6.1.0
4352 + undici: 8.12.4
4353 + w3c-xmlserializer: 6.0.0
4354 + webidl-conversions: 8.0.1
4355 + whatwg-mimetype: 5.0.0
4356 + whatwg-url: 17.3.0
4357 + xml-name-validator: 5.0.0
4358 + transitivePeerDependencies:
4359 + - '@noble/hashes'
4360 +
3430 4361 jsesc@3.1.0: {}
3431 4362
3432 4363 json-buffer@3.0.1: {}
@@ -3522,10 +4453,14 @@ snapshots:
3522 4453 dependencies:
3523 4454 js-tokens: 4.0.0
3524 4455
4456 + lru-cache@11.5.3: {}
4457 +
3525 4458 lru-cache@5.1.1:
3526 4459 dependencies:
3527 4460 yallist: 3.1.1
3528 4461
4462 + lz-string@1.5.0: {}
4463 +
3529 4464 magic-string@1.4.5:
3530 4465 dependencies:
3531 4466 '@jridgewell/sourcemap-codec': 1.6.0
@@ -3539,6 +4474,8 @@ snapshots:
3539 4474 braces: 3.0.3
3540 4475 picomatch: 2.3.2
3541 4476
4477 + min-indent@1.0.1: {}
4478 +
3542 4479 minimatch@10.2.6:
3543 4480 dependencies:
3544 4481 brace-expansion: 5.0.12
@@ -3557,7 +4494,7 @@ snapshots:
3557 4494
3558 4495 natural-compare@1.4.0: {}
3559 4496
3560 − next@16.4.5(@babel/core@7.29.13)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
4497 + next@16.4.5(@babel/core@7.29.13)(@playwright/test@1.65.0)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
3561 4498 dependencies:
3562 4499 '@next/env': 16.4.5
3563 4500 '@swc/helpers': 0.5.23
@@ -3576,6 +4513,7 @@ snapshots:
3576 4513 '@next/swc-linux-x64-musl': 16.4.5
3577 4514 '@next/swc-win32-arm64-msvc': 16.4.5
3578 4515 '@next/swc-win32-x64-msvc': 16.4.5
4516 + '@playwright/test': 1.65.0
3579 4517 sharp: 0.35.5(@types/node@20.19.43)
3580 4518 transitivePeerDependencies:
3581 4519 - '@babel/core'
@@ -3591,6 +4529,8 @@ snapshots:
3591 4529
3592 4530 node-releases@2.0.63: {}
3593 4531
4532 + nodemailer@10.3.1: {}
4533 +
3594 4534 object-assign@4.1.1: {}
3595 4535
3596 4536 object-inspect@1.13.4: {}
@@ -3633,6 +4573,8 @@ snapshots:
3633 4573 define-properties: 1.2.1
3634 4574 es-object-atoms: 1.1.2
3635 4575
4576 + obug@3.0.0: {}
4577 +
3636 4578 optionator@0.9.4:
3637 4579 dependencies:
3638 4580 deep-is: 0.1.4
@@ -3661,6 +4603,14 @@ snapshots:
3661 4603 dependencies:
3662 4604 callsites: 3.1.0
3663 4605
4606 + parse5-parser-stream@8.1.0:
4607 + dependencies:
4608 + parse5: 8.1.0
4609 +
4610 + parse5@8.1.0:
4611 + dependencies:
4612 + entities: 9.0.0
4613 +
3664 4614 path-exists@4.0.0: {}
3665 4615
3666 4616 path-key@3.1.1: {}
@@ -3673,6 +4623,12 @@ snapshots:
3673 4623
3674 4624 picomatch@4.0.7: {}
3675 4625
4626 + playwright-core@1.65.0: {}
4627 +
4628 + playwright@1.65.0:
4629 + dependencies:
4630 + playwright-core: 1.65.0
4631 +
3676 4632 possible-typed-array-names@1.1.0: {}
3677 4633
3678 4634 postcss@8.5.23:
@@ -3681,8 +4637,26 @@ snapshots:
3681 4637 picocolors: 1.1.1
3682 4638 source-map-js: 1.2.2
3683 4639
4640 + postcss@8.5.32:
4641 + dependencies:
4642 + nanoid: 3.3.20
4643 + picocolors: 1.1.1
4644 + source-map-js: 1.2.2
4645 +
3684 4646 prelude-ls@1.2.1: {}
3685 4647
4648 + prettier-plugin-tailwindcss@0.8.1(prettier@3.9.10):
4649 + dependencies:
4650 + prettier: 3.9.10
4651 +
4652 + prettier@3.9.10: {}
4653 +
4654 + pretty-format@27.5.1:
4655 + dependencies:
4656 + ansi-regex: 5.0.1
4657 + ansi-styles: 5.2.0
4658 + react-is: 17.0.2
4659 +
3686 4660 prop-types@15.8.1:
3687 4661 dependencies:
3688 4662 loose-envify: 1.4.0
@@ -3698,10 +4672,23 @@ snapshots:
3698 4672 react: 19.3.0
3699 4673 scheduler: 0.28.0
3700 4674
4675 + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.0):
4676 + dependencies:
4677 + react: 19.3.0
4678 + optionalDependencies:
4679 + '@types/react': 19.3.0
4680 +
3701 4681 react-is@16.13.1: {}
3702 4682
4683 + react-is@17.0.2: {}
4684 +
3703 4685 react@19.3.0: {}
3704 4686
4687 + redent@3.0.0:
4688 + dependencies:
4689 + indent-string: 4.0.0
4690 + strip-indent: 3.0.0
4691 +
3705 4692 reflect.getprototypeof@1.0.10:
3706 4693 dependencies:
3707 4694 call-bind: 1.0.9
@@ -3737,6 +4724,27 @@ snapshots:
3737 4724
3738 4725 reusify@1.1.0: {}
3739 4726
4727 + rolldown@1.2.16:
4728 + dependencies:
4729 + '@oxc-project/types': 0.156.0
4730 + '@rolldown/pluginutils': 1.0.1
4731 + optionalDependencies:
4732 + '@rolldown/binding-android-arm-eabi': 1.2.16
4733 + '@rolldown/binding-android-arm64': 1.2.16
4734 + '@rolldown/binding-darwin-arm64': 1.2.16
4735 + '@rolldown/binding-darwin-x64': 1.2.16
4736 + '@rolldown/binding-freebsd-x64': 1.2.16
4737 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16
4738 + '@rolldown/binding-linux-arm64-gnu': 1.2.16
4739 + '@rolldown/binding-linux-arm64-musl': 1.2.16
4740 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16
4741 + '@rolldown/binding-linux-s390x-gnu': 1.2.16
4742 + '@rolldown/binding-linux-x64-gnu': 1.2.16
4743 + '@rolldown/binding-linux-x64-musl': 1.2.16
4744 + '@rolldown/binding-openharmony-arm64': 1.2.16
4745 + '@rolldown/binding-win32-arm64-msvc': 1.2.16
4746 + '@rolldown/binding-win32-x64-msvc': 1.2.16
4747 +
3740 4748 run-parallel@1.2.0:
3741 4749 dependencies:
3742 4750 queue-microtask: 1.2.3
@@ -3760,6 +4768,10 @@ snapshots:
3760 4768 es-errors: 1.3.0
3761 4769 is-regex: 1.2.1
3762 4770
4771 + saxes@6.0.0:
4772 + dependencies:
4773 + xmlchars: 2.2.0
4774 +
3763 4775 scheduler@0.28.0: {}
3764 4776
3765 4777 semver@6.3.2: {}
@@ -3820,7 +4832,6 @@ snapshots:
3820 4832 '@img/sharp-win32-ia32': 0.35.5
3821 4833 '@img/sharp-win32-x64': 0.35.5
3822 4834 '@types/node': 20.19.43
3823 − optional: true
3824 4835
3825 4836 shebang-command@2.0.0:
3826 4837 dependencies:
@@ -3860,6 +4871,8 @@ snapshots:
3860 4871
3861 4872 stable-hash@0.0.5: {}
3862 4873
4874 + std-env@4.3.0: {}
4875 +
3863 4876 stop-iteration-iterator@1.1.0:
3864 4877 dependencies:
3865 4878 es-errors: 1.3.0
@@ -3918,6 +4931,10 @@ snapshots:
3918 4931
3919 4932 strip-bom@3.0.0: {}
3920 4933
4934 + strip-indent@3.0.0:
4935 + dependencies:
4936 + min-indent: 1.0.1
4937 +
3921 4938 strip-json-comments@3.1.1: {}
3922 4939
3923 4940 styled-jsx@5.1.6(@babel/core@7.29.13)(react@19.3.0):
@@ -3937,15 +4954,33 @@ snapshots:
3937 4954
3938 4955 tapable@2.3.3: {}
3939 4956
4957 + tinybench@6.2.3: {}
4958 +
4959 + tinyexec@1.3.1: {}
4960 +
3940 4961 tinyglobby@0.2.17:
3941 4962 dependencies:
3942 4963 fdir: 6.5.0(picomatch@4.0.7)
3943 4964 picomatch: 4.0.7
3944 4965
4966 + tldts-core@7.4.20: {}
4967 +
4968 + tldts@7.4.19:
4969 + dependencies:
4970 + tldts-core: 7.4.20
4971 +
3945 4972 to-regex-range@5.0.1:
3946 4973 dependencies:
3947 4974 is-number: 7.0.0
3948 4975
4976 + tough-cookie@6.1.0:
4977 + dependencies:
4978 + tldts: 7.4.19
4979 +
4980 + tr46@7.0.0:
4981 + dependencies:
4982 + punycode: 2.3.1
4983 +
3949 4984 ts-api-utils@2.5.0(typescript@5.9.3):
3950 4985 dependencies:
3951 4986 typescript: 5.9.3
@@ -4017,6 +5052,8 @@ snapshots:
4017 5052
4018 5053 undici-types@6.21.0: {}
4019 5054
5055 + undici@8.12.4: {}
5056 +
4020 5057 unrs-resolver@1.13.0:
4021 5058 dependencies:
4022 5059 napi-postinstall: 0.3.8
@@ -4053,6 +5090,56 @@ snapshots:
4053 5090 dependencies:
4054 5091 punycode: 2.3.1
4055 5092
5093 + vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0):
5094 + dependencies:
5095 + lightningcss: 1.33.0
5096 + picomatch: 4.0.7
5097 + postcss: 8.5.32
5098 + rolldown: 1.2.16
5099 + tinyglobby: 0.2.17
5100 + optionalDependencies:
5101 + '@types/node': 20.19.43
5102 + fsevents: 2.3.3
5103 + jiti: 2.7.0
5104 +
5105 + vitest@5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)):
5106 + dependencies:
5107 + '@types/chai': 5.2.3
5108 + '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))
5109 + chai: 6.3.0
5110 + es-module-lexer: 2.3.2
5111 + expect-type: 1.4.0
5112 + magic-string: 1.4.5
5113 + obug: 3.0.0
5114 + picomatch: 4.0.7
5115 + std-env: 4.3.0
5116 + tinybench: 6.2.3
5117 + tinyexec: 1.3.1
5118 + tinyglobby: 0.2.17
5119 + vite: 8.3.6(@types/node@20.19.43)(jiti@2.7.0)
5120 + why-is-node-running: 3.2.1
5121 + optionalDependencies:
5122 + '@types/node': 20.19.43
5123 + jsdom: 31.0.0
5124 + transitivePeerDependencies:
5125 + - msw
5126 +
5127 + w3c-xmlserializer@6.0.0:
5128 + dependencies:
5129 + xml-name-validator: 5.0.0
5130 +
5131 + webidl-conversions@8.0.1: {}
5132 +
5133 + whatwg-mimetype@5.0.0: {}
5134 +
5135 + whatwg-url@17.3.0:
5136 + dependencies:
5137 + '@exodus/bytes': 1.16.0
5138 + tr46: 7.0.0
5139 + webidl-conversions: 8.0.1
5140 + transitivePeerDependencies:
5141 + - '@noble/hashes'
5142 +
4056 5143 which-boxed-primitive@1.1.1:
4057 5144 dependencies:
4058 5145 is-bigint: 1.1.0
@@ -4098,8 +5185,14 @@ snapshots:
4098 5185 dependencies:
4099 5186 isexe: 2.0.0
4100 5187
5188 + why-is-node-running@3.2.1: {}
5189 +
4101 5190 word-wrap@1.2.5: {}
4102 5191
5192 + xml-name-validator@5.0.0: {}
5193 +
5194 + xmlchars@2.2.0: {}
5195 +
4103 5196 yallist@3.1.1: {}
4104 5197
4105 5198 yocto-queue@0.1.0: {}
deleted public/file.svg +0 −1
@@ -1 +0,0 @@
1 −<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
deleted public/globe.svg +0 −1
@@ -1 +0,0 @@
1 −<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
added public/icon-192.png +0 −0

Binary file not shown.

added public/icon-512.png +0 −0

Binary file not shown.

added public/logo-mark.svg +4 −0
@@ -0,0 +1,4 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25.2 -6.2 36.4 23.4">
2 +
3 +<g fill="none" stroke="#c7a075" stroke-width="0.22" stroke-linecap="round"><rect x="0" y="0" width="1" height="1" stroke-width="0.14" opacity="0.8"/><rect x="1" y="0" width="1" height="1" stroke-width="0.14" opacity="0.8"/><rect x="0" y="1" width="2" height="2" stroke-width="0.14" opacity="0.8"/><rect x="-3" y="0" width="3" height="3" stroke-width="0.14" opacity="0.8"/><rect x="-3" y="-5" width="5" height="5" stroke-width="0.14" opacity="0.8"/><rect x="2" y="-5" width="8" height="8" stroke-width="0.14" opacity="0.8"/><rect x="-3" y="3" width="13" height="13" stroke-width="0.14" opacity="0.8"/><rect x="-24" y="-5" width="21" height="21" stroke-width="0.14" opacity="0.8"/><path d="M 0 1 A 1 1 0 0 0 1 0 A 1 1 0 0 1 2 1 A 2 2 0 0 1 0 3 A 3 3 0 0 1 -3 0 A 5 5 0 0 1 2 -5 A 8 8 0 0 1 10 3 A 13 13 0 0 1 -3 16 A 21 21 0 0 1 -24 -5" stroke="#c7a075"/></g>
4 +</svg>
\ No newline at end of file
deleted public/next.svg +0 −1
@@ -1 +0,0 @@
1 −<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
deleted public/vercel.svg +0 −1
@@ -1 +0,0 @@
1 −<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
deleted public/window.svg +0 −1
@@ -1 +0,0 @@
1 −<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
added references/phi-conseil-homepage.jpeg +0 −0

Binary file not shown.

added scripts/generate-icons.mts +58 −0
@@ -0,0 +1,58 @@
1 +/**
2 + * Génère le favicon et les icônes à partir du logo (spirale dorée), jamais
3 + * d'icône générique. Usage : pnpm icons
4 + */
5 +import { writeFileSync, mkdirSync } from "node:fs";
6 +import sharp from "sharp";
7 +import { buildSpiral } from "../src/lib/spiral.ts";
8 +
9 +const { squares, path, bbox } = buildSpiral(8);
10 +const pad = 1.2;
11 +const vb = `${bbox.x - pad} ${bbox.y - pad} ${bbox.w + pad * 2} ${bbox.h + pad * 2}`;
12 +const gold = "#c7a075";
13 +const ivory = "#f5eee4";
14 +
15 +function svg({ background, size }: { background: boolean; size?: number }) {
16 + const rects = squares
17 + .map(
18 + (s) =>
19 + `<rect x="${s.x}" y="${s.y}" width="${s.s}" height="${s.s}" stroke-width="${background ? 0.32 : 0.14}" opacity="0.8"/>`,
20 + )
21 + .join("");
22 + const dim = size ? ` width="${size}" height="${size}"` : "";
23 + // Le rectangle d'or est 34×21 : on le centre dans un carré pour l'icône.
24 + const side = Math.max(bbox.w, bbox.h) + pad * 2;
25 + const sq = `${bbox.x + bbox.w / 2 - side / 2} ${bbox.y + bbox.h / 2 - side / 2} ${side} ${side}`;
26 + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${background ? sq : vb}"${dim}>
27 +${background ? `<rect x="${bbox.x + bbox.w / 2 - side / 2}" y="${bbox.y + bbox.h / 2 - side / 2}" width="${side}" height="${side}" rx="${side * 0.2}" fill="${ivory}"/>` : ""}
28 +<g fill="none" stroke="${gold}" stroke-width="${background ? 0.55 : 0.22}" stroke-linecap="round">${rects}<path d="${path}" stroke="${background ? "#b48a5a" : gold}"/></g>
29 +</svg>`;
30 +}
31 +
32 +mkdirSync("src/app", { recursive: true });
33 +mkdirSync("public", { recursive: true });
34 +writeFileSync("src/app/icon.svg", svg({ background: true }));
35 +writeFileSync("public/logo-mark.svg", svg({ background: false }));
36 +
37 +const png = (s: number) => sharp(Buffer.from(svg({ background: true, size: s }))).png();
38 +await png(180).toFile("src/app/apple-icon.png");
39 +await png(512).toFile("public/icon-512.png");
40 +await png(192).toFile("public/icon-192.png");
41 +// favicon.ico : PNG 48 px encapsulé (format accepté par les navigateurs modernes)
42 +const p48 = await png(48).toBuffer();
43 +const ico = Buffer.alloc(6 + 16);
44 +ico.writeUInt16LE(0, 0);
45 +ico.writeUInt16LE(1, 2);
46 +ico.writeUInt16LE(1, 4);
47 +ico.writeUInt8(48, 6);
48 +ico.writeUInt8(48, 7);
49 +ico.writeUInt8(0, 8);
50 +ico.writeUInt8(0, 9);
51 +ico.writeUInt16LE(1, 10);
52 +ico.writeUInt16LE(32, 12);
53 +ico.writeUInt32LE(p48.length, 14);
54 +ico.writeUInt32LE(22, 18);
55 +writeFileSync("src/app/favicon.ico", Buffer.concat([ico, p48]));
56 +console.log(
57 + "Icônes générées : src/app/icon.svg, favicon.ico, apple-icon.png, public/icon-*.png, public/logo-mark.svg",
58 +);
added scripts/overflow.mjs +18 −0
@@ -0,0 +1,18 @@
1 +import { chromium } from "@playwright/test";
2 +const b = await chromium.launch();
3 +const p = await b.newPage({ viewport: { width: 360, height: 800 } });
4 +await p.goto(process.argv[2] ?? "http://127.0.0.1:3100/", { waitUntil: "networkidle" });
5 +const r = await p.evaluate(() => {
6 + const w = document.documentElement.clientWidth;
7 + const out = [];
8 + for (const el of document.querySelectorAll("body *")) {
9 + const r = el.getBoundingClientRect();
10 + if (r.right > w + 1 || r.left < -1)
11 + out.push(
12 + `${el.tagName.toLowerCase()}.${[...el.classList].slice(0, 4).join(".")} left=${Math.round(r.left)} right=${Math.round(r.right)}`,
13 + );
14 + }
15 + return { scrollWidth: document.documentElement.scrollWidth, w, out: out.slice(0, 25) };
16 +});
17 +console.log(JSON.stringify(r, null, 1));
18 +await b.close();
added scripts/shot.mjs +9 −0
@@ -0,0 +1,9 @@
1 +import { chromium } from "@playwright/test";
2 +const [, , url, out, w, h, full] = process.argv;
3 +const b = await chromium.launch();
4 +const p = await b.newPage({ viewport: { width: +w, height: +(h || 900) } });
5 +await p.goto(url, { waitUntil: "networkidle" });
6 +await p.waitForTimeout(300);
7 +await p.screenshot({ path: out, fullPage: full !== "0" });
8 +await b.close();
9 +console.log(out);
added src/app/(site)/a-propos/page.tsx +65 −0
@@ -0,0 +1,65 @@
1 +import type { Metadata } from "next";
2 +import { home, pages } from "@/lib/content";
3 +import { pageMetadata } from "@/lib/seo";
4 +import { CheckBadge } from "@/components/ui/Icons";
5 +import { Button } from "@/components/ui/Button";
6 +import { VaseScene } from "@/components/graphics/Placeholders";
7 +import Image from "next/image";
8 +
9 +export const metadata: Metadata = pageMetadata({
10 + title: pages.about.title,
11 + description: pages.about.seoDescription,
12 + path: "/a-propos",
13 +});
14 +
15 +export default function AboutPage() {
16 + const { about } = home;
17 + return (
18 + <>
19 + <section className="section-y bg-ivory-100">
20 + <div className="container-site">
21 + <p className="overtitle">{about.overtitle}</p>
22 + <h1 className="text-h2 mt-3 max-w-[40rem]">{pages.about.intro}</h1>
23 + </div>
24 + </section>
25 + <section className="section-y bg-ivory-50">
26 + <div className="container-site grid items-start gap-10 lg:grid-cols-[minmax(0,0.4fr)_minmax(0,0.6fr)]">
27 + <div className="relative aspect-[4/5] overflow-hidden rounded-[var(--radius-xl)]">
28 + {about.image.src ? (
29 + <Image
30 + src={about.image.src}
31 + alt={about.image.alt}
32 + fill
33 + sizes="(min-width:1024px) 40vw, 100vw"
34 + className="object-cover"
35 + />
36 + ) : (
37 + <VaseScene className="absolute inset-0 h-full w-full" />
38 + )}
39 + </div>
40 + <div>
41 + {pages.about.body.map((p, i) => (
42 + <p key={i} className="text-lead text-ink-700 mt-0 mb-5">
43 + {p}
44 + </p>
45 + ))}
46 + <h2 className="text-h4 mt-8">Nos engagements</h2>
47 + <ul className="mt-4 flex flex-col gap-3">
48 + {about.benefits.map((b) => (
49 + <li key={b} className="text-ink-900 flex items-start gap-3">
50 + <CheckBadge className="mt-0.5 h-5 w-5 shrink-0" />
51 + <span>{b}</span>
52 + </li>
53 + ))}
54 + </ul>
55 + <div className="mt-8">
56 + <Button href="/nous-joindre" size="lg">
57 + Nous joindre
58 + </Button>
59 + </div>
60 + </div>
61 + </div>
62 + </section>
63 + </>
64 + );
65 +}
added src/app/(site)/confidentialite/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +import type { Metadata } from "next";
2 +import { pages, site } from "@/lib/content";
3 +import { pageMetadata } from "@/lib/seo";
4 +
5 +export const metadata: Metadata = pageMetadata({
6 + title: pages.privacy.title,
7 + description: pages.privacy.seoDescription,
8 + path: "/confidentialite",
9 +});
10 +
11 +const dateFr = new Intl.DateTimeFormat("fr-CA", { dateStyle: "long", timeZone: "UTC" });
12 +
13 +export default function PrivacyPage() {
14 + const p = pages.privacy;
15 + const email = site.contact.email;
16 + return (
17 + <>
18 + <section className="section-y bg-ivory-100">
19 + <div className="container-site">
20 + <h1 className="text-h2 max-w-[44rem]">{p.title}</h1>
21 + <p className="text-ink-500 mt-4 text-sm">
22 + Dernière mise à jour : {dateFr.format(new Date(`${p.lastUpdated}T00:00:00Z`))}
23 + </p>
24 + {!p.reviewed ? (
25 + <p
26 + role="note"
27 + className="border-gold-500/60 bg-gold-300/30 text-ink-900 mt-4 max-w-[44rem] rounded-md border px-4 py-3 text-sm"
28 + >
29 + Gabarit en attente de validation juridique. Ce texte décrit les pratiques réelles du
30 + site mais doit être revu par le client ou son conseiller avant publication définitive.
31 + </p>
32 + ) : null}
33 + </div>
34 + </section>
35 + <section className="section-y bg-ivory-50">
36 + <div className="container-site prose-site max-w-[44rem]">
37 + <h2 className="text-h4">1. Renseignements recueillis</h2>
38 + <p className="mt-3">
39 + Le site ne recueille que les renseignements que vous saisissez volontairement dans le
40 + formulaire « Nous joindre » : nom, adresse courriel, organisation (facultatif), sujet et
41 + message. Aucun témoin (cookie) de suivi publicitaire ni outil d’analyse tiers n’est
42 + utilisé.
43 + </p>
44 + <h2 className="text-h4 mt-10">2. Utilisation</h2>
45 + <p className="mt-3">
46 + Ces renseignements servent uniquement à répondre à votre demande. Ils sont transmis par
47 + courriel à {site.brand.legalName} et ne sont ni vendus ni partagés avec des tiers, sauf
48 + obligation légale.
49 + </p>
50 + <h2 className="text-h4 mt-10">3. Conservation</h2>
51 + <p className="mt-3">
52 + Les messages reçus sont conservés le temps nécessaire au traitement de la demande et au
53 + suivi de la relation d’affaires, puis supprimés. Le site lui-même ne stocke pas les
54 + messages : seules des traces techniques anonymisées (empreinte d’adresse IP, horodatage)
55 + sont journalisées pour prévenir les abus.
56 + </p>
57 + <h2 className="text-h4 mt-10">4. Vos droits</h2>
58 + <p className="mt-3">
59 + Conformément à la Loi sur la protection des renseignements personnels dans le secteur
60 + privé (Québec), vous pouvez demander l’accès à vos renseignements, leur rectification ou
61 + leur suppression
62 + {email ? (
63 + <>
64 + {" "}
65 + en écrivant à{" "}
66 + <a className="underline underline-offset-4" href={`mailto:${email}`}>
67 + {email}
68 + </a>
69 + </>
70 + ) : (
71 + " par l’entremise du formulaire de contact"
72 + )}
73 + .
74 + </p>
75 + <h2 className="text-h4 mt-10">5. Sécurité</h2>
76 + <p className="mt-3">
77 + Le site est servi exclusivement en HTTPS. Les données du formulaire sont validées et
78 + transmises de façon sécurisée au fournisseur de messagerie configuré par{" "}
79 + {site.brand.legalName}.
80 + </p>
81 + <h2 id="mentions-legales" className="text-h4 mt-12 scroll-mt-28">
82 + Mentions légales
83 + </h2>
84 + <p className="mt-3">
85 + Éditeur du site : {site.brand.legalName}
86 + {site.contact.address ? `, ${site.contact.address}` : ""}.
87 + {email ? ` Contact : ${email}.` : ""}
88 + </p>
89 + <p className="mt-3">
90 + Les textes, le logo et les éléments graphiques de ce site sont la propriété de{" "}
91 + {site.brand.legalName} ou de leurs auteurs respectifs et ne peuvent être reproduits sans
92 + autorisation. Les crédits des photographies et polices figurent dans le registre des
93 + actifs du site.
94 + </p>
95 + </div>
96 + </section>
97 + </>
98 + );
99 +}
added src/app/(site)/nous-joindre/page.tsx +77 −0
@@ -0,0 +1,77 @@
1 +import type { Metadata } from "next";
2 +import { pages, site } from "@/lib/content";
3 +import { pageMetadata } from "@/lib/seo";
4 +import { ContactForm } from "@/components/forms/ContactForm";
5 +import { LinkedInIcon, MailIcon } from "@/components/ui/Icons";
6 +
7 +export const metadata: Metadata = pageMetadata({
8 + title: pages.contact.title,
9 + description: pages.contact.seoDescription,
10 + path: "/nous-joindre",
11 +});
12 +
13 +export default function ContactPage() {
14 + const c = pages.contact;
15 + const hasCoords =
16 + site.contact.email || site.contact.phone || site.contact.address || site.social.linkedin;
17 + return (
18 + <>
19 + <section className="section-y bg-ivory-100">
20 + <div className="container-site">
21 + <h1 className="text-h2">{c.title}</h1>
22 + <p className="text-lead text-ink-700 mt-4 max-w-[40rem]">{c.intro}</p>
23 + </div>
24 + </section>
25 + <section className="section-y bg-ivory-50">
26 + <div className="container-site grid gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.5fr)]">
27 + <ContactForm
28 + subjects={c.subjects}
29 + consentLabel={c.consentLabel}
30 + successTitle={c.successTitle}
31 + successMessage={c.successMessage}
32 + />
33 + <aside aria-label="Coordonnées" className="bg-pale-200/60 rounded-[var(--radius-lg)] p-7">
34 + <h2 className="text-h4">Coordonnées</h2>
35 + {hasCoords ? (
36 + <ul className="text-ink-900 mt-4 flex flex-col gap-3">
37 + {site.contact.email ? (
38 + <li className="flex items-center gap-3">
39 + <MailIcon className="text-navy-800 h-5 w-5" />
40 + <a
41 + className="underline-offset-4 hover:underline"
42 + href={`mailto:${site.contact.email}`}
43 + >
44 + {site.contact.email}
45 + </a>
46 + </li>
47 + ) : null}
48 + {site.contact.phone ? <li>{site.contact.phone}</li> : null}
49 + {site.contact.address ? (
50 + <li className="whitespace-pre-line">{site.contact.address}</li>
51 + ) : null}
52 + {site.social.linkedin ? (
53 + <li className="flex items-center gap-3">
54 + <LinkedInIcon className="text-navy-800 h-5 w-5" />
55 + <a
56 + className="underline-offset-4 hover:underline"
57 + href={site.social.linkedin}
58 + target="_blank"
59 + rel="noopener noreferrer"
60 + >
61 + LinkedIn (nouvelle fenêtre)
62 + </a>
63 + </li>
64 + ) : null}
65 + </ul>
66 + ) : (
67 + <p className="text-ink-700 mt-4">
68 + Le formulaire ci-contre est le moyen privilégié pour nous joindre.
69 + {site.contact.region ? ` Nous servons la clientèle de ${site.contact.region}.` : ""}
70 + </p>
71 + )}
72 + </aside>
73 + </div>
74 + </section>
75 + </>
76 + );
77 +}
added src/app/(site)/page.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import { Hero } from "@/components/sections/Hero";
2 +import { Services } from "@/components/sections/Services";
3 +import { About } from "@/components/sections/About";
4 +import { Testimonials } from "@/components/sections/Testimonials";
5 +import { CallToAction } from "@/components/sections/CallToAction";
6 +import { organizationJsonLd } from "@/lib/seo";
7 +
8 +export default function HomePage() {
9 + return (
10 + <>
11 + <script
12 + type="application/ld+json"
13 + dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd()) }}
14 + />
15 + <Hero />
16 + <Services />
17 + <About />
18 + <Testimonials />
19 + <CallToAction />
20 + </>
21 + );
22 +}
added src/app/(site)/services/page.tsx +52 −0
@@ -0,0 +1,52 @@
1 +import type { Metadata } from "next";
2 +import { home, pages } from "@/lib/content";
3 +import { pageMetadata } from "@/lib/seo";
4 +import { SectionTitle } from "@/components/ui/SectionTitle";
5 +import { ServiceIcon } from "@/components/ui/ServiceIcon";
6 +import { Button } from "@/components/ui/Button";
7 +
8 +export const metadata: Metadata = pageMetadata({
9 + title: pages.services.title,
10 + description: pages.services.seoDescription,
11 + path: "/services",
12 +});
13 +
14 +export default function ServicesPage() {
15 + return (
16 + <>
17 + <section className="section-y bg-ivory-100">
18 + <div className="container-site">
19 + <SectionTitle as="h1" id="services-title">
20 + {pages.services.title}
21 + </SectionTitle>
22 + <p className="text-lead text-ink-700 mx-auto mt-6 max-w-[42rem] text-center">
23 + {pages.services.intro}
24 + </p>
25 + </div>
26 + </section>
27 + <section className="section-y bg-ivory-50" aria-label="Détail des services">
28 + <div className="container-site grid gap-8 md:grid-cols-2">
29 + {home.services.items.map((s) => (
30 + <article
31 + key={s.slug}
32 + id={s.slug}
33 + className="border-pale-300/70 scroll-mt-28 rounded-[var(--radius-lg)] border bg-white/60 p-7 md:p-9"
34 + >
35 + <span className="bg-pale-200 text-navy-800 flex h-14 w-14 items-center justify-center rounded-full">
36 + <ServiceIcon name={s.icon} className="h-7 w-7" />
37 + </span>
38 + <h2 className="text-h4 mt-5">{s.title}</h2>
39 + <p className="text-ink-900 mt-3">{s.summary}</p>
40 + {s.description ? <p className="text-ink-700 mt-3">{s.description}</p> : null}
41 + </article>
42 + ))}
43 + </div>
44 + <div className="container-site mt-12 text-center">
45 + <Button href="/nous-joindre" size="lg">
46 + Discuter de votre projet
47 + </Button>
48 + </div>
49 + </section>
50 + </>
51 + );
52 +}
added src/app/(site)/temoignages/page.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import type { Metadata } from "next";
2 +import { pages, displayableTestimonials } from "@/lib/content";
3 +import { pageMetadata } from "@/lib/seo";
4 +import { TestimonialSlider } from "@/components/sections/TestimonialSlider";
5 +import { Button } from "@/components/ui/Button";
6 +
7 +export const metadata: Metadata = pageMetadata({
8 + title: pages.testimonials.title,
9 + description: pages.testimonials.seoDescription,
10 + path: "/temoignages",
11 +});
12 +
13 +export default function TestimonialsPage() {
14 + const preview = process.env.NEXT_PUBLIC_CONTENT_PREVIEW === "1";
15 + const { items, isPreview } = displayableTestimonials(preview);
16 + return (
17 + <>
18 + <section className="section-y bg-ivory-100">
19 + <div className="container-site">
20 + <h1 className="text-h2">{pages.testimonials.title}</h1>
21 + <p className="text-lead text-ink-700 mt-4 max-w-[40rem]">{pages.testimonials.intro}</p>
22 + </div>
23 + </section>
24 + <section className="section-y bg-ivory-50">
25 + <div className="container-site">
26 + {items.length > 0 ? (
27 + <>
28 + {isPreview ? (
29 + <p
30 + role="status"
31 + className="border-gold-500/60 bg-gold-300/30 text-ink-900 mb-4 rounded-md border px-4 py-2 text-sm"
32 + >
33 + Prévisualisation : témoignage de maquette non vérifié.
34 + </p>
35 + ) : null}
36 + <TestimonialSlider items={items} />
37 + </>
38 + ) : (
39 + <div className="bg-pale-200 rounded-[var(--radius-xl)] px-8 py-12 text-center">
40 + <p className="text-navy-900 font-serif text-xl">{pages.testimonials.emptyMessage}</p>
41 + <div className="mt-6">
42 + <Button href="/nous-joindre">Parlons de votre projet</Button>
43 + </div>
44 + </div>
45 + )}
46 + </div>
47 + </section>
48 + </>
49 + );
50 +}
added src/app/api/contact/route.ts +96 −0
@@ -0,0 +1,96 @@
1 +import { NextResponse } from "next/server";
2 +import { contactSchema } from "@/lib/validation";
3 +import { getEnv } from "@/lib/env";
4 +import { getMailer } from "@/lib/mail";
5 +import { clientIp, createDedupe, createMemoryRateLimiter, fingerprint } from "@/lib/security";
6 +
7 +const MAX_BODY_BYTES = 32 * 1024;
8 +const MIN_FILL_MS = 2500;
9 +
10 +let limiter: ReturnType<typeof createMemoryRateLimiter> | null = null;
11 +const dedupe = createDedupe(600);
12 +
13 +function getLimiter() {
14 + if (!limiter) {
15 + const env = getEnv();
16 + limiter = createMemoryRateLimiter(env.CONTACT_RATE_LIMIT, env.CONTACT_RATE_WINDOW_S);
17 + }
18 + return limiter;
19 +}
20 +
21 +export async function POST(request: Request) {
22 + const ip = clientIp(request.headers);
23 + const ipHash = fingerprint(ip);
24 +
25 + const len = Number(request.headers.get("content-length") ?? "0");
26 + if (len > MAX_BODY_BYTES) {
27 + return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 });
28 + }
29 + if (!(request.headers.get("content-type") ?? "").includes("application/json")) {
30 + return NextResponse.json({ ok: false, error: "Format non pris en charge." }, { status: 415 });
31 + }
32 +
33 + const rl = getLimiter().check(ipHash);
34 + if (!rl.allowed) {
35 + return NextResponse.json(
36 + { ok: false, error: "Trop de tentatives." },
37 + { status: 429, headers: { "Retry-After": String(rl.retryAfterS) } },
38 + );
39 + }
40 +
41 + let raw: unknown;
42 + try {
43 + const text = await request.text();
44 + if (text.length > MAX_BODY_BYTES) {
45 + return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 });
46 + }
47 + raw = JSON.parse(text);
48 + } catch {
49 + return NextResponse.json({ ok: false, error: "Requête invalide." }, { status: 400 });
50 + }
51 +
52 + const parsed = contactSchema.safeParse(raw);
53 + if (!parsed.success) {
54 + // Honeypot rempli → réponse « succès » silencieuse pour ne pas renseigner les robots.
55 + const honeypot = parsed.error.issues.some((i) => i.path[0] === "website");
56 + if (honeypot) {
57 + console.info(`[contact] honeypot déclenché ip=${ipHash}`);
58 + return NextResponse.json({ ok: true, delivered: false });
59 + }
60 + const fields = Object.fromEntries(
61 + parsed.error.issues.map((i) => [i.path.join("."), i.message]),
62 + );
63 + return NextResponse.json(
64 + { ok: false, error: "Certains champs sont invalides.", fields },
65 + { status: 400 },
66 + );
67 + }
68 +
69 + const data = parsed.data;
70 + if (data.startedAt !== undefined && Date.now() - data.startedAt < MIN_FILL_MS) {
71 + console.info(`[contact] soumission trop rapide ip=${ipHash}`);
72 + return NextResponse.json({ ok: true, delivered: false });
73 + }
74 +
75 + const key = fingerprint(`${data.email}|${data.subject}|${data.message}`);
76 + if (dedupe.isDuplicate(key)) {
77 + return NextResponse.json({ ok: true, delivered: false, duplicate: true });
78 + }
79 +
80 + try {
81 + const result = await getMailer().send(data, { ipHash });
82 + console.info(
83 + `[contact] reçu ip=${ipHash} delivered=${result.delivered} id=${result.id ?? "-"}`,
84 + );
85 + return NextResponse.json({ ok: true, delivered: result.delivered });
86 + } catch (e) {
87 + console.error(
88 + `[contact] échec d'envoi ip=${ipHash}`,
89 + e instanceof Error ? e.message : "erreur",
90 + );
91 + return NextResponse.json(
92 + { ok: false, error: "L'envoi a échoué. Veuillez réessayer plus tard." },
93 + { status: 502 },
94 + );
95 + }
96 +}
added src/app/apple-icon.png +0 −0

Binary file not shown.

added src/app/error.tsx +35 −0
@@ -0,0 +1,35 @@
1 +"use client";
2 +
3 +import { useEffect } from "react";
4 +import { Button } from "@/components/ui/Button";
5 +
6 +export default function ErrorPage({
7 + error,
8 + reset,
9 +}: {
10 + error: Error & { digest?: string };
11 + reset: () => void;
12 +}) {
13 + useEffect(() => {
14 + // Journal côté client sans détail sensible ; le digest permet la corrélation serveur.
15 + console.error("Erreur de rendu", error.digest ?? "");
16 + }, [error]);
17 + return (
18 + <section className="section-y bg-ivory-100">
19 + <div className="container-site py-16 text-center">
20 + <h1 className="text-h2">Une erreur est survenue</h1>
21 + <p className="text-lead text-ink-700 mx-auto mt-4 max-w-[32rem]">
22 + Veuillez réessayer. Si le problème persiste, contactez-nous.
23 + </p>
24 + <div className="mt-8 flex justify-center gap-3">
25 + <Button onClick={reset} arrow={false}>
26 + Réessayer
27 + </Button>
28 + <Button href="/" variant="outline">
29 + Accueil
30 + </Button>
31 + </div>
32 + </div>
33 + </section>
34 + );
35 +}
modified src/app/favicon.ico +0 −0

Binary file not shown.

modified src/app/globals.css +153 −16
@@ -1,26 +1,163 @@
1 1 @import "tailwindcss";
2 +@import "../styles/tokens.css";
2 3
3 −:root {
4 − --background: #ffffff;
5 − --foreground: #171717;
4 +@theme inline {
5 + --color-ivory-50: var(--color-ivory-50);
6 + --color-ivory-100: var(--color-ivory-100);
7 + --color-ivory-200: var(--color-ivory-200);
8 + --color-ivory-300: var(--color-ivory-300);
9 + --color-pale-100: var(--color-pale-100);
10 + --color-pale-200: var(--color-pale-200);
11 + --color-pale-300: var(--color-pale-300);
12 + --color-pale-400: var(--color-pale-400);
13 + --color-slate-500: var(--color-slate-500);
14 + --color-slate-600: var(--color-slate-600);
15 + --color-slate-700: var(--color-slate-700);
16 + --color-navy-800: var(--color-navy-800);
17 + --color-navy-900: var(--color-navy-900);
18 + --color-gold-300: var(--color-gold-300);
19 + --color-gold-400: var(--color-gold-400);
20 + --color-gold-500: var(--color-gold-500);
21 + --color-gold-600: var(--color-gold-600);
22 + --color-gold-700: var(--color-gold-700);
23 + --color-ink-900: var(--color-ink-900);
24 + --color-ink-700: var(--color-ink-700);
25 + --color-ink-500: var(--color-ink-500);
26 + --color-ink-300: var(--color-ink-300);
27 + --color-danger: var(--color-danger);
28 + --color-success: var(--color-success);
29 +
30 + --font-serif: var(--font-serif);
31 + --font-sans: var(--font-sans);
32 +
33 + --radius-sm: var(--radius-sm);
34 + --radius-md: var(--radius-md);
35 + --radius-lg: var(--radius-lg);
36 + --radius-xl: var(--radius-xl);
37 + --radius-pill: var(--radius-pill);
38 +
39 + --shadow-sm: var(--shadow-sm);
40 + --shadow-md: var(--shadow-md);
41 + --shadow-lg: var(--shadow-lg);
42 +
43 + --ease-out: var(--ease-out);
6 44 }
7 45
8 −@theme inline {
9 − --color-background: var(--background);
10 − --color-foreground: var(--foreground);
11 − --font-sans: var(--font-geist-sans);
12 − --font-mono: var(--font-geist-mono);
46 +@layer base {
47 + html {
48 + scroll-behavior: smooth;
49 + -webkit-text-size-adjust: 100%;
50 + }
51 + @media (prefers-reduced-motion: reduce) {
52 + html {
53 + scroll-behavior: auto;
54 + }
55 + *,
56 + *::before,
57 + *::after {
58 + animation-duration: 0.01ms !important;
59 + animation-iteration-count: 1 !important;
60 + transition-duration: 0.01ms !important;
61 + }
62 + }
63 + body {
64 + background: var(--color-ivory-50);
65 + color: var(--color-ink-700);
66 + font-family: var(--font-sans);
67 + font-size: var(--text-base);
68 + line-height: var(--leading-normal);
69 + overflow-x: clip;
70 + }
71 + h1,
72 + h2,
73 + h3,
74 + h4 {
75 + font-family: var(--font-serif);
76 + color: var(--color-navy-900);
77 + font-weight: 500;
78 + letter-spacing: -0.01em;
79 + text-wrap: balance;
80 + }
81 + p {
82 + text-wrap: pretty;
83 + }
84 + :focus-visible {
85 + outline: 3px solid var(--color-slate-500);
86 + outline-offset: 3px;
87 + border-radius: var(--radius-sm);
88 + }
89 + ::selection {
90 + background: var(--color-gold-300);
91 + color: var(--color-navy-900);
92 + }
93 +}
94 +
95 +@layer components {
96 + .container-site {
97 + width: 100%;
98 + max-width: var(--container-max);
99 + margin-inline: auto;
100 + padding-inline: var(--container-pad);
101 + }
102 + .section-y {
103 + padding-block: var(--section-y);
104 + }
105 + .overtitle {
106 + font-family: var(--font-sans);
107 + font-size: var(--text-xs);
108 + font-weight: 600;
109 + letter-spacing: var(--tracking-caps);
110 + text-transform: uppercase;
111 + color: var(--color-gold-700);
112 + }
113 + .skip-link {
114 + position: absolute;
115 + left: 1rem;
116 + top: -100%;
117 + z-index: 100;
118 + padding: 0.5rem 1rem;
119 + background: var(--color-navy-900);
120 + color: white;
121 + border-radius: var(--radius-sm);
122 + }
123 + .skip-link:focus {
124 + top: 1rem;
125 + }
126 + .text-display {
127 + font-size: var(--text-display);
128 + line-height: var(--leading-tight);
129 + }
130 + .text-h2 {
131 + font-size: var(--text-4xl);
132 + line-height: var(--leading-snug);
133 + }
134 + .text-h3 {
135 + font-size: var(--text-3xl);
136 + line-height: var(--leading-snug);
137 + }
138 + .text-h4 {
139 + font-size: var(--text-xl);
140 + line-height: var(--leading-snug);
141 + }
142 + .text-lead {
143 + font-size: var(--text-lg);
144 + }
13 145 }
14 146
15 −@media (prefers-color-scheme: dark) {
16 − :root {
17 − --background: #0a0a0a;
18 − --foreground: #ededed;
147 +@layer components {
148 + .prose-site p {
149 + color: var(--color-ink-700);
150 + }
151 + .prose-site a {
152 + color: var(--color-navy-900);
19 153 }
20 154 }
21 155
22 −body {
23 − background: var(--background);
24 − color: var(--foreground);
25 − font-family: Arial, Helvetica, sans-serif;
156 +@keyframes fade-in {
157 + from {
158 + opacity: 0;
159 + }
160 + to {
161 + opacity: 1;
162 + }
26 163 }
added src/app/icon.svg +4 −0
@@ -0,0 +1,4 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25.2 -12.7 36.4 36.4">
2 +<rect x="-25.2" y="-12.7" width="36.4" height="36.4" rx="7.28" fill="#f5eee4"/>
3 +<g fill="none" stroke="#c7a075" stroke-width="0.55" stroke-linecap="round"><rect x="0" y="0" width="1" height="1" stroke-width="0.32" opacity="0.8"/><rect x="1" y="0" width="1" height="1" stroke-width="0.32" opacity="0.8"/><rect x="0" y="1" width="2" height="2" stroke-width="0.32" opacity="0.8"/><rect x="-3" y="0" width="3" height="3" stroke-width="0.32" opacity="0.8"/><rect x="-3" y="-5" width="5" height="5" stroke-width="0.32" opacity="0.8"/><rect x="2" y="-5" width="8" height="8" stroke-width="0.32" opacity="0.8"/><rect x="-3" y="3" width="13" height="13" stroke-width="0.32" opacity="0.8"/><rect x="-24" y="-5" width="21" height="21" stroke-width="0.32" opacity="0.8"/><path d="M 0 1 A 1 1 0 0 0 1 0 A 1 1 0 0 1 2 1 A 2 2 0 0 1 0 3 A 3 3 0 0 1 -3 0 A 5 5 0 0 1 2 -5 A 8 8 0 0 1 10 3 A 13 13 0 0 1 -3 16 A 21 21 0 0 1 -24 -5" stroke="#b48a5a"/></g>
4 +</svg>
\ No newline at end of file
modified src/app/layout.tsx +48 −16
@@ -1,29 +1,61 @@
1 −import type { Metadata } from "next";
2 −import { Geist, Geist_Mono } from "next/font/google";
1 +import type { Metadata, Viewport } from "next";
2 +import { EB_Garamond, Figtree } from "next/font/google";
3 +import { site } from "@/lib/content";
4 +import { siteUrl } from "@/lib/env";
5 +import { Header } from "@/components/layout/Header";
6 +import { Footer } from "@/components/layout/Footer";
3 7 import "./globals.css";
4 8
5 −const geistSans = Geist({
6 − variable: "--font-geist-sans",
7 − subsets: ["latin"],
9 +const garamond = EB_Garamond({
10 + variable: "--font-eb-garamond",
11 + subsets: ["latin", "latin-ext"],
12 + weight: ["400", "500", "600"],
13 + style: ["normal", "italic"],
14 + display: "swap",
8 15 });
9 16
10 −const geistMono = Geist_Mono({
11 − variable: "--font-geist-mono",
12 − subsets: ["latin"],
17 +const figtree = Figtree({
18 + variable: "--font-figtree",
19 + subsets: ["latin", "latin-ext"],
20 + weight: ["400", "500", "600"],
21 + display: "swap",
13 22 });
14 23
15 24 export const metadata: Metadata = {
16 − title: "Create Next App",
17 − description: "Generated by create next app",
25 + metadataBase: new URL(siteUrl(site.seo.siteUrl)),
26 + title: { default: site.seo.defaultTitle, template: site.seo.titleTemplate },
27 + description: site.seo.defaultDescription,
28 + applicationName: site.brand.name,
29 + openGraph: {
30 + type: "website",
31 + locale: site.seo.locale,
32 + siteName: site.brand.name,
33 + title: site.seo.defaultTitle,
34 + description: site.seo.defaultDescription,
35 + },
36 + twitter: { card: "summary_large_image" },
37 + robots: { index: true, follow: true },
18 38 };
19 39
20 −export default function RootLayout({ children }: LayoutProps<"/">) {
40 +export const viewport: Viewport = {
41 + themeColor: "#f5eee4",
42 + width: "device-width",
43 + initialScale: 1,
44 +};
45 +
46 +export default function RootLayout({ children }: { children: React.ReactNode }) {
21 47 return (
22 − <html
23 − lang="en"
24 − className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
25 − >
26 − <body className="min-h-full flex flex-col">{children}</body>
48 + <html lang="fr-CA" className={`${garamond.variable} ${figtree.variable} h-full antialiased`}>
49 + <body className="flex min-h-full flex-col">
50 + <a href="#contenu" className="skip-link">
51 + Aller au contenu
52 + </a>
53 + <Header />
54 + <main id="contenu" className="flex-1">
55 + {children}
56 + </main>
57 + <Footer />
58 + </body>
27 59 </html>
28 60 );
29 61 }
added src/app/manifest.ts +18 −0
@@ -0,0 +1,18 @@
1 +import type { MetadataRoute } from "next";
2 +import { site } from "@/lib/content";
3 +
4 +export default function manifest(): MetadataRoute.Manifest {
5 + return {
6 + name: site.brand.name,
7 + short_name: site.brand.name,
8 + description: site.seo.defaultDescription,
9 + start_url: "/",
10 + display: "browser",
11 + background_color: "#f5eee4",
12 + theme_color: "#f5eee4",
13 + icons: [
14 + { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
15 + { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
16 + ],
17 + };
18 +}
added src/app/not-found.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import { Button } from "@/components/ui/Button";
2 +
3 +export default function NotFound() {
4 + return (
5 + <section className="section-y bg-ivory-100">
6 + <div className="container-site py-16 text-center">
7 + <p className="overtitle">Erreur 404</p>
8 + <h1 className="text-h2 mt-3">Cette page n’existe pas</h1>
9 + <p className="text-lead text-ink-700 mx-auto mt-4 max-w-[32rem]">
10 + L’adresse demandée est introuvable. Revenez à l’accueil ou contactez-nous.
11 + </p>
12 + <div className="mt-8 flex justify-center gap-3">
13 + <Button href="/">Accueil</Button>
14 + <Button href="/nous-joindre" variant="outline">
15 + Nous joindre
16 + </Button>
17 + </div>
18 + </div>
19 + </section>
20 + );
21 +}
added src/app/opengraph-image.tsx +65 −0
@@ -0,0 +1,65 @@
1 +import { ImageResponse } from "next/og";
2 +import { site } from "@/lib/content";
3 +import { buildSpiral } from "@/lib/spiral";
4 +
5 +export const alt = `${site.brand.name} — ${site.brand.tagline}`;
6 +export const size = { width: 1200, height: 630 };
7 +export const contentType = "image/png";
8 +
9 +export default function OpenGraphImage() {
10 + const { squares, path, bbox } = buildSpiral(8);
11 + return new ImageResponse(
12 + <div
13 + style={{
14 + width: "100%",
15 + height: "100%",
16 + display: "flex",
17 + alignItems: "center",
18 + background: "#f5eee4",
19 + color: "#1d3047",
20 + padding: 80,
21 + fontFamily: "Georgia, serif",
22 + }}
23 + >
24 + <div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
25 + <div style={{ fontSize: 88, lineHeight: 1 }}>{site.brand.name}</div>
26 + <div
27 + style={{
28 + fontSize: 22,
29 + letterSpacing: 6,
30 + marginTop: 24,
31 + color: "#3a4652",
32 + fontFamily: "sans-serif",
33 + }}
34 + >
35 + {site.brand.tagline}
36 + </div>
37 + <div
38 + style={{
39 + fontSize: 30,
40 + marginTop: 48,
41 + color: "#3a4652",
42 + fontFamily: "sans-serif",
43 + maxWidth: 620,
44 + }}
45 + >
46 + {site.seo.defaultDescription}
47 + </div>
48 + </div>
49 + <svg
50 + width="400"
51 + height="247"
52 + viewBox={`${bbox.x} ${bbox.y} ${bbox.w} ${bbox.h}`}
53 + fill="none"
54 + stroke="#c7a075"
55 + strokeWidth="0.25"
56 + >
57 + {squares.map((s, i) => (
58 + <rect key={i} x={s.x} y={s.y} width={s.s} height={s.s} />
59 + ))}
60 + <path d={path} strokeWidth="0.4" />
61 + </svg>
62 + </div>,
63 + size,
64 + );
65 +}
deleted src/app/page.tsx +0 −69
@@ -1,69 +0,0 @@
1 −import Image from "next/image";
2 −
3 −export default function Home() {
4 − return (
5 − <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6 − <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7 − <Image
8 − className="dark:invert h-5 w-[100px]"
9 − src="/next.svg"
10 − alt="Next.js logo"
11 − width={100}
12 − height={20}
13 − priority
14 − />
15 − <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16 − <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17 − To get started, edit the{" "}
18 − <code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
19 − page.tsx
20 − </code>{" "}
21 − file.
22 − </h1>
23 − <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
24 − Looking for a starting point or more instructions? Head over to{" "}
25 − <a
26 − href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
27 − className="font-medium text-zinc-950 dark:text-zinc-50"
28 − >
29 − Templates
30 − </a>{" "}
31 − or the{" "}
32 − <a
33 − href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
34 − className="font-medium text-zinc-950 dark:text-zinc-50"
35 − >
36 − Learning
37 − </a>{" "}
38 − center.
39 − </p>
40 − </div>
41 − <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
42 − <a
43 − className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
44 − href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
45 − target="_blank"
46 − rel="noopener noreferrer"
47 − >
48 − <Image
49 − className="dark:invert h-[14px] w-4"
50 − src="/vercel.svg"
51 − alt="Vercel logomark"
52 − width={16}
53 − height={14}
54 − />
55 − Deploy Now
56 − </a>
57 − <a
58 − className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
59 − href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
60 − target="_blank"
61 − rel="noopener noreferrer"
62 − >
63 − Documentation
64 − </a>
65 − </div>
66 − </main>
67 − </div>
68 − );
69 −}
added src/app/robots.ts +9 −0
@@ -0,0 +1,9 @@
1 +import type { MetadataRoute } from "next";
2 +import { baseUrl } from "@/lib/seo";
3 +
4 +export default function robots(): MetadataRoute.Robots {
5 + return {
6 + rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }],
7 + sitemap: `${baseUrl()}/sitemap.xml`,
8 + };
9 +}
added src/app/sitemap.ts +19 −0
@@ -0,0 +1,19 @@
1 +import type { MetadataRoute } from "next";
2 +import { baseUrl } from "@/lib/seo";
3 +
4 +export default function sitemap(): MetadataRoute.Sitemap {
5 + const base = baseUrl();
6 + const routes: { path: string; priority: number; changeFrequency: "monthly" | "yearly" }[] = [
7 + { path: "/", priority: 1, changeFrequency: "monthly" },
8 + { path: "/services", priority: 0.9, changeFrequency: "monthly" },
9 + { path: "/a-propos", priority: 0.8, changeFrequency: "monthly" },
10 + { path: "/temoignages", priority: 0.6, changeFrequency: "monthly" },
11 + { path: "/nous-joindre", priority: 0.8, changeFrequency: "yearly" },
12 + { path: "/confidentialite", priority: 0.3, changeFrequency: "yearly" },
13 + ];
14 + return routes.map((r) => ({
15 + url: `${base}${r.path}`,
16 + changeFrequency: r.changeFrequency,
17 + priority: r.priority,
18 + }));
19 +}
added src/components/forms/ContactForm.tsx +275 −0
@@ -0,0 +1,275 @@
1 +"use client";
2 +
3 +import { useEffect, useId, useRef, useState } from "react";
4 +import { useForm } from "react-hook-form";
5 +import { zodResolver } from "@hookform/resolvers/zod";
6 +import { contactFormSchema, type ContactFormValues } from "@/lib/validation";
7 +import { Button } from "@/components/ui/Button";
8 +
9 +type Status =
10 + | { kind: "idle" }
11 + | { kind: "submitting" }
12 + | { kind: "success"; delivered: boolean }
13 + | { kind: "error"; message: string };
14 +
15 +export function ContactForm({
16 + subjects,
17 + consentLabel,
18 + successTitle,
19 + successMessage,
20 +}: {
21 + subjects: string[];
22 + consentLabel: string;
23 + successTitle: string;
24 + successMessage: string;
25 +}) {
26 + const id = useId();
27 + const startedAt = useRef<number>(0);
28 + useEffect(() => {
29 + startedAt.current = Date.now();
30 + }, []);
31 + const [status, setStatus] = useState<Status>({ kind: "idle" });
32 + const {
33 + register,
34 + handleSubmit,
35 + reset,
36 + formState: { errors, isSubmitting },
37 + } = useForm<ContactFormValues>({
38 + resolver: zodResolver(contactFormSchema),
39 + defaultValues: {
40 + name: "",
41 + email: "",
42 + organization: "",
43 + subject: "",
44 + message: "",
45 + consent: undefined,
46 + },
47 + });
48 +
49 + const submit = async (values: ContactFormValues) => {
50 + setStatus({ kind: "submitting" });
51 + try {
52 + const form = document.getElementById(`${id}-form`) as HTMLFormElement | null;
53 + const honeypot =
54 + (form?.elements.namedItem("website") as HTMLInputElement | null)?.value ?? "";
55 + const res = await fetch("/api/contact", {
56 + method: "POST",
57 + headers: { "Content-Type": "application/json", Accept: "application/json" },
58 + body: JSON.stringify({ ...values, website: honeypot, startedAt: startedAt.current }),
59 + });
60 + const data = (await res.json().catch(() => ({}))) as {
61 + ok?: boolean;
62 + delivered?: boolean;
63 + error?: string;
64 + };
65 + if (!res.ok || !data.ok) {
66 + setStatus({
67 + kind: "error",
68 + message:
69 + res.status === 429
70 + ? "Trop de tentatives. Veuillez réessayer dans quelques minutes."
71 + : (data.error ?? "L’envoi a échoué. Veuillez réessayer ou nous écrire directement."),
72 + });
73 + return;
74 + }
75 + setStatus({ kind: "success", delivered: data.delivered !== false });
76 + reset();
77 + } catch {
78 + setStatus({
79 + kind: "error",
80 + message: "Connexion impossible. Vérifiez votre réseau puis réessayez.",
81 + });
82 + }
83 + };
84 +
85 + if (status.kind === "success") {
86 + return (
87 + <div
88 + role="status"
89 + aria-live="polite"
90 + className="border-success/30 rounded-[var(--radius-lg)] border bg-white/70 p-8"
91 + >
92 + <h2 className="text-h4 text-success">{successTitle}</h2>
93 + <p className="text-ink-700 mt-3">{successMessage}</p>
94 + {!status.delivered ? (
95 + <p className="text-ink-500 mt-3 text-sm">
96 + Note : le service de messagerie n’est pas encore configuré sur cet environnement ; le
97 + message a été reçu mais pas relayé par courriel.
98 + </p>
99 + ) : null}
100 + <div className="mt-6">
101 + <Button variant="outline" arrow={false} onClick={() => setStatus({ kind: "idle" })}>
102 + Envoyer un autre message
103 + </Button>
104 + </div>
105 + </div>
106 + );
107 + }
108 +
109 + const field = (name: keyof ContactFormValues) => ({
110 + "aria-invalid": errors[name] ? true : undefined,
111 + "aria-describedby": errors[name] ? `${id}-${name}-error` : undefined,
112 + });
113 + const err = (name: keyof ContactFormValues) =>
114 + errors[name] ? (
115 + <p id={`${id}-${name}-error`} role="alert" className="text-danger mt-1.5 text-sm">
116 + {errors[name]?.message as string}
117 + </p>
118 + ) : null;
119 +
120 + const input =
121 + "mt-1.5 w-full rounded-md border border-pale-300 bg-white px-3.5 py-2.5 text-ink-900 placeholder:text-ink-300 focus:border-slate-500 aria-[invalid]:border-danger";
122 + const label = "block text-sm font-medium text-ink-900";
123 +
124 + return (
125 + <form
126 + id={`${id}-form`}
127 + onSubmit={(e) => void handleSubmit(submit)(e)}
128 + noValidate
129 + className="flex flex-col gap-5"
130 + aria-describedby={status.kind === "error" ? `${id}-status` : undefined}
131 + >
132 + <div className="grid gap-5 sm:grid-cols-2">
133 + <div>
134 + <label htmlFor={`${id}-name`} className={label}>
135 + Nom <span aria-hidden="true">*</span>
136 + </label>
137 + <input
138 + id={`${id}-name`}
139 + type="text"
140 + autoComplete="name"
141 + required
142 + maxLength={120}
143 + className={input}
144 + {...register("name")}
145 + {...field("name")}
146 + />
147 + {err("name")}
148 + </div>
149 + <div>
150 + <label htmlFor={`${id}-email`} className={label}>
151 + Courriel <span aria-hidden="true">*</span>
152 + </label>
153 + <input
154 + id={`${id}-email`}
155 + type="email"
156 + autoComplete="email"
157 + required
158 + maxLength={254}
159 + className={input}
160 + {...register("email")}
161 + {...field("email")}
162 + />
163 + {err("email")}
164 + </div>
165 + </div>
166 + <div className="grid gap-5 sm:grid-cols-2">
167 + <div>
168 + <label htmlFor={`${id}-organization`} className={label}>
169 + Organisation <span className="text-ink-500 font-normal">(facultatif)</span>
170 + </label>
171 + <input
172 + id={`${id}-organization`}
173 + type="text"
174 + autoComplete="organization"
175 + maxLength={160}
176 + className={input}
177 + {...register("organization")}
178 + {...field("organization")}
179 + />
180 + {err("organization")}
181 + </div>
182 + <div>
183 + <label htmlFor={`${id}-subject`} className={label}>
184 + Sujet <span aria-hidden="true">*</span>
185 + </label>
186 + <select
187 + id={`${id}-subject`}
188 + required
189 + className={input}
190 + defaultValue=""
191 + {...register("subject")}
192 + {...field("subject")}
193 + >
194 + <option value="" disabled>
195 + Choisir un sujet…
196 + </option>
197 + {subjects.map((s) => (
198 + <option key={s} value={s}>
199 + {s}
200 + </option>
201 + ))}
202 + </select>
203 + {err("subject")}
204 + </div>
205 + </div>
206 + <div>
207 + <label htmlFor={`${id}-message`} className={label}>
208 + Message <span aria-hidden="true">*</span>
209 + </label>
210 + <textarea
211 + id={`${id}-message`}
212 + rows={6}
213 + required
214 + minLength={20}
215 + maxLength={4000}
216 + className={input}
217 + {...register("message")}
218 + {...field("message")}
219 + />
220 + {err("message")}
221 + </div>
222 +
223 + {/* Honeypot : invisible et hors tabulation ; doit rester vide. */}
224 + <div className="absolute -left-[9999px] h-0 w-0 overflow-hidden" aria-hidden="true">
225 + <label htmlFor={`${id}-website`}>Site web</label>
226 + <input
227 + id={`${id}-website`}
228 + type="text"
229 + name="website"
230 + tabIndex={-1}
231 + autoComplete="off"
232 + defaultValue=""
233 + />
234 + </div>
235 +
236 + <div className="flex items-start gap-3">
237 + <input
238 + id={`${id}-consent`}
239 + type="checkbox"
240 + className="mt-1 h-4 w-4 accent-slate-500"
241 + {...register("consent")}
242 + {...field("consent")}
243 + />
244 + <label htmlFor={`${id}-consent`} className="text-ink-700 text-sm">
245 + {consentLabel} <span aria-hidden="true">*</span>
246 + </label>
247 + </div>
248 + {err("consent")}
249 +
250 + {status.kind === "error" ? (
251 + <p
252 + id={`${id}-status`}
253 + role="alert"
254 + className="border-danger/40 bg-danger/5 text-danger rounded-md border px-4 py-3 text-sm"
255 + >
256 + {status.message}
257 + </p>
258 + ) : null}
259 +
260 + <div>
261 + <Button
262 + type="submit"
263 + size="lg"
264 + disabled={isSubmitting || status.kind === "submitting"}
265 + aria-busy={status.kind === "submitting"}
266 + >
267 + {status.kind === "submitting" ? "Envoi en cours…" : "Envoyer le message"}
268 + </Button>
269 + <p className="text-ink-500 mt-3 text-xs">
270 + <span aria-hidden="true">* </span>Champs obligatoires.
271 + </p>
272 + </div>
273 + </form>
274 + );
275 +}
added src/components/graphics/BotanicalMotif.tsx +34 −0
@@ -0,0 +1,34 @@
1 +/** Feuille stylisée au trait, pour le bandeau final. */
2 +export function BotanicalMotif({ className = "" }: { className?: string }) {
3 + const leaf = (rot: number, len: number, w: number, y: number) => (
4 + <ellipse
5 + key={`${rot}-${y}`}
6 + cx={0}
7 + cy={-len / 2}
8 + rx={w}
9 + ry={len / 2}
10 + transform={`translate(0 ${y}) rotate(${rot})`}
11 + />
12 + );
13 + return (
14 + <svg
15 + className={className}
16 + viewBox="-80 -10 160 220"
17 + fill="var(--color-ivory-200)"
18 + fillOpacity="0.55"
19 + stroke="var(--color-ivory-200)"
20 + strokeWidth="1.5"
21 + aria-hidden="true"
22 + focusable="false"
23 + >
24 + <path d="M0 200 C -8 150, -4 90, 0 10" fill="none" />
25 + {leaf(-40, 60, 14, 160)}
26 + {leaf(42, 56, 13, 140)}
27 + {leaf(-44, 54, 12, 115)}
28 + {leaf(40, 50, 11, 92)}
29 + {leaf(-46, 46, 10, 70)}
30 + {leaf(38, 40, 9, 50)}
31 + {leaf(-10, 34, 8, 32)}
32 + </svg>
33 + );
34 +}
added src/components/graphics/GoldenSpiral.tsx +66 −0
@@ -0,0 +1,66 @@
1 +import type { SVGProps } from "react";
2 +import { buildSpiral } from "@/lib/spiral";
3 +
4 +/**
5 + * Spirale de Fibonacci construite par programme : carrés de côtés
6 + * 1, 1, 2, 3, 5, 8, 13, 21 (…) disposés en rectangle, et quart de cercle
7 + * inscrit dans chaque carré. L'approximation du nombre d'or est celle de la
8 + * suite de Fibonacci (21/13 ≈ 1,615) — ce n'est pas une spirale
9 + * logarithmique exacte.
10 + */
11 +
12 +type Props = SVGProps<SVGSVGElement> & {
13 + levels?: number;
14 + showSquares?: boolean;
15 + strokeWidth?: number;
16 + /** miroir horizontal / vertical pour orienter le motif */
17 + flipX?: boolean;
18 + flipY?: boolean;
19 + title?: string;
20 +};
21 +
22 +export function GoldenSpiral({
23 + levels = 8,
24 + showSquares = true,
25 + strokeWidth = 0.12,
26 + flipX = false,
27 + flipY = false,
28 + title,
29 + ...rest
30 +}: Props) {
31 + const { squares, path, bbox } = buildSpiral(levels);
32 + const pad = strokeWidth;
33 + const transform = `translate(${flipX ? bbox.x * 2 + bbox.w : 0} ${
34 + flipY ? bbox.y * 2 + bbox.h : 0
35 + }) scale(${flipX ? -1 : 1} ${flipY ? -1 : 1})`;
36 + return (
37 + <svg
38 + viewBox={`${bbox.x - pad} ${bbox.y - pad} ${bbox.w + pad * 2} ${bbox.h + pad * 2}`}
39 + fill="none"
40 + stroke="currentColor"
41 + strokeWidth={strokeWidth}
42 + strokeLinecap="round"
43 + aria-hidden={title ? undefined : true}
44 + role={title ? "img" : undefined}
45 + {...rest}
46 + >
47 + {title ? <title>{title}</title> : null}
48 + <g transform={transform}>
49 + {showSquares
50 + ? squares.map((sq, i) => (
51 + <rect
52 + key={i}
53 + x={sq.x}
54 + y={sq.y}
55 + width={sq.s}
56 + height={sq.s}
57 + strokeWidth={strokeWidth * 0.75}
58 + opacity={0.8}
59 + />
60 + ))
61 + : null}
62 + <path d={path} />
63 + </g>
64 + </svg>
65 + );
66 +}
added src/components/graphics/Logo.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import { GoldenSpiral } from "./GoldenSpiral";
2 +
3 +/**
4 + * Logo recréé en SVG d'après la capture (rectangle d'or + spirale dorée).
5 + * À remplacer par le fichier vectoriel officiel du client s'il existe ;
6 + * validation du client requise avant usage définitif.
7 + */
8 +export function LogoMark({ className }: { className?: string }) {
9 + return (
10 + <GoldenSpiral className={className} levels={8} strokeWidth={0.16} title="Logo PHI Conseil" />
11 + );
12 +}
13 +
14 +export function LogoLockup({
15 + compact = false,
16 + tagline,
17 + name,
18 +}: {
19 + compact?: boolean;
20 + name: string;
21 + tagline: string;
22 +}) {
23 + return (
24 + <span className="flex min-w-0 items-center gap-2.5 md:gap-3">
25 + <LogoMark
26 + className={
27 + compact ? "text-gold-500 h-8 w-auto" : "text-gold-500 h-9 w-auto shrink-0 sm:h-11 md:h-14"
28 + }
29 + />
30 + <span className="flex flex-col leading-none">
31 + <span
32 + className={`text-navy-900 font-serif ${
33 + compact ? "text-[1.35rem]" : "text-[1.35rem] sm:text-[1.6rem] md:text-[2rem]"
34 + }`}
35 + >
36 + {name}
37 + </span>
38 + {!compact ? (
39 + <span className="text-ink-700 mt-1.5 hidden text-[0.55rem] font-semibold tracking-[0.16em] uppercase sm:block md:text-[0.62rem]">
40 + {tagline}
41 + </span>
42 + ) : null}
43 + </span>
44 + </span>
45 + );
46 +}
added src/components/graphics/Placeholders.tsx +168 −0
@@ -0,0 +1,168 @@
1 +/**
2 + * Illustrations vectorielles de remplacement, dans la palette du site.
3 + * Elles occupent exactement la place des photographies de la capture
4 + * (bureau avec plante, tasse et ordinateur ; vase et livres) en attendant les
5 + * photos sous licence du client. Voir docs/ASSET_REGISTER.md.
6 + */
7 +
8 +export function DeskScene({ className = "" }: { className?: string }) {
9 + return (
10 + <svg
11 + className={className}
12 + viewBox="0 0 640 440"
13 + role="img"
14 + aria-labelledby="desk-title"
15 + preserveAspectRatio="xMidYMid slice"
16 + >
17 + <title id="desk-title">
18 + Illustration : bureau lumineux avec une plante, une tasse et un ordinateur portable
19 + </title>
20 + <defs>
21 + <linearGradient id="desk-light" x1="0" y1="0" x2="1" y2="1">
22 + <stop offset="0" stopColor="#fbf6ed" />
23 + <stop offset="0.55" stopColor="#f3e9da" />
24 + <stop offset="1" stopColor="#e5dccb" />
25 + </linearGradient>
26 + <linearGradient id="desk-wood" x1="0" y1="0" x2="1" y2="0">
27 + <stop offset="0" stopColor="#e4cfae" />
28 + <stop offset="1" stopColor="#cdb389" />
29 + </linearGradient>
30 + <linearGradient id="desk-screen" x1="0" y1="0" x2="0" y2="1">
31 + <stop offset="0" stopColor="#f6f3ec" />
32 + <stop offset="1" stopColor="#dfe6ec" />
33 + </linearGradient>
34 + <radialGradient id="desk-glow" cx="0.6" cy="0.3" r="0.6">
35 + <stop offset="0" stopColor="#ffffff" stopOpacity="0.9" />
36 + <stop offset="1" stopColor="#ffffff" stopOpacity="0" />
37 + </radialGradient>
38 + </defs>
39 + <rect width="640" height="440" fill="url(#desk-light)" />
40 + {/* fenêtre / lumière */}
41 + <rect x="360" y="20" width="250" height="260" rx="6" fill="#f7f0e4" />
42 + <rect x="372" y="32" width="110" height="236" fill="#fbf8f1" />
43 + <rect x="494" y="32" width="104" height="236" fill="#f9f4ea" />
44 + <rect width="640" height="440" fill="url(#desk-glow)" />
45 + {/* plan de travail */}
46 + <path d="M0 300 L640 282 L640 440 L0 440 Z" fill="url(#desk-wood)" />
47 + <path d="M0 300 L640 282 L640 292 L0 310 Z" fill="#f0e0c5" opacity="0.7" />
48 + {/* ordinateur portable */}
49 + <g transform="translate(372 170)">
50 + <rect x="0" y="0" width="240" height="140" rx="8" fill="#9aa5ae" />
51 + <rect x="8" y="8" width="224" height="124" rx="4" fill="url(#desk-screen)" />
52 + <path
53 + d="M-18 140 L258 140 L278 160 Q280 166 272 166 L-32 166 Q-40 166 -38 160 Z"
54 + fill="#aab4bc"
55 + />
56 + <rect x="90" y="150" width="60" height="5" rx="2" fill="#8c979f" />
57 + </g>
58 + {/* tasse */}
59 + <g transform="translate(260 240)">
60 + <ellipse cx="38" cy="86" rx="46" ry="8" fill="#b9a88a" opacity="0.35" />
61 + <path d="M0 20 Q0 86 38 86 Q76 86 76 20 Z" fill="#fbfaf6" />
62 + <ellipse cx="38" cy="20" rx="38" ry="9" fill="#efe9dd" />
63 + <ellipse cx="38" cy="20" rx="30" ry="6" fill="#d9c8ad" />
64 + <path
65 + d="M76 36 Q104 36 102 58 Q100 78 74 76"
66 + fill="none"
67 + stroke="#fbfaf6"
68 + strokeWidth="9"
69 + />
70 + </g>
71 + {/* plante */}
72 + <g transform="translate(150 150)">
73 + <ellipse cx="40" cy="170" rx="60" ry="9" fill="#b9a88a" opacity="0.3" />
74 + <path d="M-10 110 L90 110 L80 170 L0 170 Z" fill="#f4eee3" />
75 + <path d="M-10 110 L90 110 L87 124 L-7 124 Z" fill="#e6dccb" />
76 + <g fill="#6f8f6a">
77 + <path d="M40 112 C 20 90, 10 60, 40 40 C 70 60, 60 90, 40 112 Z" />
78 + <path d="M42 110 C 70 100, 95 80, 110 45 C 80 50, 55 75, 42 110 Z" fill="#7d9c74" />
79 + <path d="M38 110 C 10 100, -20 85, -30 50 C 0 55, 25 78, 38 110 Z" fill="#86a37c" />
80 + <path d="M44 112 C 60 118, 95 120, 125 105 C 100 95, 65 100, 44 112 Z" fill="#6a8a66" />
81 + <path d="M36 112 C 20 118, -15 122, -45 110 C -20 98, 15 100, 36 112 Z" fill="#92ad88" />
82 + <path
83 + d="M42 100 C 55 70, 75 45, 95 20 C 70 30, 50 60, 42 100 Z"
84 + fill="#9db694"
85 + opacity="0.9"
86 + />
87 + </g>
88 + </g>
89 + </svg>
90 + );
91 +}
92 +
93 +export function VaseScene({ className = "" }: { className?: string }) {
94 + return (
95 + <svg
96 + className={className}
97 + viewBox="0 0 440 560"
98 + role="img"
99 + aria-labelledby="vase-title"
100 + preserveAspectRatio="xMidYMid slice"
101 + >
102 + <title id="vase-title">
103 + Illustration : vase transparent avec feuillage posé sur des livres, lumière douce
104 + </title>
105 + <defs>
106 + <linearGradient id="vase-bg" x1="0" y1="0" x2="1" y2="1">
107 + <stop offset="0" stopColor="#f6efe4" />
108 + <stop offset="1" stopColor="#e9dfd0" />
109 + </linearGradient>
110 + <linearGradient id="vase-glass" x1="0" y1="0" x2="1" y2="0">
111 + <stop offset="0" stopColor="#ffffff" stopOpacity="0.75" />
112 + <stop offset="0.5" stopColor="#dfe8ee" stopOpacity="0.55" />
113 + <stop offset="1" stopColor="#ffffff" stopOpacity="0.8" />
114 + </linearGradient>
115 + </defs>
116 + <rect width="440" height="560" fill="url(#vase-bg)" />
117 + <rect x="0" y="0" width="440" height="560" fill="#ffffff" opacity="0.18" />
118 + {/* table */}
119 + <path d="M0 470 L440 450 L440 560 L0 560 Z" fill="#dcc6a4" />
120 + <path d="M0 470 L440 450 L440 460 L0 480 Z" fill="#efe2cc" opacity="0.7" />
121 + {/* livres */}
122 + <g transform="translate(20 380)">
123 + <rect x="0" y="60" width="300" height="34" rx="3" fill="#5f7a94" />
124 + <rect x="0" y="60" width="300" height="6" rx="2" fill="#7b94ab" />
125 + <rect x="14" y="26" width="280" height="34" rx="3" fill="#a9b8c6" />
126 + <rect x="14" y="26" width="280" height="6" rx="2" fill="#c5d1dc" />
127 + <rect x="6" y="-8" width="290" height="34" rx="3" fill="#e8dfd0" />
128 + <rect x="6" y="-8" width="290" height="6" rx="2" fill="#f5efe4" />
129 + </g>
130 + {/* vase */}
131 + <g transform="translate(140 200)">
132 + <path
133 + d="M30 0 L90 0 L100 60 Q110 150 60 172 Q10 150 20 60 Z"
134 + fill="url(#vase-glass)"
135 + stroke="#ffffff"
136 + strokeWidth="2"
137 + />
138 + <path d="M24 90 Q20 150 60 168 Q100 150 96 90 Z" fill="#dbe6ec" opacity="0.55" />
139 + </g>
140 + {/* tiges et feuilles */}
141 + <g fill="#7f9a76" stroke="#5e7858" strokeWidth="1">
142 + <path d="M200 200 C 190 150, 170 110, 150 60" fill="none" />
143 + <path d="M205 200 C 215 150, 240 110, 270 70" fill="none" />
144 + <path d="M198 200 C 180 160, 150 140, 110 120" fill="none" />
145 + {[
146 + [150, 60, -30],
147 + [170, 110, -20],
148 + [120, 95, -60],
149 + [270, 70, 25],
150 + [240, 110, 35],
151 + [285, 115, 60],
152 + [110, 120, -70],
153 + [140, 150, -35],
154 + [225, 150, 45],
155 + ].map(([x, y, r], i) => (
156 + <ellipse
157 + key={i}
158 + cx={0}
159 + cy={-22}
160 + rx={11}
161 + ry={24}
162 + transform={`translate(${x} ${y}) rotate(${r})`}
163 + />
164 + ))}
165 + </g>
166 + </svg>
167 + );
168 +}
added src/components/graphics/WaveDivider.tsx +28 −0
@@ -0,0 +1,28 @@
1 +/**
2 + * Transition décorative en deux vagues superposées (ivoire puis bleu pâle),
3 + * comme au bas du hero de la capture.
4 + */
5 +export function WaveDivider({ className = "" }: { className?: string }) {
6 + return (
7 + <svg
8 + className={className}
9 + viewBox="0 0 1440 160"
10 + preserveAspectRatio="none"
11 + aria-hidden="true"
12 + focusable="false"
13 + >
14 + <path
15 + d="M0 70 C 180 20, 300 20, 480 60 S 820 150, 1040 110 S 1340 60, 1440 40 L1440 160 L0 160 Z"
16 + fill="var(--color-ivory-200)"
17 + />
18 + <path
19 + d="M0 110 C 160 70, 340 60, 520 90 S 880 160, 1100 125 S 1360 70, 1440 90 L1440 160 L0 160 Z"
20 + fill="var(--color-pale-300)"
21 + />
22 + <path
23 + d="M0 150 C 240 120, 480 115, 720 135 S 1200 160, 1440 145 L1440 160 L0 160 Z"
24 + fill="var(--color-ivory-50)"
25 + />
26 + </svg>
27 + );
28 +}
added src/components/layout/Footer.tsx +102 −0
@@ -0,0 +1,102 @@
1 +import Link from "next/link";
2 +import { site } from "@/lib/content";
3 +import { LogoMark } from "@/components/graphics/Logo";
4 +import { LinkedInIcon, MailIcon } from "@/components/ui/Icons";
5 +import { currentYear } from "@/lib/time";
6 +
7 +export async function Footer() {
8 + const year = await currentYear();
9 + const hasLinkedIn = site.social.linkedin !== "";
10 + const hasEmail = site.contact.email !== "";
11 + return (
12 + <footer className="bg-ivory-50 text-ink-700">
13 + <div className="container-site py-8 md:py-10">
14 + <div className="flex flex-col gap-8 md:flex-row md:items-center md:justify-between">
15 + <Link
16 + href="/"
17 + className="flex items-center gap-3 rounded-md"
18 + aria-label={`${site.brand.name} — Accueil`}
19 + >
20 + <LogoMark className="text-gold-500 h-9 w-auto" />
21 + <span className="text-navy-900 font-serif text-2xl">{site.brand.name}</span>
22 + </Link>
23 +
24 + <nav aria-label="Navigation de pied de page">
25 + <ul className="flex flex-wrap items-center gap-x-0 gap-y-2 text-sm">
26 + {site.navigation.map((item, i) => (
27 + <li key={item.href} className="flex items-center">
28 + {i > 0 ? (
29 + <span aria-hidden="true" className="bg-ink-300/70 mx-4 h-4 w-px" />
30 + ) : null}
31 + <Link
32 + href={item.href}
33 + className="text-ink-700 hover:text-navy-900 rounded-sm underline-offset-4 hover:underline"
34 + >
35 + {item.label}
36 + </Link>
37 + </li>
38 + ))}
39 + </ul>
40 + </nav>
41 +
42 + {hasLinkedIn || hasEmail ? (
43 + <ul className="flex items-center gap-4" aria-label="Réseaux et contact">
44 + {hasLinkedIn ? (
45 + <li>
46 + <a
47 + href={site.social.linkedin}
48 + target="_blank"
49 + rel="noopener noreferrer"
50 + aria-label="LinkedIn (nouvelle fenêtre)"
51 + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-10 w-10 items-center justify-center rounded-md"
52 + >
53 + <LinkedInIcon />
54 + </a>
55 + </li>
56 + ) : null}
57 + {hasEmail ? (
58 + <li>
59 + <a
60 + href={`mailto:${site.contact.email}`}
61 + aria-label="Nous écrire par courriel"
62 + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-10 w-10 items-center justify-center rounded-md"
63 + >
64 + <MailIcon />
65 + </a>
66 + </li>
67 + ) : null}
68 + </ul>
69 + ) : (
70 + <Link
71 + href="/nous-joindre"
72 + aria-label="Nous joindre"
73 + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-10 w-10 items-center justify-center rounded-md"
74 + >
75 + <MailIcon />
76 + </Link>
77 + )}
78 + </div>
79 +
80 + <div className="border-ink-300/30 text-ink-500 mt-8 flex flex-col gap-3 border-t pt-5 text-xs md:flex-row md:items-center md:justify-between">
81 + <p className="flex flex-wrap items-center gap-x-3 gap-y-1">
82 + <span>
83 + © {year} {site.brand.legalName}. Tous droits réservés.
84 + </span>
85 + {site.legalLinks.map((l) => (
86 + <Link
87 + key={l.href}
88 + href={l.href}
89 + className="hover:text-navy-900 rounded-sm underline-offset-4 hover:underline"
90 + >
91 + {l.label}
92 + </Link>
93 + ))}
94 + </p>
95 + <p className="text-ink-700 font-semibold tracking-[0.14em] uppercase">
96 + {site.brand.footerSignature}
97 + </p>
98 + </div>
99 + </div>
100 + </footer>
101 + );
102 +}
added src/components/layout/Header.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import Link from "next/link";
2 +import { site } from "@/lib/content";
3 +import { LogoLockup } from "@/components/graphics/Logo";
4 +import { Button } from "@/components/ui/Button";
5 +import { MobileNav } from "./MobileNav";
6 +import { NavLinks } from "./NavLinks";
7 +
8 +export function Header() {
9 + return (
10 + <header className="bg-ivory-100/90 supports-[backdrop-filter]:bg-ivory-100/80 sticky top-0 z-40 backdrop-blur-sm">
11 + <div className="container-site flex items-center justify-between gap-5 py-3 md:py-4">
12 + <Link href="/" className="min-w-0 rounded-md" aria-label={`${site.brand.name} — Accueil`}>
13 + <LogoLockup name={site.brand.name} tagline={site.brand.tagline} />
14 + </Link>
15 +
16 + <nav aria-label="Navigation principale" className="hidden lg:block">
17 + <NavLinks items={site.navigation} className="flex items-center gap-5 xl:gap-7" />
18 + </nav>
19 +
20 + <div className="hidden lg:block">
21 + <Button href={site.headerCta.href} size="md" className="whitespace-nowrap">
22 + {site.headerCta.label}
23 + </Button>
24 + </div>
25 +
26 + <MobileNav items={site.navigation} cta={site.headerCta} brandName={site.brand.name} />
27 + </div>
28 + </header>
29 + );
30 +}
added src/components/layout/MobileNav.tsx +114 −0
@@ -0,0 +1,114 @@
1 +"use client";
2 +
3 +import { useEffect, useId, useRef, useState } from "react";
4 +import { Button } from "@/components/ui/Button";
5 +import { NavLinks } from "./NavLinks";
6 +
7 +type Item = { label: string; href: string };
8 +
9 +export function MobileNav({
10 + items,
11 + cta,
12 + brandName,
13 +}: {
14 + items: Item[];
15 + cta: Item;
16 + brandName: string;
17 +}) {
18 + const [open, setOpen] = useState(false);
19 + const panelId = useId();
20 + const closeRef = useRef<HTMLButtonElement>(null);
21 + const openerRef = useRef<HTMLButtonElement>(null);
22 +
23 + useEffect(() => {
24 + if (!open) return;
25 + const prev = document.body.style.overflow;
26 + const opener = openerRef.current;
27 + document.body.style.overflow = "hidden";
28 + closeRef.current?.focus();
29 + const onKey = (e: KeyboardEvent) => {
30 + if (e.key === "Escape") setOpen(false);
31 + };
32 + document.addEventListener("keydown", onKey);
33 + return () => {
34 + document.body.style.overflow = prev;
35 + document.removeEventListener("keydown", onKey);
36 + opener?.focus();
37 + };
38 + }, [open]);
39 +
40 + return (
41 + <div className="lg:hidden">
42 + <button
43 + ref={openerRef}
44 + type="button"
45 + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-11 w-11 items-center justify-center rounded-md"
46 + aria-expanded={open}
47 + aria-controls={panelId}
48 + aria-label="Ouvrir le menu"
49 + onClick={() => setOpen(true)}
50 + >
51 + <svg
52 + viewBox="0 0 24 24"
53 + className="h-6 w-6"
54 + fill="none"
55 + stroke="currentColor"
56 + strokeWidth="1.6"
57 + strokeLinecap="round"
58 + aria-hidden="true"
59 + >
60 + <path d="M4 7h16M4 12h16M4 17h16" />
61 + </svg>
62 + </button>
63 +
64 + {open ? (
65 + <div
66 + id={panelId}
67 + role="dialog"
68 + aria-modal="true"
69 + aria-label={`Menu ${brandName}`}
70 + className="bg-ivory-100 fixed inset-0 z-50 flex flex-col motion-safe:animate-[fade-in_var(--duration-base)_var(--ease-out)]"
71 + >
72 + <div className="container-site flex items-center justify-between py-4">
73 + <span className="text-navy-900 font-serif text-xl">{brandName}</span>
74 + <button
75 + ref={closeRef}
76 + type="button"
77 + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-11 w-11 items-center justify-center rounded-md"
78 + aria-label="Fermer le menu"
79 + onClick={() => setOpen(false)}
80 + >
81 + <svg
82 + viewBox="0 0 24 24"
83 + className="h-6 w-6"
84 + fill="none"
85 + stroke="currentColor"
86 + strokeWidth="1.6"
87 + strokeLinecap="round"
88 + aria-hidden="true"
89 + >
90 + <path d="m6 6 12 12M18 6 6 18" />
91 + </svg>
92 + </button>
93 + </div>
94 + <nav
95 + aria-label="Navigation mobile"
96 + className="container-site flex-1 overflow-y-auto py-6"
97 + >
98 + <NavLinks
99 + items={items}
100 + vertical
101 + className="flex flex-col gap-1"
102 + onNavigate={() => setOpen(false)}
103 + />
104 + </nav>
105 + <div className="container-site pb-10">
106 + <Button href={cta.href} size="lg" className="w-full" onClick={() => setOpen(false)}>
107 + {cta.label}
108 + </Button>
109 + </div>
110 + </div>
111 + ) : null}
112 + </div>
113 + );
114 +}
added src/components/layout/NavLinks.tsx +47 −0
@@ -0,0 +1,47 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname } from "next/navigation";
5 +
6 +type Item = { label: string; href: string };
7 +
8 +export function NavLinks({
9 + items,
10 + className = "",
11 + onNavigate,
12 + vertical = false,
13 +}: {
14 + items: Item[];
15 + className?: string;
16 + onNavigate?: () => void;
17 + vertical?: boolean;
18 +}) {
19 + const pathname = usePathname();
20 + return (
21 + <ul className={className}>
22 + {items.map((item) => {
23 + const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
24 + return (
25 + <li key={item.href}>
26 + <Link
27 + href={item.href}
28 + onClick={onNavigate}
29 + aria-current={active ? "page" : undefined}
30 + className={
31 + vertical
32 + ? `text-navy-900 hover:bg-navy-900/5 block rounded-md px-3 py-3 font-serif text-2xl ${
33 + active ? "decoration-gold-500 underline underline-offset-8" : ""
34 + }`
35 + : `text-ink-700 hover:text-navy-900 after:bg-navy-900 relative block py-1 text-[0.85rem] whitespace-nowrap transition-colors after:absolute after:inset-x-0 after:-bottom-1 after:h-px after:origin-left after:scale-x-0 after:transition-transform after:duration-[var(--duration-base)] hover:after:scale-x-100 ${
36 + active ? "text-navy-900 after:scale-x-100" : ""
37 + }`
38 + }
39 + >
40 + {item.label}
41 + </Link>
42 + </li>
43 + );
44 + })}
45 + </ul>
46 + );
47 +}
added src/components/sections/About.tsx +78 −0
@@ -0,0 +1,78 @@
1 +import Image from "next/image";
2 +import { home } from "@/lib/content";
3 +import { Button } from "@/components/ui/Button";
4 +import { CheckBadge } from "@/components/ui/Icons";
5 +import { VaseScene } from "@/components/graphics/Placeholders";
6 +
7 +export function About() {
8 + const { about } = home;
9 + if (!about.visible) return null;
10 + return (
11 + <section aria-labelledby="about-title" className="bg-ivory-50 relative">
12 + <div className="grid lg:grid-cols-[minmax(0,0.34fr)_minmax(0,0.66fr)]">
13 + {/* Photo + courbe ivoire */}
14 + <div className="relative h-[18rem] overflow-hidden sm:h-[22rem] lg:h-auto lg:min-h-[26rem]">
15 + {about.image.src ? (
16 + <Image
17 + src={about.image.src}
18 + alt={about.image.alt}
19 + fill
20 + sizes="(min-width:1024px) 34vw, 100vw"
21 + className="object-cover"
22 + />
23 + ) : (
24 + <VaseScene className="absolute inset-0 h-full w-full" />
25 + )}
26 + <svg
27 + className="pointer-events-none absolute inset-y-0 right-0 hidden h-full w-[38%] lg:block"
28 + viewBox="0 0 100 300"
29 + preserveAspectRatio="none"
30 + aria-hidden="true"
31 + >
32 + <path
33 + d="M100 0 L100 300 L55 300 C 10 230, 100 160, 55 80 C 35 45, 60 15, 100 0 Z"
34 + fill="var(--color-ivory-50)"
35 + />
36 + <path
37 + d="M58 300 C 14 232, 103 160, 58 80 C 38 46, 62 16, 100 2"
38 + fill="none"
39 + stroke="var(--color-ivory-200)"
40 + strokeWidth="6"
41 + opacity="0.9"
42 + />
43 + </svg>
44 + </div>
45 +
46 + <div className="container-site grid gap-10 py-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.52fr)] lg:gap-6 lg:py-16 lg:pl-6 xl:pl-10">
47 + <div>
48 + <p className="overtitle">{about.overtitle}</p>
49 + <h2 id="about-title" className="text-h3 mt-3">
50 + {about.title}
51 + </h2>
52 + <p className="text-ink-700 mt-5 max-w-[32rem]">{about.paragraph}</p>
53 + <div className="mt-7">
54 + <Button href={about.cta.href} variant="outline" size="lg">
55 + {about.cta.label}
56 + </Button>
57 + </div>
58 + </div>
59 +
60 + <ul
61 + className="border-gold-500/60 flex flex-col gap-4 lg:border-l lg:py-2 lg:pl-7 xl:pl-10"
62 + aria-label="Nos engagements"
63 + >
64 + {about.benefits.map((b) => (
65 + <li
66 + key={b}
67 + className="text-ink-900 flex items-start gap-3 text-[0.9rem] leading-snug"
68 + >
69 + <CheckBadge className="mt-0.5 h-5 w-5 shrink-0" />
70 + <span>{b}</span>
71 + </li>
72 + ))}
73 + </ul>
74 + </div>
75 + </div>
76 + </section>
77 + );
78 +}
added src/components/sections/CallToAction.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import { home } from "@/lib/content";
2 +import { Button } from "@/components/ui/Button";
3 +import { GoldenSpiral } from "@/components/graphics/GoldenSpiral";
4 +import { BotanicalMotif } from "@/components/graphics/BotanicalMotif";
5 +
6 +export function CallToAction() {
7 + const { callToAction: cta } = home;
8 + if (!cta.visible) return null;
9 + return (
10 + <section
11 + aria-labelledby="cta-title"
12 + className="relative isolate overflow-hidden bg-slate-600 text-white"
13 + >
14 + <svg
15 + className="pointer-events-none absolute inset-x-0 -top-px h-10 w-full md:h-16"
16 + viewBox="0 0 1440 80"
17 + preserveAspectRatio="none"
18 + aria-hidden="true"
19 + >
20 + <path
21 + d="M0 0 L1440 0 L1440 10 C 1200 70, 900 70, 660 30 C 420 -5, 200 10, 0 50 Z"
22 + fill="var(--color-ivory-50)"
23 + />
24 + </svg>
25 + <GoldenSpiral
26 + className="text-gold-400/80 pointer-events-none absolute top-[4%] -right-[6%] h-[120%] w-auto max-lg:opacity-40"
27 + strokeWidth={0.1}
28 + flipX
29 + />
30 + <BotanicalMotif className="pointer-events-none absolute -right-2 bottom-0 h-[92%] w-auto max-lg:opacity-30" />
31 + <div className="container-site relative py-16 md:py-20 lg:py-24">
32 + <div className="max-w-[36rem]">
33 + <h2 id="cta-title" className="text-h2 text-white">
34 + {cta.title}
35 + </h2>
36 + <p className="mt-3 text-lg text-white">{cta.subtitle}</p>
37 + <div className="mt-7">
38 + <Button href={cta.cta.href} variant="ivory" size="lg">
39 + {cta.cta.label}
40 + </Button>
41 + </div>
42 + </div>
43 + </div>
44 + </section>
45 + );
46 +}
added src/components/sections/Hero.tsx +55 −0
@@ -0,0 +1,55 @@
1 +import Image from "next/image";
2 +import { home } from "@/lib/content";
3 +import { Button } from "@/components/ui/Button";
4 +import { GoldenSpiral } from "@/components/graphics/GoldenSpiral";
5 +import { WaveDivider } from "@/components/graphics/WaveDivider";
6 +import { DeskScene } from "@/components/graphics/Placeholders";
7 +
8 +export function Hero() {
9 + const { hero } = home;
10 + return (
11 + <section aria-labelledby="hero-title" className="bg-ivory-100 relative isolate overflow-hidden">
12 + <div className="container-site relative grid items-center gap-10 pt-8 pb-24 md:pt-10 md:pb-32 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-4 lg:pt-4 lg:pb-32">
13 + <div className="relative z-10 max-w-[36rem] lg:pt-6">
14 + <h1 id="hero-title" className="text-display">
15 + {hero.titleLines.map((line, i) => (
16 + <span key={i} className="block">
17 + {line}
18 + </span>
19 + ))}
20 + </h1>
21 + <p className="text-ink-700 mt-6 max-w-[30rem] text-[1.05rem]">{hero.subtitle}</p>
22 + <div className="mt-8">
23 + <Button href={hero.cta.href} size="lg">
24 + {hero.cta.label}
25 + </Button>
26 + </div>
27 + </div>
28 +
29 + <div className="relative -mx-[var(--container-pad)] lg:mx-0 lg:-mr-[var(--container-pad)]">
30 + <div className="relative aspect-[16/10] w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_12%,black)] lg:aspect-[1.18]">
31 + {hero.image.src ? (
32 + <Image
33 + src={hero.image.src}
34 + alt={hero.image.alt}
35 + fill
36 + priority
37 + sizes="(min-width:1024px) 50vw, 100vw"
38 + className="object-cover"
39 + />
40 + ) : (
41 + <DeskScene className="absolute inset-0 h-full w-full" />
42 + )}
43 + <GoldenSpiral
44 + className="text-gold-500 pointer-events-none absolute -top-[6%] right-[2%] h-[112%] w-auto opacity-90 drop-shadow-[0_0_1px_rgba(255,255,255,0.6)]"
45 + strokeWidth={0.1}
46 + flipY
47 + />
48 + </div>
49 + </div>
50 + </div>
51 +
52 + <WaveDivider className="absolute inset-x-0 bottom-0 h-20 w-full md:h-28 lg:h-36" />
53 + </section>
54 + );
55 +}
added src/components/sections/Services.tsx +34 −0
@@ -0,0 +1,34 @@
1 +import Link from "next/link";
2 +import { home } from "@/lib/content";
3 +import { SectionTitle } from "@/components/ui/SectionTitle";
4 +import { ServiceIcon } from "@/components/ui/ServiceIcon";
5 +
6 +export function Services() {
7 + const { services } = home;
8 + if (!services.visible) return null;
9 + return (
10 + <section aria-labelledby="services-title" className="section-y bg-ivory-50">
11 + <div className="container-site">
12 + <SectionTitle id="services-title">{services.title}</SectionTitle>
13 + <ul className="mt-10 grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 md:mt-14 lg:grid-cols-4">
14 + {services.items.map((s) => (
15 + <li key={s.slug} className="flex flex-col items-center text-center">
16 + <span className="bg-pale-200 text-navy-800 flex h-[4.5rem] w-[4.5rem] items-center justify-center rounded-full">
17 + <ServiceIcon name={s.icon} className="h-8 w-8" />
18 + </span>
19 + <h3 className="text-h4 mt-6 max-w-[14rem]">
20 + <Link
21 + href={`/services#${s.slug}`}
22 + className="decoration-gold-500 rounded-sm underline-offset-4 hover:underline"
23 + >
24 + {s.title}
25 + </Link>
26 + </h3>
27 + <p className="text-ink-700 mt-3 max-w-[15rem] text-[0.95rem]">{s.summary}</p>
28 + </li>
29 + ))}
30 + </ul>
31 + </div>
32 + </section>
33 + );
34 +}
added src/components/sections/TestimonialSlider.tsx +106 −0
@@ -0,0 +1,106 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useId, useState, useSyncExternalStore } from "react";
4 +import { QuoteMark } from "@/components/ui/Icons";
5 +
6 +type Item = { quote: string; author: string; role: string };
7 +
8 +const INTERVAL_MS = 8000;
9 +const RM_QUERY = "(prefers-reduced-motion: reduce)";
10 +function subscribeReducedMotion(cb: () => void) {
11 + const mq = window.matchMedia(RM_QUERY);
12 + mq.addEventListener("change", cb);
13 + return () => mq.removeEventListener("change", cb);
14 +}
15 +function getReducedMotion() {
16 + return window.matchMedia(RM_QUERY).matches;
17 +}
18 +
19 +export function TestimonialSlider({ items }: { items: Item[] }) {
20 + const [index, setIndex] = useState(0);
21 + const [paused, setPaused] = useState(false);
22 + const reduced = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, () => false);
23 + const regionId = useId();
24 +
25 + const go = useCallback(
26 + (n: number) => setIndex((i) => (i + n + items.length) % items.length),
27 + [items.length],
28 + );
29 +
30 + useEffect(() => {
31 + if (items.length < 2 || paused || reduced) return;
32 + const t = setInterval(() => go(1), INTERVAL_MS);
33 + return () => clearInterval(t);
34 + }, [go, items.length, paused, reduced]);
35 +
36 + const current = items[index];
37 + const multiple = items.length > 1;
38 +
39 + return (
40 + <section
41 + aria-roledescription="carrousel"
42 + aria-label="Témoignages de clients"
43 + className="bg-pale-200 relative rounded-[var(--radius-xl)] px-6 py-8 sm:px-10 md:px-14 md:py-10"
44 + onMouseEnter={() => setPaused(true)}
45 + onMouseLeave={() => setPaused(false)}
46 + onFocusCapture={() => setPaused(true)}
47 + onBlurCapture={(e) => {
48 + if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false);
49 + }}
50 + onKeyDown={(e) => {
51 + if (!multiple) return;
52 + if (e.key === "ArrowRight") go(1);
53 + if (e.key === "ArrowLeft") go(-1);
54 + }}
55 + >
56 + <div className="flex flex-col gap-6 md:flex-row md:items-start md:gap-8">
57 + <QuoteMark className="text-gold-400 h-9 w-9 shrink-0 md:h-11 md:w-11" />
58 + <div id={regionId} aria-live="polite" aria-atomic="true" className="flex-1">
59 + <blockquote>
60 + <p className="text-navy-900 font-serif text-xl leading-relaxed italic md:text-[1.45rem]">
61 + {current.quote}
62 + </p>
63 + <footer className="text-ink-700 mt-5 text-sm">
64 + <span aria-hidden="true">— </span>
65 + <cite className="text-ink-900 font-semibold not-italic">{current.author}</cite>
66 + {current.role ? <span>, {current.role}</span> : null}
67 + </footer>
68 + </blockquote>
69 + </div>
70 +
71 + {multiple ? (
72 + <div
73 + className="flex items-center gap-3 self-end md:self-center"
74 + role="group"
75 + aria-label="Pagination des témoignages"
76 + >
77 + <button
78 + type="button"
79 + onClick={() => setPaused((p) => !p)}
80 + aria-pressed={paused}
81 + className="text-ink-700 rounded-md px-2 py-1 text-xs hover:bg-white/50"
82 + >
83 + {paused ? "Reprendre" : "Pause"}
84 + </button>
85 + <ul className="flex items-center gap-2.5">
86 + {items.map((_, i) => (
87 + <li key={i}>
88 + <button
89 + type="button"
90 + aria-label={`Témoignage ${i + 1} sur ${items.length}`}
91 + aria-current={i === index ? "true" : undefined}
92 + aria-controls={regionId}
93 + onClick={() => setIndex(i)}
94 + className={`block h-2.5 w-2.5 rounded-full transition-colors ${
95 + i === index ? "bg-navy-900" : "bg-pale-400 hover:bg-slate-500"
96 + }`}
97 + />
98 + </li>
99 + ))}
100 + </ul>
101 + </div>
102 + ) : null}
103 + </div>
104 + </section>
105 + );
106 +}
added src/components/sections/Testimonials.tsx +27 −0
@@ -0,0 +1,27 @@
1 +import { displayableTestimonials } from "@/lib/content";
2 +import { TestimonialSlider } from "./TestimonialSlider";
3 +
4 +export function Testimonials() {
5 + const preview = process.env.NEXT_PUBLIC_CONTENT_PREVIEW === "1";
6 + const { items, isPreview } = displayableTestimonials(preview);
7 + if (items.length === 0) return null;
8 + return (
9 + <section aria-labelledby="testimonials-title" className="bg-ivory-50 pt-2 pb-6 md:pb-10">
10 + <div className="container-site">
11 + <h2 id="testimonials-title" className="sr-only">
12 + Témoignages
13 + </h2>
14 + {isPreview ? (
15 + <p
16 + role="status"
17 + className="border-gold-500/60 bg-gold-300/30 text-ink-900 mb-3 rounded-md border px-4 py-2 text-sm"
18 + >
19 + Exemple de maquette (témoignage non vérifié) — sera remplacé par un avis client
20 + autorisé.
21 + </p>
22 + ) : null}
23 + <TestimonialSlider items={items} />
24 + </div>
25 + </section>
26 + );
27 +}
added src/components/ui/Button.tsx +88 −0
@@ -0,0 +1,88 @@
1 +import Link from "next/link";
2 +import type { ComponentPropsWithoutRef, ReactNode } from "react";
3 +
4 +type Variant = "primary" | "ivory" | "outline" | "ghost";
5 +type Size = "md" | "lg";
6 +
7 +const base =
8 + "inline-flex items-center justify-center gap-2 rounded-pill font-medium transition-[background-color,color,box-shadow,transform] duration-[var(--duration-base)] ease-[var(--ease-out)] focus-visible:outline-3 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-60 motion-safe:hover:-translate-y-px";
9 +
10 +const variants: Record<Variant, string> = {
11 + primary: "bg-slate-500 text-white shadow-sm hover:bg-slate-700 focus-visible:outline-navy-900",
12 + ivory: "bg-ivory-200 text-navy-900 hover:bg-ivory-100 focus-visible:outline-white",
13 + outline:
14 + "border border-navy-800/50 bg-transparent text-navy-900 hover:border-navy-900 hover:bg-white/60",
15 + ghost: "text-navy-900 hover:bg-navy-900/5",
16 +};
17 +
18 +const sizes: Record<Size, string> = {
19 + md: "px-5 py-2 text-sm",
20 + lg: "px-6 py-3 text-[0.95rem]",
21 +};
22 +
23 +export function Arrow({ className = "h-4 w-4" }: { className?: string }) {
24 + return (
25 + <svg
26 + className={className}
27 + viewBox="0 0 16 16"
28 + fill="none"
29 + stroke="currentColor"
30 + strokeWidth="1.6"
31 + strokeLinecap="round"
32 + strokeLinejoin="round"
33 + aria-hidden="true"
34 + >
35 + <path d="M2.5 8h11M9 3.5 13.5 8 9 12.5" />
36 + </svg>
37 + );
38 +}
39 +
40 +type CommonProps = {
41 + variant?: Variant;
42 + size?: Size;
43 + arrow?: boolean;
44 + className?: string;
45 + children: ReactNode;
46 +};
47 +
48 +type LinkProps = CommonProps & { href: string } & Omit<
49 + ComponentPropsWithoutRef<typeof Link>,
50 + "href" | "children" | "className"
51 + >;
52 +type ButtonProps = CommonProps & { href?: undefined } & Omit<
53 + ComponentPropsWithoutRef<"button">,
54 + "children" | "className"
55 + >;
56 +
57 +export function Button(props: LinkProps | ButtonProps) {
58 + const {
59 + variant = "primary",
60 + size = "md",
61 + arrow = true,
62 + className = "",
63 + children,
64 + ...rest
65 + } = props;
66 + const cls = `${base} ${variants[variant]} ${sizes[size]} ${className}`;
67 + const content = (
68 + <>
69 + {children}
70 + {arrow ? <Arrow /> : null}
71 + </>
72 + );
73 + if (rest.href !== undefined) {
74 + const { href, ...linkRest } = rest as Omit<LinkProps, keyof CommonProps>;
75 + return (
76 + <Link href={href} className={cls} {...linkRest}>
77 + {content}
78 + </Link>
79 + );
80 + }
81 + const { href: _unused, ...buttonRest } = rest as Omit<ButtonProps, keyof CommonProps>;
82 + void _unused;
83 + return (
84 + <button type="button" className={cls} {...buttonRest}>
85 + {content}
86 + </button>
87 + );
88 +}
added src/components/ui/Icons.tsx +63 −0
@@ -0,0 +1,63 @@
1 +export function LinkedInIcon({ className = "h-5 w-5" }: { className?: string }) {
2 + return (
3 + <svg
4 + className={className}
5 + viewBox="0 0 24 24"
6 + fill="none"
7 + stroke="currentColor"
8 + strokeWidth="1.5"
9 + aria-hidden="true"
10 + focusable="false"
11 + >
12 + <rect x="3" y="3" width="18" height="18" rx="3" />
13 + <path d="M8 10v7M8 7v.5M12 17v-4a2 2 0 0 1 4 0v4M12 10v1" strokeLinecap="round" />
14 + </svg>
15 + );
16 +}
17 +
18 +export function MailIcon({ className = "h-5 w-5" }: { className?: string }) {
19 + return (
20 + <svg
21 + className={className}
22 + viewBox="0 0 24 24"
23 + fill="none"
24 + stroke="currentColor"
25 + strokeWidth="1.5"
26 + aria-hidden="true"
27 + focusable="false"
28 + >
29 + <rect x="3" y="5" width="18" height="14" rx="2.5" />
30 + <path d="m4 7 8 6 8-6" strokeLinecap="round" strokeLinejoin="round" />
31 + </svg>
32 + );
33 +}
34 +
35 +export function CheckBadge({ className = "h-5 w-5" }: { className?: string }) {
36 + return (
37 + <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
38 + <circle cx="12" cy="12" r="11" fill="var(--color-gold-400)" />
39 + <path
40 + d="m7.5 12.3 3 3 6-6.5"
41 + fill="none"
42 + stroke="#fff"
43 + strokeWidth="2"
44 + strokeLinecap="round"
45 + strokeLinejoin="round"
46 + />
47 + </svg>
48 + );
49 +}
50 +
51 +export function QuoteMark({ className = "h-10 w-10" }: { className?: string }) {
52 + return (
53 + <svg
54 + className={className}
55 + viewBox="0 0 48 40"
56 + fill="currentColor"
57 + aria-hidden="true"
58 + focusable="false"
59 + >
60 + <path d="M10.5 40C4.7 40 0 35.3 0 29.5c0-2 .5-4 1.4-5.8L12.6 0h10.2L15 17.6c4.3 1.6 7.2 5.7 7.2 10.4C22.2 34.7 17 40 10.5 40Zm25.8 0C30.5 40 25.8 35.3 25.8 29.5c0-2 .5-4 1.4-5.8L38.4 0h10.2L40.8 17.6c4.3 1.6 7.2 5.7 7.2 10.4C48 34.7 42.8 40 36.3 40Z" />
61 + </svg>
62 + );
63 +}
added src/components/ui/SectionTitle.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import type { ReactNode } from "react";
2 +
3 +/** Titre centré entre deux fins filets dorés (section « Nos services »). */
4 +export function SectionTitle({
5 + children,
6 + as: Tag = "h2",
7 + id,
8 +}: {
9 + children: ReactNode;
10 + as?: "h1" | "h2";
11 + id?: string;
12 +}) {
13 + return (
14 + <div className="flex items-center gap-5 md:gap-8">
15 + <span aria-hidden="true" className="bg-gold-500/70 h-px flex-1" />
16 + <Tag id={id} className="text-h2 shrink-0 text-center">
17 + {children}
18 + </Tag>
19 + <span aria-hidden="true" className="bg-gold-500/70 h-px flex-1" />
20 + </div>
21 + );
22 +}
added src/components/ui/ServiceIcon.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import type { ServiceIcon as IconName } from "@/lib/content";
2 +
3 +const paths: Record<IconName, React.ReactNode> = {
4 + search: (
5 + <>
6 + <circle cx="11" cy="11" r="6.5" />
7 + <path d="m16 16 5 5" />
8 + <path d="M8.5 11a2.5 2.5 0 0 1 2.5-2.5" />
9 + </>
10 + ),
11 + "document-edit": (
12 + <>
13 + <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-9" />
14 + <path d="M14 3v5h5" />
15 + <path d="M8.5 13h4M8.5 16.5h3" />
16 + <path d="m19.5 9.5-6 6-1.5 3 3-1.5 6-6a1.06 1.06 0 0 0-1.5-1.5Z" />
17 + </>
18 + ),
19 + chart: (
20 + <>
21 + <path d="M4 20h16" />
22 + <path d="M6 17v-5M11 17V9M16 17v-3" />
23 + <path d="m5 10 5-4 4 3 6-5" />
24 + <path d="M17 4h3v3" />
25 + </>
26 + ),
27 + "check-circle": (
28 + <>
29 + <circle cx="12" cy="12" r="8.5" />
30 + <path d="m8.5 12.2 2.4 2.4 4.8-5" />
31 + </>
32 + ),
33 +};
34 +
35 +export function ServiceIcon({
36 + name,
37 + className = "h-7 w-7",
38 +}: {
39 + name: IconName;
40 + className?: string;
41 +}) {
42 + return (
43 + <svg
44 + className={className}
45 + viewBox="0 0 24 24"
46 + fill="none"
47 + stroke="currentColor"
48 + strokeWidth="1.5"
49 + strokeLinecap="round"
50 + strokeLinejoin="round"
51 + aria-hidden="true"
52 + focusable="false"
53 + >
54 + {paths[name]}
55 + </svg>
56 + );
57 +}
added src/lib/content.ts +184 −0
@@ -0,0 +1,184 @@
1 +import { z } from "zod";
2 +import siteJson from "../../content/site.json";
3 +import homeJson from "../../content/home.json";
4 +import pagesJson from "../../content/pages.json";
5 +
6 +/**
7 + * Unique point d'accès au contenu administrable.
8 + * Les fichiers JSON de `content/` sont validés au chargement : une erreur de
9 + * saisie (champ manquant, type incorrect) fait échouer le build plutôt que de
10 + * publier une page cassée.
11 + *
12 + * Pour brancher un CMS (Payload, Sanity…), remplacer les imports JSON par des
13 + * appels à l'API du CMS en conservant les schémas ci-dessous.
14 + */
15 +
16 +const link = z.object({
17 + label: z.string().min(1),
18 + href: z.string().min(1),
19 +});
20 +
21 +const image = z.object({
22 + src: z.string(), // vide = illustration vectorielle de remplacement
23 + alt: z.string().min(1),
24 +});
25 +
26 +export const serviceIconSchema = z.enum(["search", "document-edit", "chart", "check-circle"]);
27 +export type ServiceIcon = z.infer<typeof serviceIconSchema>;
28 +
29 +export const siteSchema = z.object({
30 + brand: z.object({
31 + name: z.string().min(1),
32 + tagline: z.string().min(1),
33 + footerSignature: z.string().min(1),
34 + legalName: z.string().min(1),
35 + }),
36 + navigation: z.array(link).min(1),
37 + headerCta: link,
38 + contact: z.object({
39 + email: z.union([z.literal(""), z.email()]),
40 + phone: z.string(),
41 + address: z.string(),
42 + region: z.string(),
43 + }),
44 + social: z.object({
45 + linkedin: z.union([z.literal(""), z.url()]),
46 + }),
47 + legalLinks: z.array(link),
48 + seo: z.object({
49 + siteUrl: z.url(),
50 + defaultTitle: z.string().min(1),
51 + titleTemplate: z.string().includes("%s"),
52 + defaultDescription: z.string().min(1),
53 + locale: z.string().min(2),
54 + }),
55 +});
56 +
57 +export const homeSchema = z.object({
58 + hero: z.object({
59 + titleLines: z.array(z.string().min(1)).min(1).max(4),
60 + subtitle: z.string().min(1),
61 + cta: link,
62 + image,
63 + }),
64 + services: z.object({
65 + visible: z.boolean(),
66 + title: z.string().min(1),
67 + items: z
68 + .array(
69 + z.object({
70 + slug: z.string().regex(/^[a-z0-9-]+$/),
71 + icon: serviceIconSchema,
72 + title: z.string().min(1),
73 + summary: z.string().min(1),
74 + description: z.string(),
75 + }),
76 + )
77 + .min(1),
78 + }),
79 + about: z.object({
80 + visible: z.boolean(),
81 + overtitle: z.string().min(1),
82 + title: z.string().min(1),
83 + paragraph: z.string().min(1),
84 + cta: link,
85 + benefits: z.array(z.string().min(1)).min(1),
86 + image,
87 + }),
88 + testimonials: z.object({
89 + visible: z.boolean(),
90 + previewOnly: z.boolean(),
91 + items: z.array(
92 + z.object({
93 + quote: z.string().min(1),
94 + author: z.string().min(1),
95 + role: z.string(),
96 + approved: z.boolean(),
97 + }),
98 + ),
99 + }),
100 + callToAction: z.object({
101 + visible: z.boolean(),
102 + title: z.string().min(1),
103 + subtitle: z.string().min(1),
104 + cta: link,
105 + }),
106 +});
107 +
108 +export const pagesSchema = z.object({
109 + services: z.object({
110 + title: z.string().min(1),
111 + intro: z.string().min(1),
112 + seoDescription: z.string().min(1),
113 + }),
114 + about: z.object({
115 + title: z.string().min(1),
116 + intro: z.string().min(1),
117 + seoDescription: z.string().min(1),
118 + body: z.array(z.string().min(1)),
119 + }),
120 + testimonials: z.object({
121 + title: z.string().min(1),
122 + intro: z.string().min(1),
123 + emptyMessage: z.string().min(1),
124 + seoDescription: z.string().min(1),
125 + }),
126 + contact: z.object({
127 + title: z.string().min(1),
128 + intro: z.string().min(1),
129 + seoDescription: z.string().min(1),
130 + subjects: z.array(z.string().min(1)).min(1),
131 + consentLabel: z.string().min(1),
132 + successTitle: z.string().min(1),
133 + successMessage: z.string().min(1),
134 + }),
135 + privacy: z.object({
136 + title: z.string().min(1),
137 + seoDescription: z.string().min(1),
138 + lastUpdated: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
139 + reviewed: z.boolean(),
140 + }),
141 +});
142 +
143 +export type SiteContent = z.infer<typeof siteSchema>;
144 +export type HomeContent = z.infer<typeof homeSchema>;
145 +export type PagesContent = z.infer<typeof pagesSchema>;
146 +
147 +function parse<T>(schema: z.ZodType<T>, data: unknown, file: string): T {
148 + const result = schema.safeParse(data);
149 + if (!result.success) {
150 + const issues = result.error.issues
151 + .map((i) => ` - ${i.path.join(".") || "(racine)"}: ${i.message}`)
152 + .join("\n");
153 + throw new Error(`Contenu invalide dans content/${file}:\n${issues}`);
154 + }
155 + return result.data;
156 +}
157 +
158 +export const site: SiteContent = parse(siteSchema, siteJson, "site.json");
159 +export const home: HomeContent = parse(homeSchema, homeJson, "home.json");
160 +export const pages: PagesContent = parse(pagesSchema, pagesJson, "pages.json");
161 +
162 +/** Témoignages publiables : section visible ET avis approuvés uniquement. */
163 +export function publishedTestimonials(): HomeContent["testimonials"]["items"] {
164 + if (!home.testimonials.visible) return [];
165 + return home.testimonials.items.filter((t) => t.approved);
166 +}
167 +
168 +/**
169 + * Témoignages à afficher : en production, uniquement les avis approuvés ;
170 + * en prévisualisation (NEXT_PUBLIC_CONTENT_PREVIEW=1), les exemples non
171 + * vérifiés sont montrés avec un bandeau explicite.
172 + */
173 +export function displayableTestimonials(preview: boolean) {
174 + const approved = publishedTestimonials();
175 + if (approved.length > 0) return { items: approved, isPreview: false };
176 + if (preview && home.testimonials.previewOnly) {
177 + return { items: home.testimonials.items, isPreview: true };
178 + }
179 + return { items: [], isPreview: false };
180 +}
181 +
182 +export function serviceBySlug(slug: string) {
183 + return home.services.items.find((s) => s.slug === slug);
184 +}
added src/lib/env.ts +62 −0
@@ -0,0 +1,62 @@
1 +import { z } from "zod";
2 +
3 +/**
4 + * Variables d'environnement serveur, validées au premier accès.
5 + * Aucune n'est exposée au navigateur (pas de préfixe NEXT_PUBLIC_ sauf
6 + * NEXT_PUBLIC_SITE_URL et NEXT_PUBLIC_CONTENT_PREVIEW, non sensibles).
7 + */
8 +const schema = z.object({
9 + NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
10 + NEXT_PUBLIC_SITE_URL: z.url().optional(),
11 + NEXT_PUBLIC_CONTENT_PREVIEW: z.enum(["0", "1"]).default("0"),
12 +
13 + /** `smtp` = envoi réel ; `log` = journalisation locale uniquement (dev/CI). */
14 + CONTACT_TRANSPORT: z.enum(["smtp", "log"]).default("log"),
15 + CONTACT_TO: z.email().optional(),
16 + CONTACT_FROM: z.email().optional(),
17 + SMTP_HOST: z.string().optional(),
18 + SMTP_PORT: z.coerce.number().int().positive().optional(),
19 + SMTP_SECURE: z
20 + .enum(["true", "false"])
21 + .default("false")
22 + .transform((v) => v === "true"),
23 + SMTP_USER: z.string().optional(),
24 + SMTP_PASS: z.string().optional(),
25 +
26 + /** Limitation de débit du formulaire : N requêtes par fenêtre (secondes). */
27 + CONTACT_RATE_LIMIT: z.coerce.number().int().positive().default(5),
28 + CONTACT_RATE_WINDOW_S: z.coerce.number().int().positive().default(600),
29 +});
30 +
31 +export type Env = z.infer<typeof schema>;
32 +
33 +let cached: Env | null = null;
34 +
35 +export function getEnv(): Env {
36 + if (cached) return cached;
37 + const parsed = schema.safeParse(process.env);
38 + if (!parsed.success) {
39 + const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
40 + throw new Error(`Variables d'environnement invalides : ${issues}`);
41 + }
42 + const env = parsed.data;
43 + if (env.CONTACT_TRANSPORT === "smtp") {
44 + const missing = (["CONTACT_TO", "CONTACT_FROM", "SMTP_HOST", "SMTP_PORT"] as const).filter(
45 + (k) => !env[k],
46 + );
47 + if (missing.length) {
48 + throw new Error(`CONTACT_TRANSPORT=smtp requiert : ${missing.join(", ")}`);
49 + }
50 + }
51 + cached = env;
52 + return env;
53 +}
54 +
55 +/** Pour les tests : vider le cache après modification de process.env. */
56 +export function resetEnvCache() {
57 + cached = null;
58 +}
59 +
60 +export function siteUrl(fallback: string): string {
61 + return process.env.NEXT_PUBLIC_SITE_URL ?? fallback;
62 +}
added src/lib/mail.ts +85 −0
@@ -0,0 +1,85 @@
1 +import nodemailer from "nodemailer";
2 +import type { ContactData } from "./validation";
3 +import { getEnv } from "./env";
4 +
5 +export interface Mailer {
6 + send(data: ContactData, meta: { ipHash: string }): Promise<{ delivered: boolean; id?: string }>;
7 +}
8 +
9 +function escapeHtml(s: string) {
10 + return s
11 + .replace(/&/g, "&amp;")
12 + .replace(/</g, "&lt;")
13 + .replace(/>/g, "&gt;")
14 + .replace(/"/g, "&quot;");
15 +}
16 +
17 +export function renderContactEmail(data: ContactData) {
18 + const text = [
19 + `Nouveau message depuis le site PHI Conseil`,
20 + ``,
21 + `Nom : ${data.name}`,
22 + `Courriel : ${data.email}`,
23 + `Organisation : ${data.organization || "—"}`,
24 + `Sujet : ${data.subject}`,
25 + ``,
26 + data.message,
27 + ].join("\n");
28 + const html = `<!doctype html><html lang="fr"><body style="font-family:system-ui,sans-serif;color:#1f2a33">
29 +<h2 style="font-weight:600">Nouveau message depuis le site PHI Conseil</h2>
30 +<table cellpadding="4">
31 +<tr><td><strong>Nom</strong></td><td>${escapeHtml(data.name)}</td></tr>
32 +<tr><td><strong>Courriel</strong></td><td>${escapeHtml(data.email)}</td></tr>
33 +<tr><td><strong>Organisation</strong></td><td>${escapeHtml(data.organization || "—")}</td></tr>
34 +<tr><td><strong>Sujet</strong></td><td>${escapeHtml(data.subject)}</td></tr>
35 +</table>
36 +<p style="white-space:pre-wrap">${escapeHtml(data.message)}</p>
37 +</body></html>`;
38 + return { subject: `[Site PHI Conseil] ${data.subject}`, text, html };
39 +}
40 +
41 +/** Transport « log » : n'envoie rien, journalise une empreinte. Jamais présenté comme une livraison. */
42 +export const logMailer: Mailer = {
43 + async send(data, meta) {
44 + console.info(
45 + `[contact] CONTACT_TRANSPORT=log — courriel NON envoyé (sujet="${data.subject}", ip=${meta.ipHash})`,
46 + );
47 + return { delivered: false };
48 + },
49 +};
50 +
51 +export function createSmtpMailer(): Mailer {
52 + const env = getEnv();
53 + const transporter = nodemailer.createTransport({
54 + host: env.SMTP_HOST,
55 + port: env.SMTP_PORT,
56 + secure: env.SMTP_SECURE,
57 + auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
58 + });
59 + return {
60 + async send(data) {
61 + const { subject, text, html } = renderContactEmail(data);
62 + const info = await transporter.sendMail({
63 + from: env.CONTACT_FROM,
64 + to: env.CONTACT_TO,
65 + replyTo: data.email,
66 + subject,
67 + text,
68 + html,
69 + });
70 + return { delivered: true, id: info.messageId };
71 + },
72 + };
73 +}
74 +
75 +let instance: Mailer | null = null;
76 +export function getMailer(): Mailer {
77 + if (instance) return instance;
78 + instance = getEnv().CONTACT_TRANSPORT === "smtp" ? createSmtpMailer() : logMailer;
79 + return instance;
80 +}
81 +
82 +/** Pour les tests. */
83 +export function setMailerForTests(m: Mailer | null) {
84 + instance = m;
85 +}
added src/lib/security.ts +93 −0
@@ -0,0 +1,93 @@
1 +import { createHash } from "node:crypto";
2 +
3 +/**
4 + * Limitation de débit en mémoire (fenêtre glissante) — adaptée à une instance
5 + * unique (PM2 fork / Node). Pour plusieurs instances, remplacer par un store
6 + * partagé (Redis) en conservant l'interface `RateLimiter`.
7 + */
8 +export interface RateLimiter {
9 + check(key: string, now?: number): { allowed: boolean; retryAfterS: number };
10 +}
11 +
12 +export function createMemoryRateLimiter(limit: number, windowS: number): RateLimiter {
13 + const hits = new Map<string, number[]>();
14 + const windowMs = windowS * 1000;
15 + let lastSweep = 0;
16 + return {
17 + check(key, now = Date.now()) {
18 + if (now - lastSweep > windowMs) {
19 + for (const [k, arr] of hits) {
20 + const kept = arr.filter((t) => now - t < windowMs);
21 + if (kept.length) hits.set(k, kept);
22 + else hits.delete(k);
23 + }
24 + lastSweep = now;
25 + }
26 + const arr = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
27 + if (arr.length >= limit) {
28 + const retryAfterS = Math.ceil((arr[0] + windowMs - now) / 1000);
29 + hits.set(key, arr);
30 + return { allowed: false, retryAfterS: Math.max(1, retryAfterS) };
31 + }
32 + arr.push(now);
33 + hits.set(key, arr);
34 + return { allowed: true, retryAfterS: 0 };
35 + },
36 + };
37 +}
38 +
39 +/** IP cliente derrière un reverse proxy de confiance (Caddy / nginx). */
40 +export function clientIp(headers: Headers): string {
41 + const fwd = headers.get("x-forwarded-for");
42 + if (fwd) return fwd.split(",")[0].trim();
43 + return headers.get("x-real-ip") ?? "unknown";
44 +}
45 +
46 +/** Empreinte non réversible pour la journalisation (pas de donnée personnelle en clair). */
47 +export function fingerprint(value: string): string {
48 + return createHash("sha256").update(value).digest("hex").slice(0, 12);
49 +}
50 +
51 +/** Dédoublonnage court : même expéditeur + même message dans la fenêtre. */
52 +export function createDedupe(windowS: number) {
53 + const seen = new Map<string, number>();
54 + return {
55 + isDuplicate(key: string, now = Date.now()): boolean {
56 + for (const [k, t] of seen) if (now - t > windowS * 1000) seen.delete(k);
57 + if (seen.has(key)) return true;
58 + seen.set(key, now);
59 + return false;
60 + },
61 + };
62 +}
63 +
64 +/** Politique de sécurité de contenu (CSP) du site. */
65 +export function contentSecurityPolicy(): string {
66 + return [
67 + "default-src 'self'",
68 + "script-src 'self' 'unsafe-inline'", // inline requis par l'hydratation Next (scripts d'amorçage)
69 + "style-src 'self' 'unsafe-inline'",
70 + "img-src 'self' data: blob:",
71 + "font-src 'self' data:",
72 + "connect-src 'self'",
73 + "frame-ancestors 'none'",
74 + "base-uri 'self'",
75 + "form-action 'self'",
76 + "object-src 'none'",
77 + ].join("; ");
78 +}
79 +
80 +export const securityHeaders = [
81 + { key: "Content-Security-Policy", value: contentSecurityPolicy() },
82 + { key: "X-Content-Type-Options", value: "nosniff" },
83 + { key: "X-Frame-Options", value: "DENY" },
84 + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
85 + {
86 + key: "Permissions-Policy",
87 + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
88 + },
89 + {
90 + key: "Strict-Transport-Security",
91 + value: "max-age=31536000; includeSubDomains",
92 + },
93 +];
added src/lib/seo.ts +39 −0
@@ -0,0 +1,39 @@
1 +import type { Metadata } from "next";
2 +import { site } from "./content";
3 +import { siteUrl } from "./env";
4 +
5 +export function baseUrl() {
6 + return siteUrl(site.seo.siteUrl).replace(/\/$/, "");
7 +}
8 +
9 +export function pageMetadata(opts: {
10 + title: string;
11 + description: string;
12 + path: string;
13 + noIndex?: boolean;
14 +}): Metadata {
15 + const url = `${baseUrl()}${opts.path}`;
16 + return {
17 + title: opts.title,
18 + description: opts.description,
19 + alternates: { canonical: url },
20 + openGraph: { title: opts.title, description: opts.description, url, type: "website" },
21 + robots: opts.noIndex ? { index: false, follow: false } : undefined,
22 + };
23 +}
24 +
25 +/** Données structurées minimales : aucune adresse/avis inventé (champs vides omis). */
26 +export function organizationJsonLd() {
27 + const data: Record<string, unknown> = {
28 + "@context": "https://schema.org",
29 + "@type": "ProfessionalService",
30 + name: site.brand.name,
31 + url: baseUrl(),
32 + description: site.seo.defaultDescription,
33 + areaServed: site.contact.region || undefined,
34 + email: site.contact.email || undefined,
35 + telephone: site.contact.phone || undefined,
36 + sameAs: site.social.linkedin ? [site.social.linkedin] : undefined,
37 + };
38 + return Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
39 +}
added src/lib/spiral.ts +81 −0
@@ -0,0 +1,81 @@
1 +/**
2 + * Géométrie de la spirale de Fibonacci (pure, sans React) — partagée par le
3 + * composant <GoldenSpiral /> et le script de génération des icônes.
4 + */
5 +type Square = { x: number; y: number; s: number };
6 +type Dir = "right" | "down" | "left" | "up";
7 +const DIRS: Dir[] = ["right", "down", "left", "up"];
8 +
9 +export function buildSpiral(levels = 8) {
10 + const fib = [1, 1];
11 + while (fib.length < levels) fib.push(fib[fib.length - 1] + fib[fib.length - 2]);
12 +
13 + const squares: Square[] = [{ x: 0, y: 0, s: 1 }];
14 + const dirs: Dir[] = ["right"]; // direction "virtuelle" du premier carré
15 + let bbox = { x: 0, y: 0, w: 1, h: 1 };
16 +
17 + for (let i = 1; i < fib.length; i++) {
18 + const s = fib[i];
19 + const d = DIRS[(i - 1) % 4];
20 + let sq: Square;
21 + if (d === "right") sq = { x: bbox.x + bbox.w, y: bbox.y, s };
22 + else if (d === "down") sq = { x: bbox.x, y: bbox.y + bbox.h, s };
23 + else if (d === "left") sq = { x: bbox.x - s, y: bbox.y, s };
24 + else sq = { x: bbox.x, y: bbox.y - s, s };
25 + squares.push(sq);
26 + dirs.push(d);
27 + const x0 = Math.min(bbox.x, sq.x);
28 + const y0 = Math.min(bbox.y, sq.y);
29 + const x1 = Math.max(bbox.x + bbox.w, sq.x + sq.s);
30 + const y1 = Math.max(bbox.y + bbox.h, sq.y + sq.s);
31 + bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
32 + }
33 +
34 + // Centre de l'arc d'un carré = coin situé du côté du carré précédent
35 + // (opposé à sa direction) et du côté de la direction suivante.
36 + const opposite: Record<Dir, Dir> = {
37 + right: "left",
38 + left: "right",
39 + up: "down",
40 + down: "up",
41 + };
42 + const corner = (sq: Square, a: Dir, b: Dir) => {
43 + const x = a === "left" || b === "left" ? sq.x : sq.x + sq.s;
44 + const y = a === "up" || b === "up" ? sq.y : sq.y + sq.s;
45 + return { x, y };
46 + };
47 +
48 + const centers = squares.map((sq, i) => {
49 + if (i === 0) return { x: sq.x, y: sq.y }; // coin haut-gauche du 1er carré
50 + const next = i + 1 < dirs.length ? dirs[i + 1] : DIRS[i % 4];
51 + return corner(sq, opposite[dirs[i]], next);
52 + });
53 +
54 + let d = "";
55 + let prev: { x: number; y: number } | null = null;
56 + squares.forEach((sq, i) => {
57 + const c = centers[i];
58 + // Les deux extrémités possibles de l'arc : à distance s sur chaque axe.
59 + const dx = c.x === sq.x ? sq.x + sq.s : sq.x - 0; // extrémité horizontale
60 + const dy = c.y === sq.y ? sq.y + sq.s : sq.y - 0;
61 + const pH = { x: c.x === sq.x ? dx : sq.x, y: c.y };
62 + const pV = { x: c.x, y: c.y === sq.y ? dy : sq.y };
63 + let from = pV;
64 + let to = pH;
65 + if (prev) {
66 + const same = (p: { x: number; y: number }) =>
67 + Math.abs(p.x - prev!.x) < 1e-9 && Math.abs(p.y - prev!.y) < 1e-9;
68 + if (same(pH)) {
69 + from = pH;
70 + to = pV;
71 + }
72 + }
73 + const cross = (from.x - c.x) * (to.y - c.y) - (from.y - c.y) * (to.x - c.x);
74 + const sweep = cross > 0 ? 1 : 0;
75 + if (!prev) d += `M ${from.x} ${from.y} `;
76 + d += `A ${sq.s} ${sq.s} 0 0 ${sweep} ${to.x} ${to.y} `;
77 + prev = to;
78 + });
79 +
80 + return { squares, path: d.trim(), bbox };
81 +}
added src/lib/time.ts +8 −0
@@ -0,0 +1,8 @@
1 +import { cacheLife } from "next/cache";
2 +
3 +/** Année courante, prérendue et revalidée quotidiennement (copyright dynamique). */
4 +export async function currentYear(): Promise<number> {
5 + "use cache";
6 + cacheLife("days");
7 + return new Date().getFullYear();
8 +}
added src/lib/validation.ts +64 −0
@@ -0,0 +1,64 @@
1 +import { z } from "zod";
2 +
3 +/** Supprime les caractères de contrôle et normalise les espaces. */
4 +export function cleanText(value: string): string {
5 + return value
6 + .normalize("NFC")
7 + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
8 + .replace(/[ \t]+/g, " ")
9 + .trim();
10 +}
11 +
12 +/** Une seule ligne : empêche l'injection d'en-têtes dans les courriels. */
13 +const singleLine = (max: number) =>
14 + z
15 + .string()
16 + .transform(cleanText)
17 + .pipe(
18 + z
19 + .string()
20 + .min(1, "Ce champ est requis.")
21 + .max(max, `Maximum ${max} caractères.`)
22 + .refine((v) => !/[\r\n]/.test(v), "Les retours à la ligne ne sont pas permis."),
23 + );
24 +
25 +export const contactSchema = z.object({
26 + name: singleLine(120),
27 + email: z
28 + .string()
29 + .transform((v) => cleanText(v).toLowerCase())
30 + .pipe(z.email("Adresse courriel invalide.").max(254)),
31 + organization: z
32 + .string()
33 + .optional()
34 + .default("")
35 + .transform(cleanText)
36 + .pipe(z.string().max(160, "Maximum 160 caractères.")),
37 + subject: singleLine(160),
38 + message: z
39 + .string()
40 + .transform((v) => v.normalize("NFC").replace(/\r\n?/g, "\n").trim())
41 + .pipe(
42 + z
43 + .string()
44 + .min(20, "Décrivez votre demande en au moins 20 caractères.")
45 + .max(4000, "Maximum 4 000 caractères."),
46 + ),
47 + consent: z.literal(true, {
48 + error: "Votre consentement est requis pour traiter la demande.",
49 + }),
50 + /** Honeypot : doit rester vide (champ invisible pour les humains). */
51 + website: z.string().max(0).optional().default(""),
52 + /** Horodatage du rendu du formulaire : rejette les soumissions instantanées. */
53 + startedAt: z.coerce.number().int().nonnegative().optional(),
54 +});
55 +
56 +export type ContactInput = z.input<typeof contactSchema>;
57 +export type ContactData = z.output<typeof contactSchema>;
58 +
59 +/** Schéma côté client (sans honeypot/horodatage, consentement booléen). */
60 +export const contactFormSchema = contactSchema.omit({
61 + website: true,
62 + startedAt: true,
63 +});
64 +export type ContactFormValues = z.input<typeof contactFormSchema>;
added src/styles/tokens.css +100 −0
@@ -0,0 +1,100 @@
1 +/*
2 + * Design tokens — PHI Conseil
3 + * Couleurs échantillonnées sur references/phi-conseil-homepage.jpeg (2026-11-08).
4 + * Point d'entrée unique : ne pas coder de valeur brute dans les composants.
5 + */
6 +:root {
7 + /* Couleurs */
8 + --color-ivory-50: #faf7f1; /* fond des sections (services, à propos, footer) */
9 + --color-ivory-100: #f5eee4; /* fond du hero */
10 + --color-ivory-200: #f0e4d2; /* vague ivoire, bouton bandeau */
11 + --color-ivory-300: #eddcc8;
12 +
13 + --color-pale-100: #e6edf3;
14 + --color-pale-200: #d7e0e7; /* encadré témoignage, disques services */
15 + --color-pale-300: #c4cdd6; /* vague bleu pâle */
16 + --color-pale-400: #b7c1ca; /* points de pagination inactifs */
17 +
18 + --color-slate-500: #597992; /* boutons bleus */
19 + --color-slate-600: #55738f; /* bandeau final */
20 + --color-slate-700: #4a657f;
21 +
22 + --color-navy-800: #233a52;
23 + --color-navy-900: #1d3047; /* titres */
24 +
25 + --color-gold-300: #e3cfae;
26 + --color-gold-400: #d2b991; /* coches, guillemets */
27 + --color-gold-500: #c7a075; /* spirale, filets */
28 + --color-gold-600: #bea177; /* filets, motifs */
29 + --color-gold-700: #7c5e34; /* texte doré lisible (AA sur ivoire) */
30 +
31 + --color-ink-900: #1f2a33; /* texte courant fort */
32 + --color-ink-700: #3a4652; /* texte courant */
33 + --color-ink-500: #5b6771; /* texte secondaire */
34 + --color-ink-300: #9aa4ad;
35 +
36 + --color-white: #ffffff;
37 + --color-danger: #a23b3b;
38 + --color-success: #2f6b4f;
39 +
40 + /* Typographie */
41 + --font-serif: var(--font-eb-garamond), "Iowan Old Style", Georgia, serif;
42 + --font-sans: var(--font-figtree), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
43 +
44 + --text-xs: 0.75rem;
45 + --text-sm: 0.875rem;
46 + --text-base: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem);
47 + --text-lg: clamp(1.0625rem, 1rem + 0.35vw, 1.25rem);
48 + --text-xl: clamp(1.15rem, 1.05rem + 0.4vw, 1.35rem);
49 + --text-2xl: clamp(1.5rem, 1.3rem + 0.9vw, 1.875rem);
50 + --text-3xl: clamp(1.75rem, 1.3rem + 1.1vw, 2.25rem);
51 + --text-4xl: clamp(2rem, 1.55rem + 1.7vw, 2.75rem);
52 + --text-display: clamp(2.5rem, 1.55rem + 3.3vw, 4.25rem);
53 +
54 + --leading-tight: 1.05;
55 + --leading-snug: 1.2;
56 + --leading-normal: 1.55;
57 + --tracking-caps: 0.14em;
58 +
59 + /* Espacements */
60 + --space-1: 0.25rem;
61 + --space-2: 0.5rem;
62 + --space-3: 0.75rem;
63 + --space-4: 1rem;
64 + --space-5: 1.25rem;
65 + --space-6: 1.5rem;
66 + --space-8: 2rem;
67 + --space-10: 2.5rem;
68 + --space-12: 3rem;
69 + --space-16: 4rem;
70 + --space-20: 5rem;
71 + --space-24: 6rem;
72 + --section-y: clamp(3rem, 2rem + 4vw, 5.5rem);
73 + --container-max: 1180px;
74 + --container-pad: clamp(1.25rem, 4vw, 3rem);
75 +
76 + /* Rayons */
77 + --radius-sm: 0.375rem;
78 + --radius-md: 0.75rem;
79 + --radius-lg: 1.25rem;
80 + --radius-xl: 1.75rem;
81 + --radius-pill: 999px;
82 +
83 + /* Ombres */
84 + --shadow-sm: 0 1px 2px rgba(29, 48, 71, 0.06);
85 + --shadow-md: 0 6px 18px rgba(29, 48, 71, 0.1);
86 + --shadow-lg: 0 18px 40px rgba(29, 48, 71, 0.14);
87 +
88 + /* Mouvement */
89 + --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
90 + --duration-fast: 150ms;
91 + --duration-base: 250ms;
92 + --duration-slow: 450ms;
93 +
94 + /* Points de rupture (référence, Tailwind utilise les siens) */
95 + --bp-sm: 640px;
96 + --bp-md: 768px;
97 + --bp-lg: 1024px;
98 + --bp-xl: 1280px;
99 + --bp-2xl: 1536px;
100 +}
added tests/e2e/a11y.spec.ts +25 −0
@@ -0,0 +1,25 @@
1 +import { test, expect } from "@playwright/test";
2 +import AxeBuilder from "@axe-core/playwright";
3 +
4 +for (const r of [
5 + "/",
6 + "/services",
7 + "/a-propos",
8 + "/temoignages",
9 + "/nous-joindre",
10 + "/confidentialite",
11 +]) {
12 + test(`axe : aucune violation sérieuse ou critique sur ${r}`, async ({ page }) => {
13 + await page.goto(r);
14 + const results = await new AxeBuilder({ page })
15 + .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
16 + .analyze();
17 + const serious = results.violations.filter(
18 + (v) => v.impact === "serious" || v.impact === "critical",
19 + );
20 + expect(
21 + serious.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`),
22 + ).toEqual([]);
23 + expect(results.violations.map((v) => v.id)).toEqual([]);
24 + });
25 +}
added tests/e2e/contact.spec.ts +40 −0
@@ -0,0 +1,40 @@
1 +import { test, expect } from "@playwright/test";
2 +
3 +test.describe("formulaire de contact", () => {
4 + test("affiche les erreurs associées aux champs", async ({ page }) => {
5 + await page.goto("/nous-joindre");
6 + await page.getByRole("button", { name: /Envoyer le message/ }).click();
7 + const alerts = page.getByRole("alert");
8 + await expect(alerts.first()).toBeVisible();
9 + const name = page.getByLabel(/^Nom/);
10 + await expect(name).toHaveAttribute("aria-invalid", "true");
11 + const described = await name.getAttribute("aria-describedby");
12 + expect(described).toBeTruthy();
13 + await expect(page.locator(`#${described}`)).toBeVisible();
14 + });
15 +
16 + test("soumission valide → état de confirmation (transport log, aucun courriel réel)", async ({
17 + page,
18 + }) => {
19 + await page.goto("/nous-joindre");
20 + await page.getByLabel(/^Nom/).fill("Test Playwright");
21 + await page.getByLabel(/^Courriel/).fill("test@exemple.com");
22 + await page.getByLabel(/^Sujet/).selectOption({ index: 1 });
23 + await page
24 + .getByLabel(/^Message/)
25 + .fill("Ceci est un message de test automatisé d’au moins vingt caractères.");
26 + await page.getByLabel(/J’accepte|J'accepte/).check();
27 + await page.waitForTimeout(2600); // délai anti-robot minimal
28 + await page.getByRole("button", { name: /Envoyer le message/ }).click();
29 + const status = page.getByRole("status");
30 + await expect(status).toContainText("Message envoyé");
31 + await expect(status).toContainText("pas encore configuré"); // honnêteté : transport log
32 + });
33 +
34 + test("l'API refuse une requête malformée", async ({ request }) => {
35 + const res = await request.post("/api/contact", { data: { name: "" } });
36 + expect(res.status()).toBe(400);
37 + const body = await res.json();
38 + expect(body.ok).toBe(false);
39 + });
40 +});
added tests/e2e/mobile-menu.spec.ts +37 −0
@@ -0,0 +1,37 @@
1 +import { test, expect } from "@playwright/test";
2 +
3 +test.describe("menu mobile", () => {
4 + test("ouverture, navigation clavier, fermeture Échap", async ({ page, isMobile }) => {
5 + test.skip(!isMobile, "mobile uniquement");
6 + await page.goto("/");
7 + const opener = page.getByRole("button", { name: "Ouvrir le menu" });
8 + await expect(opener).toBeVisible();
9 + await opener.click();
10 + const dialog = page.getByRole("dialog");
11 + await expect(dialog).toBeVisible();
12 + await expect(page.getByRole("button", { name: "Fermer le menu" })).toBeFocused();
13 + await page.keyboard.press("Escape");
14 + await expect(dialog).toBeHidden();
15 + await expect(opener).toBeFocused();
16 + await opener.click();
17 + await dialog.getByRole("link", { name: "À propos" }).click();
18 + await expect(page).toHaveURL(/\/a-propos$/);
19 + await expect(page.getByRole("dialog")).toBeHidden();
20 + });
21 +});
22 +
23 +test.describe("responsive", () => {
24 + for (const width of [360, 390, 768, 1024, 1440, 1920]) {
25 + test(`aucun débordement horizontal à ${width} px`, async ({ page, isMobile }) => {
26 + test.skip(isMobile, "largeurs forcées sur le projet desktop");
27 + await page.setViewportSize({ width, height: 900 });
28 + for (const r of ["/", "/nous-joindre", "/services"]) {
29 + await page.goto(r);
30 + const overflow = await page.evaluate(
31 + () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
32 + );
33 + expect(overflow, `${r} @ ${width}`).toBeLessThanOrEqual(0);
34 + }
35 + });
36 + }
37 +});
added tests/e2e/navigation.spec.ts +85 −0
@@ -0,0 +1,85 @@
1 +import { test, expect } from "@playwright/test";
2 +
3 +const routes = ["/", "/services", "/a-propos", "/temoignages", "/nous-joindre", "/confidentialite"];
4 +
5 +test.describe("navigation et liens", () => {
6 + test("toutes les pages répondent 200 avec un H1 unique", async ({ page }) => {
7 + for (const r of routes) {
8 + const res = await page.goto(r);
9 + expect(res?.status(), r).toBe(200);
10 + await expect(page.locator("h1")).toHaveCount(1);
11 + }
12 + });
13 +
14 + test("aucun lien interne mort sur l'accueil", async ({ page, request }) => {
15 + await page.goto("/");
16 + const hrefs = await page
17 + .locator("a[href^='/']")
18 + .evaluateAll((as) =>
19 + Array.from(
20 + new Set(
21 + as
22 + .map((a) => (a as HTMLAnchorElement).getAttribute("href")!.split("#")[0])
23 + .filter(Boolean),
24 + ),
25 + ),
26 + );
27 + expect(hrefs.length).toBeGreaterThan(5);
28 + for (const h of hrefs) {
29 + const res = await request.get(h);
30 + expect(res.status(), h).toBeLessThan(400);
31 + }
32 + });
33 +
34 + test("le lien actif est signalé par aria-current", async ({ page, isMobile }) => {
35 + test.skip(isMobile, "navigation desktop");
36 + await page.goto("/services");
37 + await expect(
38 + page
39 + .getByRole("navigation", { name: "Navigation principale" })
40 + .getByRole("link", { name: "Nos services" }),
41 + ).toHaveAttribute("aria-current", "page");
42 + });
43 +
44 + test("les CTA mènent aux bonnes pages", async ({ page }) => {
45 + await page.goto("/");
46 + await page.getByRole("link", { name: /Découvrir nos services/ }).click();
47 + await expect(page).toHaveURL(/\/services$/);
48 + await page.goto("/");
49 + await page
50 + .getByRole("link", { name: /^Nous joindre/ })
51 + .last()
52 + .click();
53 + await expect(page).toHaveURL(/\/nous-joindre$/);
54 + });
55 +
56 + test("sitemap, robots, manifest et icônes sont servis", async ({ request }) => {
57 + for (const p of [
58 + "/sitemap.xml",
59 + "/robots.txt",
60 + "/manifest.webmanifest",
61 + "/icon.svg",
62 + "/apple-icon.png",
63 + "/opengraph-image",
64 + ]) {
65 + const res = await request.get(p);
66 + expect(res.status(), p).toBe(200);
67 + }
68 + const icon = await request.get("/icon.svg");
69 + expect(await icon.text()).toContain('<path d="M ');
70 + });
71 +
72 + test("en-têtes de sécurité présents", async ({ request }) => {
73 + const res = await request.get("/");
74 + const h = res.headers();
75 + expect(h["content-security-policy"]).toContain("frame-ancestors 'none'");
76 + expect(h["x-content-type-options"]).toBe("nosniff");
77 + expect(h["x-powered-by"]).toBeUndefined();
78 + });
79 +
80 + test("page 404 personnalisée", async ({ page }) => {
81 + const res = await page.goto("/page-inexistante");
82 + expect(res?.status()).toBe(404);
83 + await expect(page.getByRole("heading", { level: 1 })).toContainText("n’existe pas");
84 + });
85 +});
added tests/e2e/screenshots.spec.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { test } from "@playwright/test";
2 +
3 +/** Captures de référence pour docs/QA_REPORT.md (desktop + mobile). */
4 +test("captures de l'accueil et des pages", async ({ page, isMobile }) => {
5 + test.setTimeout(120_000);
6 + const widths = isMobile ? [390] : [1024, 1440];
7 + for (const w of widths) {
8 + if (!isMobile) await page.setViewportSize({ width: w, height: 900 });
9 + for (const [name, r] of [
10 + ["home", "/"],
11 + ["services", "/services"],
12 + ["a-propos", "/a-propos"],
13 + ["nous-joindre", "/nous-joindre"],
14 + ]) {
15 + await page.goto(r, { waitUntil: "networkidle" });
16 + await page.screenshot({
17 + path: `docs/screenshots/${name}-${isMobile ? "mobile-390" : w}.png`,
18 + fullPage: true,
19 + });
20 + }
21 + }
22 +});
added tests/unit/components.test.tsx +43 −0
@@ -0,0 +1,43 @@
1 +import { describe, expect, it } from "vitest";
2 +import { render, screen } from "@testing-library/react";
3 +import { Button } from "@/components/ui/Button";
4 +import { TestimonialSlider } from "@/components/sections/TestimonialSlider";
5 +import { buildSpiral } from "@/lib/spiral";
6 +
7 +describe("Button", () => {
8 + it("rend un lien quand href est fourni", () => {
9 + render(<Button href="/services">Voir</Button>);
10 + expect(screen.getByRole("link", { name: /voir/i })).toHaveAttribute("href", "/services");
11 + });
12 + it("rend un bouton sinon", () => {
13 + render(<Button arrow={false}>OK</Button>);
14 + expect(screen.getByRole("button", { name: "OK" })).toHaveAttribute("type", "button");
15 + });
16 +});
17 +
18 +describe("TestimonialSlider", () => {
19 + const items = [
20 + { quote: "Un", author: "A", role: "r1" },
21 + { quote: "Deux", author: "B", role: "r2" },
22 + ];
23 + it("affiche le premier témoignage et la pagination accessible", () => {
24 + render(<TestimonialSlider items={items} />);
25 + expect(screen.getByText("Un")).toBeInTheDocument();
26 + expect(screen.getByRole("button", { name: "Témoignage 2 sur 2" })).toBeInTheDocument();
27 + expect(screen.getByRole("button", { name: /pause/i })).toBeInTheDocument();
28 + });
29 + it("n'affiche pas de pagination avec un seul avis", () => {
30 + render(<TestimonialSlider items={[items[0]]} />);
31 + expect(screen.queryByRole("group")).toBeNull();
32 + });
33 +});
34 +
35 +describe("spirale de Fibonacci", () => {
36 + it("produit des carrés de côtés de Fibonacci et un rectangle 34×21 à 8 niveaux", () => {
37 + const { squares, bbox, path } = buildSpiral(8);
38 + expect(squares.map((s) => s.s)).toEqual([1, 1, 2, 3, 5, 8, 13, 21]);
39 + expect([bbox.w, bbox.h].sort((a, b) => a - b)).toEqual([21, 34]);
40 + expect(path.startsWith("M ")).toBe(true);
41 + expect((path.match(/A /g) ?? []).length).toBe(8);
42 + });
43 +});
added tests/unit/contact-route.test.ts +108 −0
@@ -0,0 +1,108 @@
1 +import { beforeEach, describe, expect, it, vi } from "vitest";
2 +import { POST } from "@/app/api/contact/route";
3 +import { setMailerForTests, type Mailer } from "@/lib/mail";
4 +import { resetEnvCache } from "@/lib/env";
5 +
6 +const sent: unknown[] = [];
7 +const fakeMailer: Mailer = {
8 + async send(data) {
9 + sent.push(data);
10 + return { delivered: true, id: "fake-id" };
11 + },
12 +};
13 +
14 +function req(body: unknown, headers: Record<string, string> = {}) {
15 + return new Request("http://localhost/api/contact", {
16 + method: "POST",
17 + headers: {
18 + "content-type": "application/json",
19 + "x-forwarded-for": headers.ip ?? "198.51.100.1",
20 + ...headers,
21 + },
22 + body: typeof body === "string" ? body : JSON.stringify(body),
23 + });
24 +}
25 +
26 +const valid = {
27 + name: "Jean Roy",
28 + email: "jean@exemple.com",
29 + organization: "OBNL",
30 + subject: "Reddition de comptes",
31 + message: "Nous devons produire un rapport final pour une subvention reçue cette année.",
32 + consent: true,
33 + website: "",
34 + startedAt: Date.now() - 10_000,
35 +};
36 +
37 +describe("POST /api/contact", () => {
38 + beforeEach(() => {
39 + sent.length = 0;
40 + process.env.CONTACT_TRANSPORT = "log";
41 + process.env.CONTACT_RATE_LIMIT = "100";
42 + resetEnvCache();
43 + setMailerForTests(fakeMailer);
44 + vi.spyOn(console, "info").mockImplementation(() => {});
45 + });
46 +
47 + it("accepte une soumission valide et appelle le mailer", async () => {
48 + const res = await POST(req({ ...valid, message: valid.message + " A" }));
49 + expect(res.status).toBe(200);
50 + expect(await res.json()).toMatchObject({ ok: true, delivered: true });
51 + expect(sent).toHaveLength(1);
52 + });
53 +
54 + it("renvoie 400 avec les champs fautifs", async () => {
55 + const res = await POST(req({ ...valid, email: "pas-un-courriel", message: "B" }));
56 + expect(res.status).toBe(400);
57 + const body = await res.json();
58 + expect(body.ok).toBe(false);
59 + expect(Object.keys(body.fields)).toEqual(expect.arrayContaining(["email", "message"]));
60 + expect(sent).toHaveLength(0);
61 + });
62 +
63 + it("répond succès silencieux au honeypot sans envoyer", async () => {
64 + const res = await POST(req({ ...valid, website: "http://bot", message: valid.message + " C" }));
65 + expect(res.status).toBe(200);
66 + expect(await res.json()).toMatchObject({ ok: true, delivered: false });
67 + expect(sent).toHaveLength(0);
68 + });
69 +
70 + it("ignore une soumission instantanée (robot)", async () => {
71 + const res = await POST(req({ ...valid, startedAt: Date.now(), message: valid.message + " D" }));
72 + expect(await res.json()).toMatchObject({ ok: true, delivered: false });
73 + expect(sent).toHaveLength(0);
74 + });
75 +
76 + it("refuse un JSON invalide et un mauvais content-type", async () => {
77 + expect((await POST(req("{oops"))).status).toBe(400);
78 + const r = await POST(
79 + new Request("http://localhost/api/contact", {
80 + method: "POST",
81 + headers: { "content-type": "text/plain" },
82 + body: "x",
83 + }),
84 + );
85 + expect(r.status).toBe(415);
86 + });
87 +
88 + it("dédoublonne un même message", async () => {
89 + const body = { ...valid, message: valid.message + " E" };
90 + await POST(req(body));
91 + const res = await POST(req(body));
92 + expect(await res.json()).toMatchObject({ ok: true, duplicate: true });
93 + expect(sent).toHaveLength(1);
94 + });
95 +
96 + it("renvoie 502 si le mailer échoue, sans fuite de détail", async () => {
97 + vi.spyOn(console, "error").mockImplementation(() => {});
98 + setMailerForTests({
99 + async send() {
100 + throw new Error("SMTP down: password=secret");
101 + },
102 + });
103 + const res = await POST(req({ ...valid, message: valid.message + " F" }));
104 + expect(res.status).toBe(502);
105 + const txt = JSON.stringify(await res.json());
106 + expect(txt).not.toContain("secret");
107 + });
108 +});
added tests/unit/content.test.ts +39 −0
@@ -0,0 +1,39 @@
1 +import { describe, expect, it } from "vitest";
2 +import {
3 + site,
4 + home,
5 + pages,
6 + homeSchema,
7 + displayableTestimonials,
8 + serviceBySlug,
9 +} from "@/lib/content";
10 +
11 +describe("contenu administrable", () => {
12 + it("charge et valide les trois fichiers JSON", () => {
13 + expect(site.brand.name).toBe("PHI Conseil");
14 + expect(home.services.items).toHaveLength(4);
15 + expect(pages.contact.subjects.length).toBeGreaterThan(0);
16 + });
17 +
18 + it("rejette un contenu invalide avec un message lisible", () => {
19 + const broken = structuredClone(home) as Record<string, unknown>;
20 + (broken.hero as { titleLines: string[] }).titleLines = [];
21 + const r = homeSchema.safeParse(broken);
22 + expect(r.success).toBe(false);
23 + });
24 +
25 + it("ne publie jamais un témoignage non approuvé hors prévisualisation", () => {
26 + expect(displayableTestimonials(false).items).toHaveLength(0);
27 + });
28 +
29 + it("montre les exemples en prévisualisation avec le drapeau isPreview", () => {
30 + const r = displayableTestimonials(true);
31 + expect(r.isPreview).toBe(true);
32 + expect(r.items.length).toBeGreaterThan(0);
33 + });
34 +
35 + it("retrouve un service par slug", () => {
36 + expect(serviceBySlug("reddition-de-comptes")?.title).toBe("Reddition de comptes");
37 + expect(serviceBySlug("inexistant")).toBeUndefined();
38 + });
39 +});
added tests/unit/security.test.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { describe, expect, it } from "vitest";
2 +import {
3 + createMemoryRateLimiter,
4 + createDedupe,
5 + clientIp,
6 + fingerprint,
7 + contentSecurityPolicy,
8 +} from "@/lib/security";
9 +
10 +describe("limitation de débit", () => {
11 + it("bloque après N requêtes dans la fenêtre puis libère", () => {
12 + const rl = createMemoryRateLimiter(3, 60);
13 + const t0 = 1_000_000;
14 + expect(rl.check("a", t0).allowed).toBe(true);
15 + expect(rl.check("a", t0 + 1).allowed).toBe(true);
16 + expect(rl.check("a", t0 + 2).allowed).toBe(true);
17 + const blocked = rl.check("a", t0 + 3);
18 + expect(blocked.allowed).toBe(false);
19 + expect(blocked.retryAfterS).toBeGreaterThan(0);
20 + expect(rl.check("b", t0 + 3).allowed).toBe(true);
21 + expect(rl.check("a", t0 + 61_000).allowed).toBe(true);
22 + });
23 +});
24 +
25 +describe("dédoublonnage", () => {
26 + it("signale un doublon dans la fenêtre seulement", () => {
27 + const d = createDedupe(10);
28 + expect(d.isDuplicate("k", 0)).toBe(false);
29 + expect(d.isDuplicate("k", 5_000)).toBe(true);
30 + expect(d.isDuplicate("k", 20_000)).toBe(false);
31 + });
32 +});
33 +
34 +describe("utilitaires", () => {
35 + it("lit la première IP de X-Forwarded-For", () => {
36 + expect(clientIp(new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }))).toBe(
37 + "203.0.113.5",
38 + );
39 + expect(clientIp(new Headers())).toBe("unknown");
40 + });
41 + it("produit une empreinte stable non réversible", () => {
42 + expect(fingerprint("a")).toBe(fingerprint("a"));
43 + expect(fingerprint("a")).not.toBe(fingerprint("b"));
44 + expect(fingerprint("a")).toHaveLength(12);
45 + });
46 + it("la CSP interdit l'intégration dans une iframe et les objets", () => {
47 + const csp = contentSecurityPolicy();
48 + expect(csp).toContain("frame-ancestors 'none'");
49 + expect(csp).toContain("object-src 'none'");
50 + });
51 +});
added tests/unit/setup.ts +20 −0
@@ -0,0 +1,20 @@
1 +import "@testing-library/jest-dom/vitest";
2 +
3 +// jsdom n'implémente pas matchMedia (utilisé pour prefers-reduced-motion).
4 +if (typeof window !== "undefined" && !window.matchMedia) {
5 + window.matchMedia = (query: string) =>
6 + ({
7 + matches: false,
8 + media: query,
9 + onchange: null,
10 + addEventListener: () => {},
11 + removeEventListener: () => {},
12 + addListener: () => {},
13 + removeListener: () => {},
14 + dispatchEvent: () => false,
15 + }) as MediaQueryList;
16 +}
17 +
18 +import { afterEach } from "vitest";
19 +import { cleanup } from "@testing-library/react";
20 +afterEach(() => cleanup());
added tests/unit/validation.test.ts +47 −0
@@ -0,0 +1,47 @@
1 +import { describe, expect, it } from "vitest";
2 +import { contactSchema, cleanText } from "@/lib/validation";
3 +
4 +const valid = {
5 + name: " Marie Tremblay ",
6 + email: "Marie@Exemple.COM",
7 + organization: "",
8 + subject: "Recherche de subventions",
9 + message: "Bonjour, nous aimerions discuter d'un projet de financement pour notre organisme.",
10 + consent: true,
11 + website: "",
12 +};
13 +
14 +describe("validation du formulaire de contact", () => {
15 + it("normalise et accepte une soumission valide", () => {
16 + const r = contactSchema.safeParse(valid);
17 + expect(r.success).toBe(true);
18 + if (r.success) {
19 + expect(r.data.name).toBe("Marie Tremblay");
20 + expect(r.data.email).toBe("marie@exemple.com");
21 + }
22 + });
23 +
24 + it("refuse l'injection d'en-têtes dans le sujet", () => {
25 + const r = contactSchema.safeParse({ ...valid, subject: "Sujet\r\nBcc: spam@exemple.com" });
26 + expect(r.success).toBe(false);
27 + });
28 +
29 + it("exige le consentement", () => {
30 + expect(contactSchema.safeParse({ ...valid, consent: false }).success).toBe(false);
31 + });
32 +
33 + it("refuse un message trop court ou trop long", () => {
34 + expect(contactSchema.safeParse({ ...valid, message: "court" }).success).toBe(false);
35 + expect(contactSchema.safeParse({ ...valid, message: "x".repeat(4001) }).success).toBe(false);
36 + });
37 +
38 + it("détecte le honeypot rempli", () => {
39 + const r = contactSchema.safeParse({ ...valid, website: "http://spam" });
40 + expect(r.success).toBe(false);
41 + if (!r.success) expect(r.error.issues.some((i) => i.path[0] === "website")).toBe(true);
42 + });
43 +
44 + it("retire les caractères de contrôle", () => {
45 + expect(cleanText("abc\u0000\u0007def ghi")).toBe("abcdef ghi");
46 + });
47 +});
modified tsconfig.json +3 −2
@@ -20,7 +20,8 @@
20 20 ],
21 21 "paths": {
22 22 "@/*": ["./src/*"]
23 − }
23 + },
24 + "allowImportingTsExtensions": true
24 25 },
25 26 "include": [
26 27 "next-env.d.ts",
@@ -30,5 +31,5 @@
30 31 ".next/dev/types/**/*.ts",
31 32 "**/*.mts"
32 33 ],
33 − "exclude": ["node_modules"]
34 + "exclude": ["node_modules", "tests/e2e", "playwright.config.ts"]
34 35 }
added vitest.config.mts +14 −0
@@ -0,0 +1,14 @@
1 +import { defineConfig } from "vitest/config";
2 +import react from "@vitejs/plugin-react";
3 +import { fileURLToPath } from "node:url";
4 +
5 +export default defineConfig({
6 + plugins: [react()],
7 + resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
8 + test: {
9 + environment: "jsdom",
10 + include: ["tests/unit/**/*.test.{ts,tsx}"],
11 + setupFiles: ["tests/unit/setup.ts"],
12 + globals: false,
13 + },
14 +});
15
Menu

Theme