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 # typescript 39 # typescript
40 *.tsbuildinfo 40 *.tsbuildinfo
41 next-env.d.ts 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 ```bash 7 ```bash
8−npm run dev 8+pnpm install
9−# or 9+cp .env.example .env.local # ajuster au besoin (transport courriel, URL)
10−yarn dev 10+pnpm dev # http://localhost:3000
11−# or
12−pnpm dev
13−# or
14−bun dev
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: 31+```
26− 32+content/ contenu administrable (site.json, home.json, pages.json) — voir docs/CLIENT_GUIDE.md
27−- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 33+src/app/ routes (accueil, services, a-propos, temoignages, nous-joindre, confidentialite, api/contact)
28−- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 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 import type { NextConfig } from "next"; 1 import type { NextConfig } from "next";
2+import { securityHeaders } from "./src/lib/security";
2 3
3 const nextConfig: NextConfig = { 4 const nextConfig: NextConfig = {
4− /* config options here */
5 cacheComponents: true, 5 cacheComponents: true,
6 partialPrefetching: true, 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 turbopack: { 15 turbopack: {
8 rules: { 16 rules: {
9 "*.css": { 17 "*.css": {
modified package.json +27 −3
@@ -6,22 +6,46 @@
6 "dev": "next dev", 6 "dev": "next dev",
7 "build": "next build", 7 "build": "next build",
8 "start": "next start", 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 "dependencies": { 19 "dependencies": {
20+ "@hookform/resolvers": "^7.0.3",
12 "next": "16.4.5", 21 "next": "16.4.5",
22+ "nodemailer": "^10.3.1",
13 "react": "19.3.0", 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 "devDependencies": { 29 "devDependencies": {
30+ "@axe-core/playwright": "^4.14.0",
31+ "@playwright/test": "^1.65.0",
17 "@tailwindcss/turbopack": "^4", 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 "@types/node": "^20", 36 "@types/node": "^20",
37+ "@types/nodemailer": "^8.0.2",
19 "@types/react": "^19", 38 "@types/react": "^19",
20 "@types/react-dom": "^19", 39 "@types/react-dom": "^19",
40+ "@vitejs/plugin-react": "^6.2.1",
21 "eslint": "^9", 41 "eslint": "^9",
22 "eslint-config-next": "16.4.5", 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 "tailwindcss": "^4", 46 "tailwindcss": "^4",
24− "typescript": "^5" 47+ "typescript": "^5",
48+ "vitest": "^5.1.0"
25 }, 49 },
26 "packageManager": "pnpm@11.1.2" 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 dependencies: 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 next: 14 next:
12 specifier: 16.4.5 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 react: 20 react:
15 specifier: 19.3.0 21 specifier: 19.3.0
16 version: 19.3.0 22 version: 19.3.0
17 react-dom: 23 react-dom:
18 specifier: 19.3.0 24 specifier: 19.3.0
19 version: 19.3.0(react@19.3.0) 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 devDependencies: 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 '@tailwindcss/turbopack': 42 '@tailwindcss/turbopack':
22 specifier: ^4 43 specifier: ^4
23 version: 4.3.5 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 '@types/node': 54 '@types/node':
25 specifier: ^20 55 specifier: ^20
26 version: 20.19.43 56 version: 20.19.43
57+ '@types/nodemailer':
58+ specifier: ^8.0.2
59+ version: 8.0.2
27 '@types/react': 60 '@types/react':
28 specifier: ^19 61 specifier: ^19
29 version: 19.3.0 62 version: 19.3.0
30 '@types/react-dom': 63 '@types/react-dom':
31 specifier: ^19 64 specifier: ^19
32 version: 19.3.0(@types/react@19.3.0) 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 eslint: 69 eslint:
34 specifier: ^9 70 specifier: ^9
35 version: 9.39.5(jiti@2.7.0) 71 version: 9.39.5(jiti@2.7.0)
36 eslint-config-next: 72 eslint-config-next:
37 specifier: 16.4.5 73 specifier: 16.4.5
38 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) 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 tailwindcss: 84 tailwindcss:
40 specifier: ^4 85 specifier: ^4
41 version: 4.3.5 86 version: 4.3.5
42 typescript: 87 typescript:
43 specifier: ^5 88 specifier: ^5
44 version: 5.9.3 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 packages: 94 packages:
47 95
96+ '@adobe/css-tools@4.5.0':
97+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
98+
48 '@alloc/quick-lru@5.3.0': 99 '@alloc/quick-lru@5.3.0':
49 resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} 100 resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
50 engines: {node: '>=10'} 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 '@babel/code-frame@7.29.7': 109 '@babel/code-frame@7.29.7':
53 resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} 110 resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
54 engines: {node: '>=6.9.0'} 111 engines: {node: '>=6.9.0'}
@@ -104,6 +161,10 @@ packages:
104 engines: {node: '>=6.0.0'} 161 engines: {node: '>=6.0.0'}
105 hasBin: true 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 '@babel/template@7.29.7': 168 '@babel/template@7.29.7':
108 resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} 169 resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
109 engines: {node: '>=6.9.0'} 170 engines: {node: '>=6.9.0'}
@@ -163,6 +224,90 @@ packages:
163 resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} 224 resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
164 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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 '@humanfs/core@0.19.2': 311 '@humanfs/core@0.19.2':
167 resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} 312 resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
168 engines: {node: '>=18.18.0'} 313 engines: {node: '>=18.18.0'}
@@ -435,9 +580,119 @@ packages:
435 resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} 580 resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
436 engines: {node: '>=12.4.0'} 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 '@rtsao/scc@1.1.0': 690 '@rtsao/scc@1.1.0':
439 resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} 691 resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
440 692
693+ '@standard-schema/utils@0.3.0':
694+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
695+
441 '@swc/helpers@0.5.23': 696 '@swc/helpers@0.5.23':
442 resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} 697 resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
443 698
@@ -532,6 +787,50 @@ packages:
532 '@tailwindcss/turbopack@4.3.5': 787 '@tailwindcss/turbopack@4.3.5':
533 resolution: {integrity: sha512-0f0rOTyz4yGtPzpSiIP3ppAaYtMnQbGnEsHPGK4Yy8wNbE4xZWlNPjWtD6oyXvQZTg2N0XF4BZEhfsgvJe889g==} 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 '@types/estree@1.0.10': 834 '@types/estree@1.0.10':
536 resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} 835 resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==}
537 836
@@ -544,6 +843,9 @@ packages:
544 '@types/node@20.19.43': 843 '@types/node@20.19.43':
545 resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} 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 '@types/react-dom@19.3.0': 849 '@types/react-dom@19.3.0':
548 resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} 850 resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
549 peerDependencies: 851 peerDependencies:
@@ -726,6 +1028,36 @@ packages:
726 cpu: [x64] 1028 cpu: [x64]
727 os: [win32] 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 acorn-jsx@5.3.2: 1061 acorn-jsx@5.3.2:
730 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} 1062 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
731 peerDependencies: 1063 peerDependencies:
@@ -739,13 +1071,24 @@ packages:
739 ajv@6.15.0: 1071 ajv@6.15.0:
740 resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} 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 ansi-styles@4.3.0: 1078 ansi-styles@4.3.0:
743 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 1079 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
744 engines: {node: '>=8'} 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 argparse@2.0.1: 1086 argparse@2.0.1:
747 resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 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 aria-query@5.3.2: 1092 aria-query@5.3.2:
750 resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} 1093 resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
751 engines: {node: '>= 0.4'} 1094 engines: {node: '>= 0.4'}
@@ -782,6 +1125,10 @@ packages:
782 resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} 1125 resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
783 engines: {node: '>= 0.4'} 1126 engines: {node: '>= 0.4'}
784 1127
1128+ assertion-error@2.0.1:
1129+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
1130+ engines: {node: '>=12'}
1131+
785 ast-types-flow@0.0.8: 1132 ast-types-flow@0.0.8:
786 resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} 1133 resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
787 1134
@@ -813,6 +1160,12 @@ packages:
813 engines: {node: '>=6.0.0'} 1160 engines: {node: '>=6.0.0'}
814 hasBin: true 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 brace-expansion@1.1.21: 1169 brace-expansion@1.1.21:
817 resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} 1170 resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
818 1171
@@ -848,6 +1201,10 @@ packages:
848 caniuse-lite@1.0.30001823: 1201 caniuse-lite@1.0.30001823:
849 resolution: {integrity: sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==} 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 chalk@4.1.2: 1208 chalk@4.1.2:
852 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1209 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
853 engines: {node: '>=10'} 1210 engines: {node: '>=10'}
@@ -872,12 +1229,19 @@ packages:
872 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 1229 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
873 engines: {node: '>= 8'} 1230 engines: {node: '>= 8'}
874 1231
1232+ css.escape@1.5.1:
1233+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
1234+
875 csstype@3.2.3: 1235 csstype@3.2.3:
876 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 1236 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
877 1237
878 damerau-levenshtein@1.0.8: 1238 damerau-levenshtein@1.0.8:
879 resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} 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 data-view-buffer@1.0.2: 1245 data-view-buffer@1.0.2:
882 resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} 1246 resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
883 engines: {node: '>= 0.4'} 1247 engines: {node: '>= 0.4'}
@@ -907,6 +1271,9 @@ packages:
907 supports-color: 1271 supports-color:
908 optional: true 1272 optional: true
909 1273
1274+ decimal.js@10.6.0:
1275+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
1276+
910 deep-is@0.1.4: 1277 deep-is@0.1.4:
911 resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} 1278 resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
912 1279
@@ -918,6 +1285,10 @@ packages:
918 resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} 1285 resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
919 engines: {node: '>= 0.4'} 1286 engines: {node: '>= 0.4'}
920 1287
1288+ dequal@2.0.3:
1289+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1290+ engines: {node: '>=6'}
1291+
921 detect-libc@2.1.2: 1292 detect-libc@2.1.2:
922 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 1293 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
923 engines: {node: '>=8'} 1294 engines: {node: '>=8'}
@@ -926,6 +1297,12 @@ packages:
926 resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} 1297 resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
927 engines: {node: '>=0.10.0'} 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 dunder-proto@1.0.1: 1306 dunder-proto@1.0.1:
930 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} 1307 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
931 engines: {node: '>= 0.4'} 1308 engines: {node: '>= 0.4'}
@@ -940,6 +1317,10 @@ packages:
940 resolution: {integrity: sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==} 1317 resolution: {integrity: sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==}
941 engines: {node: '>=10.13.0'} 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 es-abstract-get@1.0.0: 1324 es-abstract-get@1.0.0:
944 resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} 1325 resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
945 engines: {node: '>= 0.4'} 1326 engines: {node: '>= 0.4'}
@@ -960,6 +1341,9 @@ packages:
960 resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} 1341 resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
961 engines: {node: '>= 0.4'} 1342 engines: {node: '>= 0.4'}
962 1343
1344+ es-module-lexer@2.3.2:
1345+ resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
1346+
963 es-object-atoms@1.1.2: 1347 es-object-atoms@1.1.2:
964 resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} 1348 resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
965 engines: {node: '>= 0.4'} 1349 engines: {node: '>= 0.4'}
@@ -1101,10 +1485,17 @@ packages:
1101 resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} 1485 resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1102 engines: {node: '>=4.0'} 1486 engines: {node: '>=4.0'}
1103 1487
1488+ estree-walker@3.0.3:
1489+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
1490+
1104 esutils@2.0.3: 1491 esutils@2.0.3:
1105 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 1492 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1106 engines: {node: '>=0.10.0'} 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 fast-deep-equal@3.1.3: 1499 fast-deep-equal@3.1.3:
1109 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 1500 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1110 1501
@@ -1153,6 +1544,11 @@ packages:
1153 resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} 1544 resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1154 engines: {node: '>= 0.4'} 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 function-bind@1.1.2: 1552 function-bind@1.1.2:
1157 resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 1553 resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1158 1554
@@ -1246,6 +1642,10 @@ packages:
1246 hermes-parser@0.25.1: 1642 hermes-parser@0.25.1:
1247 resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} 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 ignore@5.3.2: 1649 ignore@5.3.2:
1250 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} 1650 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1251 engines: {node: '>= 4'} 1651 engines: {node: '>= 4'}
@@ -1262,10 +1662,20 @@ packages:
1262 resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} 1662 resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1263 engines: {node: '>=0.8.19'} 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 internal-slot@1.1.0: 1669 internal-slot@1.1.0:
1266 resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} 1670 resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1267 engines: {node: '>= 0.4'} 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 is-array-buffer@3.0.5: 1679 is-array-buffer@3.0.5:
1270 resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} 1680 resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1271 engines: {node: '>= 0.4'} 1681 engines: {node: '>= 0.4'}
@@ -1301,6 +1711,9 @@ packages:
1301 resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} 1711 resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1302 engines: {node: '>= 0.4'} 1712 engines: {node: '>= 0.4'}
1303 1713
1714+ is-decimal@2.0.1:
1715+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
1716+
1304 is-document.all@1.0.0: 1717 is-document.all@1.0.0:
1305 resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} 1718 resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1306 engines: {node: '>= 0.4'} 1719 engines: {node: '>= 0.4'}
@@ -1337,6 +1750,9 @@ packages:
1337 resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 1750 resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1338 engines: {node: '>=0.12.0'} 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 is-regex@1.2.1: 1756 is-regex@1.2.1:
1341 resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} 1757 resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1342 engines: {node: '>= 0.4'} 1758 engines: {node: '>= 0.4'}
@@ -1394,6 +1810,15 @@ packages:
1394 resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} 1810 resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
1395 hasBin: true 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 jsesc@3.1.0: 1822 jsesc@3.1.0:
1398 resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} 1823 resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
1399 engines: {node: '>=6'} 1824 engines: {node: '>=6'}
@@ -1520,9 +1945,17 @@ packages:
1520 resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} 1945 resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1521 hasBin: true 1946 hasBin: true
1522 1947
1948+ lru-cache@11.5.3:
1949+ resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
1950+ engines: {node: 20 || >=22}
1951+
1523 lru-cache@5.1.1: 1952 lru-cache@5.1.1:
1524 resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} 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 magic-string@1.4.5: 1959 magic-string@1.4.5:
1527 resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} 1960 resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}
1528 1961
@@ -1538,6 +1971,10 @@ packages:
1538 resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} 1971 resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1539 engines: {node: '>=8.6'} 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 minimatch@10.2.6: 1978 minimatch@10.2.6:
1542 resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} 1979 resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1543 engines: {node: 18 || 20 || >=22} 1980 engines: {node: 18 || 20 || >=22}
@@ -1593,6 +2030,10 @@ packages:
1593 resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==} 2030 resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==}
1594 engines: {node: '>=18'} 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 object-assign@4.1.1: 2037 object-assign@4.1.1:
1597 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} 2038 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1598 engines: {node: '>=0.10.0'} 2039 engines: {node: '>=0.10.0'}
@@ -1625,6 +2066,10 @@ packages:
1625 resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} 2066 resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
1626 engines: {node: '>= 0.4'} 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 optionator@0.9.4: 2073 optionator@0.9.4:
1629 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} 2074 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1630 engines: {node: '>= 0.8.0'} 2075 engines: {node: '>= 0.8.0'}
@@ -1645,6 +2090,12 @@ packages:
1645 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} 2090 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
1646 engines: {node: '>=6'} 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 path-exists@4.0.0: 2099 path-exists@4.0.0:
1649 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 2100 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
1650 engines: {node: '>=8'} 2101 engines: {node: '>=8'}
@@ -1667,6 +2118,16 @@ packages:
1667 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} 2118 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
1668 engines: {node: '>=12'} 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 possible-typed-array-names@1.1.0: 2131 possible-typed-array-names@1.1.0:
1671 resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} 2132 resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
1672 engines: {node: '>= 0.4'} 2133 engines: {node: '>= 0.4'}
@@ -1675,10 +2136,78 @@ packages:
1675 resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} 2136 resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
1676 engines: {node: ^10 || ^12 || >=14} 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 prelude-ls@1.2.1: 2143 prelude-ls@1.2.1:
1679 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} 2144 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1680 engines: {node: '>= 0.8.0'} 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 prop-types@15.8.1: 2211 prop-types@15.8.1:
1683 resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} 2212 resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1684 2213
@@ -1694,13 +2223,30 @@ packages:
1694 peerDependencies: 2223 peerDependencies:
1695 react: ^19.3.0 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 react-is@16.13.1: 2236 react-is@16.13.1:
1698 resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} 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 react@19.3.0: 2242 react@19.3.0:
1701 resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} 2243 resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
1702 engines: {node: '>=0.10.0'} 2244 engines: {node: '>=0.10.0'}
1703 2245
2246+ redent@3.0.0:
2247+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
2248+ engines: {node: '>=8'}
2249+
1704 reflect.getprototypeof@1.0.10: 2250 reflect.getprototypeof@1.0.10:
1705 resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} 2251 resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
1706 engines: {node: '>= 0.4'} 2252 engines: {node: '>= 0.4'}
@@ -1725,6 +2271,11 @@ packages:
1725 resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} 2271 resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1726 engines: {iojs: '>=1.0.0', node: '>=0.10.0'} 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 run-parallel@1.2.0: 2279 run-parallel@1.2.0:
1729 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 2280 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1730 2281
@@ -1740,6 +2291,10 @@ packages:
1740 resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} 2291 resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
1741 engines: {node: '>= 0.4'} 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 scheduler@0.28.0: 2298 scheduler@0.28.0:
1744 resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} 2299 resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
1745 2300
@@ -1804,6 +2359,9 @@ packages:
1804 stable-hash@0.0.5: 2359 stable-hash@0.0.5:
1805 resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} 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 stop-iteration-iterator@1.1.0: 2365 stop-iteration-iterator@1.1.0:
1808 resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} 2366 resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
1809 engines: {node: '>= 0.4'} 2367 engines: {node: '>= 0.4'}
@@ -1835,6 +2393,10 @@ packages:
1835 resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} 2393 resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
1836 engines: {node: '>=4'} 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 strip-json-comments@3.1.1: 2400 strip-json-comments@3.1.1:
1839 resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} 2401 resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
1840 engines: {node: '>=8'} 2402 engines: {node: '>=8'}
@@ -1867,14 +2429,37 @@ packages:
1867 resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} 2429 resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
1868 engines: {node: '>=6'} 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 tinyglobby@0.2.17: 2440 tinyglobby@0.2.17:
1871 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} 2441 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1872 engines: {node: '>=12.0.0'} 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 to-regex-range@5.0.1: 2451 to-regex-range@5.0.1:
1875 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 2452 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1876 engines: {node: '>=8.0'} 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 ts-api-utils@2.5.0: 2463 ts-api-utils@2.5.0:
1879 resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} 2464 resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
1880 engines: {node: '>=18.12'} 2465 engines: {node: '>=18.12'}
@@ -1926,6 +2511,10 @@ packages:
1926 undici-types@6.21.0: 2511 undici-types@6.21.0:
1927 resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 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 unrs-resolver@1.13.0: 2518 unrs-resolver@1.13.0:
1930 resolution: {integrity: sha512-mcPQo9Vm6kXd84d/WqMiYBi2CcA57QHBX/ev5PoUEb8b2oQzUvJ6xYsfseQblnCncgnVmCZGWiFwK7KkS5JZJA==} 2519 resolution: {integrity: sha512-mcPQo9Vm6kXd84d/WqMiYBi2CcA57QHBX/ev5PoUEb8b2oQzUvJ6xYsfseQblnCncgnVmCZGWiFwK7KkS5JZJA==}
1931 2520
@@ -1938,6 +2527,106 @@ packages:
1938 uri-js@4.4.1: 2527 uri-js@4.4.1:
1939 resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 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 which-boxed-primitive@1.1.1: 2630 which-boxed-primitive@1.1.1:
1942 resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} 2631 resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
1943 engines: {node: '>= 0.4'} 2632 engines: {node: '>= 0.4'}
@@ -1959,10 +2648,22 @@ packages:
1959 engines: {node: '>= 8'} 2648 engines: {node: '>= 8'}
1960 hasBin: true 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 word-wrap@1.2.5: 2656 word-wrap@1.2.5:
1963 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 2657 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
1964 engines: {node: '>=0.10.0'} 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 yallist@3.1.1: 2667 yallist@3.1.1:
1967 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} 2668 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
1968 2669
@@ -1981,8 +2682,15 @@ packages:
1981 2682
1982 snapshots: 2683 snapshots:
1983 2684
2685+ '@adobe/css-tools@4.5.0': {}
2686+
1984 '@alloc/quick-lru@5.3.0': {} 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 '@babel/code-frame@7.29.7': 2694 '@babel/code-frame@7.29.7':
1987 dependencies: 2695 dependencies:
1988 '@babel/helper-validator-identifier': 7.29.7 2696 '@babel/helper-validator-identifier': 7.29.7
@@ -2060,6 +2768,8 @@ snapshots:
2060 dependencies: 2768 dependencies:
2061 '@babel/types': 7.29.11 2769 '@babel/types': 7.29.11
2062 2770
2771+ '@babel/runtime@7.29.13': {}
2772+
2063 '@babel/template@7.29.7': 2773 '@babel/template@7.29.7':
2064 dependencies: 2774 dependencies:
2065 '@babel/code-frame': 7.29.7 2775 '@babel/code-frame': 7.29.7
@@ -2139,6 +2849,15 @@ snapshots:
2139 '@eslint/core': 0.17.0 2849 '@eslint/core': 0.17.0
2140 levn: 0.4.1 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 '@humanfs/core@0.19.2': 2861 '@humanfs/core@0.19.2':
2143 dependencies: 2862 dependencies:
2144 '@humanfs/types': 0.15.0 2863 '@humanfs/types': 0.15.0
@@ -2155,8 +2874,7 @@ snapshots:
2155 2874
2156 '@humanwhocodes/retry@0.4.3': {} 2875 '@humanwhocodes/retry@0.4.3': {}
2157 2876
2158− '@img/colour@1.1.0': 2877+ '@img/colour@1.1.0': {}
2159− optional: true
2160 2878
2161 '@img/sharp-darwin-arm64@0.35.5': 2879 '@img/sharp-darwin-arm64@0.35.5':
2162 optionalDependencies: 2880 optionalDependencies:
@@ -2328,8 +3046,63 @@ snapshots:
2328 3046
2329 '@nolyfill/is-core-module@1.0.39': {} 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 '@rtsao/scc@1.1.0': {} 3102 '@rtsao/scc@1.1.0': {}
2332 3103
3104+ '@standard-schema/utils@0.3.0': {}
3105+
2333 '@swc/helpers@0.5.23': 3106 '@swc/helpers@0.5.23':
2334 dependencies: 3107 dependencies:
2335 tslib: 2.9.1 3108 tslib: 2.9.1
@@ -2402,6 +3175,52 @@ snapshots:
2402 '@tailwindcss/oxide': 4.3.5 3175 '@tailwindcss/oxide': 4.3.5
2403 tailwindcss: 4.3.5 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 '@types/estree@1.0.10': {} 3224 '@types/estree@1.0.10': {}
2406 3225
2407 '@types/json-schema@7.0.15': {} 3226 '@types/json-schema@7.0.15': {}
@@ -2412,6 +3231,10 @@ snapshots:
2412 dependencies: 3231 dependencies:
2413 undici-types: 6.21.0 3232 undici-types: 6.21.0
2414 3233
3234+ '@types/nodemailer@8.0.2':
3235+ dependencies:
3236+ '@types/node': 20.19.43
3237+
2415 '@types/react-dom@19.3.0(@types/react@19.3.0)': 3238 '@types/react-dom@19.3.0(@types/react@19.3.0)':
2416 dependencies: 3239 dependencies:
2417 '@types/react': 19.3.0 3240 '@types/react': 19.3.0
@@ -2574,6 +3397,22 @@ snapshots:
2574 '@unrs/resolver-binding-win32-x64-msvc@1.13.0': 3397 '@unrs/resolver-binding-win32-x64-msvc@1.13.0':
2575 optional: true 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 acorn-jsx@5.3.2(acorn@8.19.0): 3416 acorn-jsx@5.3.2(acorn@8.19.0):
2578 dependencies: 3417 dependencies:
2579 acorn: 8.19.0 3418 acorn: 8.19.0
@@ -2587,12 +3426,20 @@ snapshots:
2587 json-schema-traverse: 0.4.1 3426 json-schema-traverse: 0.4.1
2588 uri-js: 4.4.1 3427 uri-js: 4.4.1
2589 3428
3429+ ansi-regex@5.0.1: {}
3430+
2590 ansi-styles@4.3.0: 3431 ansi-styles@4.3.0:
2591 dependencies: 3432 dependencies:
2592 color-convert: 2.0.1 3433 color-convert: 2.0.1
2593 3434
3435+ ansi-styles@5.2.0: {}
3436+
2594 argparse@2.0.1: {} 3437 argparse@2.0.1: {}
2595 3438
3439+ aria-query@5.3.0:
3440+ dependencies:
3441+ dequal: 2.0.3
3442+
2596 aria-query@5.3.2: {} 3443 aria-query@5.3.2: {}
2597 3444
2598 array-buffer-byte-length@1.0.2: 3445 array-buffer-byte-length@1.0.2:
@@ -2662,6 +3509,8 @@ snapshots:
2662 get-intrinsic: 1.3.0 3509 get-intrinsic: 1.3.0
2663 is-array-buffer: 3.0.5 3510 is-array-buffer: 3.0.5
2664 3511
3512+ assertion-error@2.0.1: {}
3513+
2665 ast-types-flow@0.0.8: {} 3514 ast-types-flow@0.0.8: {}
2666 3515
2667 async-function@1.0.0: {} 3516 async-function@1.0.0: {}
@@ -2680,6 +3529,14 @@ snapshots:
2680 3529
2681 baseline-browser-mapping@2.11.35: {} 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 brace-expansion@1.1.21: 3540 brace-expansion@1.1.21:
2684 dependencies: 3541 dependencies:
2685 balanced-match: 1.0.2 3542 balanced-match: 1.0.2
@@ -2722,6 +3579,8 @@ snapshots:
2722 3579
2723 caniuse-lite@1.0.30001823: {} 3580 caniuse-lite@1.0.30001823: {}
2724 3581
3582+ chai@6.3.0: {}
3583+
2725 chalk@4.1.2: 3584 chalk@4.1.2:
2726 dependencies: 3585 dependencies:
2727 ansi-styles: 4.3.0 3586 ansi-styles: 4.3.0
@@ -2745,10 +3604,19 @@ snapshots:
2745 shebang-command: 2.0.0 3604 shebang-command: 2.0.0
2746 which: 2.0.2 3605 which: 2.0.2
2747 3606
3607+ css.escape@1.5.1: {}
3608+
2748 csstype@3.2.3: {} 3609 csstype@3.2.3: {}
2749 3610
2750 damerau-levenshtein@1.0.8: {} 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 data-view-buffer@1.0.2: 3620 data-view-buffer@1.0.2:
2753 dependencies: 3621 dependencies:
2754 call-bound: 1.0.4 3622 call-bound: 1.0.4
@@ -2775,6 +3643,8 @@ snapshots:
2775 dependencies: 3643 dependencies:
2776 ms: 2.1.3 3644 ms: 2.1.3
2777 3645
3646+ decimal.js@10.6.0: {}
3647+
2778 deep-is@0.1.4: {} 3648 deep-is@0.1.4: {}
2779 3649
2780 define-data-property@1.1.4: 3650 define-data-property@1.1.4:
@@ -2789,12 +3659,18 @@ snapshots:
2789 has-property-descriptors: 1.0.2 3659 has-property-descriptors: 1.0.2
2790 object-keys: 1.1.1 3660 object-keys: 1.1.1
2791 3661
3662+ dequal@2.0.3: {}
3663+
2792 detect-libc@2.1.2: {} 3664 detect-libc@2.1.2: {}
2793 3665
2794 doctrine@2.1.0: 3666 doctrine@2.1.0:
2795 dependencies: 3667 dependencies:
2796 esutils: 2.0.3 3668 esutils: 2.0.3
2797 3669
3670+ dom-accessibility-api@0.5.16: {}
3671+
3672+ dom-accessibility-api@0.6.3: {}
3673+
2798 dunder-proto@1.0.1: 3674 dunder-proto@1.0.1:
2799 dependencies: 3675 dependencies:
2800 call-bind-apply-helpers: 1.0.2 3676 call-bind-apply-helpers: 1.0.2
@@ -2810,6 +3686,8 @@ snapshots:
2810 graceful-fs: 4.2.11 3686 graceful-fs: 4.2.11
2811 tapable: 2.3.3 3687 tapable: 2.3.3
2812 3688
3689+ entities@9.0.0: {}
3690+
2813 es-abstract-get@1.0.0: 3691 es-abstract-get@1.0.0:
2814 dependencies: 3692 dependencies:
2815 es-errors: 1.3.0 3693 es-errors: 1.3.0
@@ -2897,6 +3775,8 @@ snapshots:
2897 iterator.prototype: 1.1.5 3775 iterator.prototype: 1.1.5
2898 math-intrinsics: 1.1.0 3776 math-intrinsics: 1.1.0
2899 3777
3778+ es-module-lexer@2.3.2: {}
3779+
2900 es-object-atoms@1.1.2: 3780 es-object-atoms@1.1.2:
2901 dependencies: 3781 dependencies:
2902 es-errors: 1.3.0 3782 es-errors: 1.3.0
@@ -3128,8 +4008,14 @@ snapshots:
3128 4008
3129 estraverse@5.3.0: {} 4009 estraverse@5.3.0: {}
3130 4010
4011+ estree-walker@3.0.3:
4012+ dependencies:
4013+ '@types/estree': 1.0.10
4014+
3131 esutils@2.0.3: {} 4015 esutils@2.0.3: {}
3132 4016
4017+ expect-type@1.4.0: {}
4018+
3133 fast-deep-equal@3.1.3: {} 4019 fast-deep-equal@3.1.3: {}
3134 4020
3135 fast-glob@3.3.1: 4021 fast-glob@3.3.1:
@@ -3176,6 +4062,9 @@ snapshots:
3176 dependencies: 4062 dependencies:
3177 is-callable: 1.2.7 4063 is-callable: 1.2.7
3178 4064
4065+ fsevents@2.3.3:
4066+ optional: true
4067+
3179 function-bind@1.1.2: {} 4068 function-bind@1.1.2: {}
3180 4069
3181 function.prototype.name@1.2.0: 4070 function.prototype.name@1.2.0:
@@ -3273,6 +4162,12 @@ snapshots:
3273 dependencies: 4162 dependencies:
3274 hermes-estree: 0.25.1 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 ignore@5.3.2: {} 4171 ignore@5.3.2: {}
3277 4172
3278 ignore@7.0.14: {} 4173 ignore@7.0.14: {}
@@ -3284,12 +4179,21 @@ snapshots:
3284 4179
3285 imurmurhash@0.1.4: {} 4180 imurmurhash@0.1.4: {}
3286 4181
4182+ indent-string@4.0.0: {}
4183+
3287 internal-slot@1.1.0: 4184 internal-slot@1.1.0:
3288 dependencies: 4185 dependencies:
3289 es-errors: 1.3.0 4186 es-errors: 1.3.0
3290 hasown: 2.0.4 4187 hasown: 2.0.4
3291 side-channel: 1.1.1 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 is-array-buffer@3.0.5: 4197 is-array-buffer@3.0.5:
3294 dependencies: 4198 dependencies:
3295 call-bind: 1.0.9 4199 call-bind: 1.0.9
@@ -3334,6 +4238,8 @@ snapshots:
3334 call-bound: 1.0.4 4238 call-bound: 1.0.4
3335 has-tostringtag: 1.0.2 4239 has-tostringtag: 1.0.2
3336 4240
4241+ is-decimal@2.0.1: {}
4242+
3337 is-document.all@1.0.0: 4243 is-document.all@1.0.0:
3338 dependencies: 4244 dependencies:
3339 call-bound: 1.0.4 4245 call-bound: 1.0.4
@@ -3367,6 +4273,8 @@ snapshots:
3367 4273
3368 is-number@7.0.0: {} 4274 is-number@7.0.0: {}
3369 4275
4276+ is-potential-custom-element-name@1.0.1: {}
4277+
3370 is-regex@1.2.1: 4278 is-regex@1.2.1:
3371 dependencies: 4279 dependencies:
3372 call-bound: 1.0.4 4280 call-bound: 1.0.4
@@ -3427,6 +4335,29 @@ snapshots:
3427 dependencies: 4335 dependencies:
3428 argparse: 2.0.1 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 jsesc@3.1.0: {} 4361 jsesc@3.1.0: {}
3431 4362
3432 json-buffer@3.0.1: {} 4363 json-buffer@3.0.1: {}
@@ -3522,10 +4453,14 @@ snapshots:
3522 dependencies: 4453 dependencies:
3523 js-tokens: 4.0.0 4454 js-tokens: 4.0.0
3524 4455
4456+ lru-cache@11.5.3: {}
4457+
3525 lru-cache@5.1.1: 4458 lru-cache@5.1.1:
3526 dependencies: 4459 dependencies:
3527 yallist: 3.1.1 4460 yallist: 3.1.1
3528 4461
4462+ lz-string@1.5.0: {}
4463+
3529 magic-string@1.4.5: 4464 magic-string@1.4.5:
3530 dependencies: 4465 dependencies:
3531 '@jridgewell/sourcemap-codec': 1.6.0 4466 '@jridgewell/sourcemap-codec': 1.6.0
@@ -3539,6 +4474,8 @@ snapshots:
3539 braces: 3.0.3 4474 braces: 3.0.3
3540 picomatch: 2.3.2 4475 picomatch: 2.3.2
3541 4476
4477+ min-indent@1.0.1: {}
4478+
3542 minimatch@10.2.6: 4479 minimatch@10.2.6:
3543 dependencies: 4480 dependencies:
3544 brace-expansion: 5.0.12 4481 brace-expansion: 5.0.12
@@ -3557,7 +4494,7 @@ snapshots:
3557 4494
3558 natural-compare@1.4.0: {} 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 dependencies: 4498 dependencies:
3562 '@next/env': 16.4.5 4499 '@next/env': 16.4.5
3563 '@swc/helpers': 0.5.23 4500 '@swc/helpers': 0.5.23
@@ -3576,6 +4513,7 @@ snapshots:
3576 '@next/swc-linux-x64-musl': 16.4.5 4513 '@next/swc-linux-x64-musl': 16.4.5
3577 '@next/swc-win32-arm64-msvc': 16.4.5 4514 '@next/swc-win32-arm64-msvc': 16.4.5
3578 '@next/swc-win32-x64-msvc': 16.4.5 4515 '@next/swc-win32-x64-msvc': 16.4.5
4516+ '@playwright/test': 1.65.0
3579 sharp: 0.35.5(@types/node@20.19.43) 4517 sharp: 0.35.5(@types/node@20.19.43)
3580 transitivePeerDependencies: 4518 transitivePeerDependencies:
3581 - '@babel/core' 4519 - '@babel/core'
@@ -3591,6 +4529,8 @@ snapshots:
3591 4529
3592 node-releases@2.0.63: {} 4530 node-releases@2.0.63: {}
3593 4531
4532+ nodemailer@10.3.1: {}
4533+
3594 object-assign@4.1.1: {} 4534 object-assign@4.1.1: {}
3595 4535
3596 object-inspect@1.13.4: {} 4536 object-inspect@1.13.4: {}
@@ -3633,6 +4573,8 @@ snapshots:
3633 define-properties: 1.2.1 4573 define-properties: 1.2.1
3634 es-object-atoms: 1.1.2 4574 es-object-atoms: 1.1.2
3635 4575
4576+ obug@3.0.0: {}
4577+
3636 optionator@0.9.4: 4578 optionator@0.9.4:
3637 dependencies: 4579 dependencies:
3638 deep-is: 0.1.4 4580 deep-is: 0.1.4
@@ -3661,6 +4603,14 @@ snapshots:
3661 dependencies: 4603 dependencies:
3662 callsites: 3.1.0 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 path-exists@4.0.0: {} 4614 path-exists@4.0.0: {}
3665 4615
3666 path-key@3.1.1: {} 4616 path-key@3.1.1: {}
@@ -3673,6 +4623,12 @@ snapshots:
3673 4623
3674 picomatch@4.0.7: {} 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 possible-typed-array-names@1.1.0: {} 4632 possible-typed-array-names@1.1.0: {}
3677 4633
3678 postcss@8.5.23: 4634 postcss@8.5.23:
@@ -3681,8 +4637,26 @@ snapshots:
3681 picocolors: 1.1.1 4637 picocolors: 1.1.1
3682 source-map-js: 1.2.2 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 prelude-ls@1.2.1: {} 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 prop-types@15.8.1: 4660 prop-types@15.8.1:
3687 dependencies: 4661 dependencies:
3688 loose-envify: 1.4.0 4662 loose-envify: 1.4.0
@@ -3698,10 +4672,23 @@ snapshots:
3698 react: 19.3.0 4672 react: 19.3.0
3699 scheduler: 0.28.0 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 react-is@16.13.1: {} 4681 react-is@16.13.1: {}
3702 4682
4683+ react-is@17.0.2: {}
4684+
3703 react@19.3.0: {} 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 reflect.getprototypeof@1.0.10: 4692 reflect.getprototypeof@1.0.10:
3706 dependencies: 4693 dependencies:
3707 call-bind: 1.0.9 4694 call-bind: 1.0.9
@@ -3737,6 +4724,27 @@ snapshots:
3737 4724
3738 reusify@1.1.0: {} 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 run-parallel@1.2.0: 4748 run-parallel@1.2.0:
3741 dependencies: 4749 dependencies:
3742 queue-microtask: 1.2.3 4750 queue-microtask: 1.2.3
@@ -3760,6 +4768,10 @@ snapshots:
3760 es-errors: 1.3.0 4768 es-errors: 1.3.0
3761 is-regex: 1.2.1 4769 is-regex: 1.2.1
3762 4770
4771+ saxes@6.0.0:
4772+ dependencies:
4773+ xmlchars: 2.2.0
4774+
3763 scheduler@0.28.0: {} 4775 scheduler@0.28.0: {}
3764 4776
3765 semver@6.3.2: {} 4777 semver@6.3.2: {}
@@ -3820,7 +4832,6 @@ snapshots:
3820 '@img/sharp-win32-ia32': 0.35.5 4832 '@img/sharp-win32-ia32': 0.35.5
3821 '@img/sharp-win32-x64': 0.35.5 4833 '@img/sharp-win32-x64': 0.35.5
3822 '@types/node': 20.19.43 4834 '@types/node': 20.19.43
3823− optional: true
3824 4835
3825 shebang-command@2.0.0: 4836 shebang-command@2.0.0:
3826 dependencies: 4837 dependencies:
@@ -3860,6 +4871,8 @@ snapshots:
3860 4871
3861 stable-hash@0.0.5: {} 4872 stable-hash@0.0.5: {}
3862 4873
4874+ std-env@4.3.0: {}
4875+
3863 stop-iteration-iterator@1.1.0: 4876 stop-iteration-iterator@1.1.0:
3864 dependencies: 4877 dependencies:
3865 es-errors: 1.3.0 4878 es-errors: 1.3.0
@@ -3918,6 +4931,10 @@ snapshots:
3918 4931
3919 strip-bom@3.0.0: {} 4932 strip-bom@3.0.0: {}
3920 4933
4934+ strip-indent@3.0.0:
4935+ dependencies:
4936+ min-indent: 1.0.1
4937+
3921 strip-json-comments@3.1.1: {} 4938 strip-json-comments@3.1.1: {}
3922 4939
3923 styled-jsx@5.1.6(@babel/core@7.29.13)(react@19.3.0): 4940 styled-jsx@5.1.6(@babel/core@7.29.13)(react@19.3.0):
@@ -3937,15 +4954,33 @@ snapshots:
3937 4954
3938 tapable@2.3.3: {} 4955 tapable@2.3.3: {}
3939 4956
4957+ tinybench@6.2.3: {}
4958+
4959+ tinyexec@1.3.1: {}
4960+
3940 tinyglobby@0.2.17: 4961 tinyglobby@0.2.17:
3941 dependencies: 4962 dependencies:
3942 fdir: 6.5.0(picomatch@4.0.7) 4963 fdir: 6.5.0(picomatch@4.0.7)
3943 picomatch: 4.0.7 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 to-regex-range@5.0.1: 4972 to-regex-range@5.0.1:
3946 dependencies: 4973 dependencies:
3947 is-number: 7.0.0 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 ts-api-utils@2.5.0(typescript@5.9.3): 4984 ts-api-utils@2.5.0(typescript@5.9.3):
3950 dependencies: 4985 dependencies:
3951 typescript: 5.9.3 4986 typescript: 5.9.3
@@ -4017,6 +5052,8 @@ snapshots:
4017 5052
4018 undici-types@6.21.0: {} 5053 undici-types@6.21.0: {}
4019 5054
5055+ undici@8.12.4: {}
5056+
4020 unrs-resolver@1.13.0: 5057 unrs-resolver@1.13.0:
4021 dependencies: 5058 dependencies:
4022 napi-postinstall: 0.3.8 5059 napi-postinstall: 0.3.8
@@ -4053,6 +5090,56 @@ snapshots:
4053 dependencies: 5090 dependencies:
4054 punycode: 2.3.1 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 which-boxed-primitive@1.1.1: 5143 which-boxed-primitive@1.1.1:
4057 dependencies: 5144 dependencies:
4058 is-bigint: 1.1.0 5145 is-bigint: 1.1.0
@@ -4098,8 +5185,14 @@ snapshots:
4098 dependencies: 5185 dependencies:
4099 isexe: 2.0.0 5186 isexe: 2.0.0
4100 5187
5188+ why-is-node-running@3.2.1: {}
5189+
4101 word-wrap@1.2.5: {} 5190 word-wrap@1.2.5: {}
4102 5191
5192+ xml-name-validator@5.0.0: {}
5193+
5194+ xmlchars@2.2.0: {}
5195+
4103 yallist@3.1.1: {} 5196 yallist@3.1.1: {}
4104 5197
4105 yocto-queue@0.1.0: {} 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 @import "tailwindcss"; 1 @import "tailwindcss";
2+@import "../styles/tokens.css";
2 3
3−:root { 4+@theme inline {
4− --background: #ffffff; 5+ --color-ivory-50: var(--color-ivory-50);
5− --foreground: #171717; 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 { 46+@layer base {
9− --color-background: var(--background); 47+ html {
10− --color-foreground: var(--foreground); 48+ scroll-behavior: smooth;
11− --font-sans: var(--font-geist-sans); 49+ -webkit-text-size-adjust: 100%;
12− --font-mono: var(--font-geist-mono); 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) { 147+@layer components {
16− :root { 148+ .prose-site p {
17− --background: #0a0a0a; 149+ color: var(--color-ink-700);
18− --foreground: #ededed; 150+ }
151+ .prose-site a {
152+ color: var(--color-navy-900);
19 } 153 }
20 } 154 }
21 155
22−body { 156+@keyframes fade-in {
23− background: var(--background); 157+ from {
24− color: var(--foreground); 158+ opacity: 0;
25− font-family: Arial, Helvetica, sans-serif; 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"; 1+import type { Metadata, Viewport } from "next";
2−import { Geist, Geist_Mono } from "next/font/google"; 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 import "./globals.css"; 7 import "./globals.css";
4 8
5−const geistSans = Geist({ 9+const garamond = EB_Garamond({
6− variable: "--font-geist-sans", 10+ variable: "--font-eb-garamond",
7− subsets: ["latin"], 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({ 17+const figtree = Figtree({
11− variable: "--font-geist-mono", 18+ variable: "--font-figtree",
12− subsets: ["latin"], 19+ subsets: ["latin", "latin-ext"],
20+ weight: ["400", "500", "600"],
21+ display: "swap",
13 }); 22 });
14 23
15 export const metadata: Metadata = { 24 export const metadata: Metadata = {
16− title: "Create Next App", 25+ metadataBase: new URL(siteUrl(site.seo.siteUrl)),
17− description: "Generated by create next app", 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 return ( 47 return (
22− <html 48+ <html lang="fr-CA" className={`${garamond.variable} ${figtree.variable} h-full antialiased`}>
23− lang="en" 49+ <body className="flex min-h-full flex-col">
24− className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} 50+ <a href="#contenu" className="skip-link">
25− > 51+ Aller au contenu
26− <body className="min-h-full flex flex-col">{children}</body> 52+ </a>
53+ <Header />
54+ <main id="contenu" className="flex-1">
55+ {children}
56+ </main>
57+ <Footer />
58+ </body>
27 </html> 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 "paths": { 21 "paths": {
22 "@/*": ["./src/*"] 22 "@/*": ["./src/*"]
23− } 23+ },
24+ "allowImportingTsExtensions": true
24 }, 25 },
25 "include": [ 26 "include": [
26 "next-env.d.ts", 27 "next-env.d.ts",
@@ -30,5 +31,5 @@
30 ".next/dev/types/**/*.ts", 31 ".next/dev/types/**/*.ts",
31 "**/*.mts" 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