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>
90 changed files +5,294 −139
added
.github/workflows/ci.yml
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + pull_request: | |
| 7 | + | |
| 8 | +jobs: | |
| 9 | + check: | |
| 10 | + runs-on: ubuntu-latest | |
| 11 | + env: | |
| 12 | + CONTACT_TRANSPORT: log | |
| 13 | + NEXT_PUBLIC_SITE_URL: http://127.0.0.1:3100 | |
| 14 | + steps: | |
| 15 | + - uses: actions/checkout@v4 | |
| 16 | + - uses: pnpm/action-setup@v4 | |
| 17 | + with: | |
| 18 | + version: 11 | |
| 19 | + - uses: actions/setup-node@v4 | |
| 20 | + with: | |
| 21 | + node-version: 22 | |
| 22 | + cache: pnpm | |
| 23 | + - run: pnpm install --frozen-lockfile | |
| 24 | + - run: pnpm lint | |
| 25 | + - run: pnpm typecheck | |
| 26 | + - run: pnpm test | |
| 27 | + - run: pnpm build | |
| 28 | + - run: pnpm exec playwright install --with-deps chromium webkit | |
| 29 | + - run: pnpm test:e2e | |
| 30 | + - uses: actions/upload-artifact@v4 | |
| 31 | + if: always() | |
| 32 | + with: | |
| 33 | + name: playwright-report | |
| 34 | + path: | | |
| 35 | + playwright-report | |
| 36 | + docs/screenshots | |
| 37 | + retention-days: 14 | |
modified
.gitignore
+7 −0
@@ -39,3 +39,10 @@ yarn-error.log* | ||
| 39 | 39 | # typescript |
| 40 | 40 | *.tsbuildinfo |
| 41 | 41 | next-env.d.ts |
| 42 | + | |
| 43 | +# tests & captures | |
| 44 | +/test-results/ | |
| 45 | +/playwright-report/ | |
| 46 | +/docs/screenshots/*.png | |
| 47 | +!/docs/screenshots/.gitkeep | |
| 48 | +.env.local | |
added
.prettierignore
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +.next | |
| 2 | +node_modules | |
| 3 | +pnpm-lock.yaml | |
| 4 | +test-results | |
| 5 | +playwright-report | |
| 6 | +docs/screenshots | |
added
.prettierrc
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +{ | |
| 2 | + "semi": true, | |
| 3 | + "singleQuote": false, | |
| 4 | + "trailingComma": "all", | |
| 5 | + "printWidth": 100, | |
| 6 | + "plugins": ["prettier-plugin-tailwindcss"] | |
| 7 | +} | |
modified
README.md
+41 −22
@@ -1,36 +1,55 @@ | ||
| 1 | −This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). | |
| 1 | +# PHI Conseil — site web | |
| 2 | 2 | |
| 3 | −## Getting Started | |
| 3 | +Site vitrine de PHI Conseil (« Stratégies · Financement · Réussite »), reproduit fidèlement à partir de la maquette client (`references/phi-conseil-homepage.jpeg`) et conçu comme un produit de production : Next.js 16 (App Router), React 19, TypeScript strict, Tailwind CSS 4, contenu administrable validé par Zod, formulaire de contact sécurisé, tests unitaires et de bout en bout. | |
| 4 | 4 | |
| 5 | −First, run the development server: | |
| 5 | +## Démarrage | |
| 6 | 6 | |
| 7 | 7 | ```bash |
| 8 | −npm run dev | |
| 9 | −# or | |
| 10 | −yarn dev | |
| 11 | −# or | |
| 12 | −pnpm dev | |
| 13 | −# or | |
| 14 | −bun dev | |
| 8 | +pnpm install | |
| 9 | +cp .env.example .env.local # ajuster au besoin (transport courriel, URL) | |
| 10 | +pnpm dev # http://localhost:3000 | |
| 15 | 11 | ``` |
| 16 | 12 | |
| 17 | −Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | |
| 13 | +Prérequis : Node.js ≥ 20 (testé avec 26), pnpm ≥ 10. | |
| 18 | 14 | |
| 19 | −You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. | |
| 15 | +## Scripts | |
| 20 | 16 | |
| 21 | −This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. | |
| 17 | +| Commande | Rôle | | |
| 18 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | |
| 19 | +| `pnpm dev` | serveur de développement | | |
| 20 | +| `pnpm build` / `pnpm start` | build de production / serveur de production (`-p <port>`) | | |
| 21 | +| `pnpm lint` | ESLint (config Next + règles React Compiler) | | |
| 22 | +| `pnpm typecheck` | `tsc --noEmit` | | |
| 23 | +| `pnpm test` | tests unitaires Vitest (schémas, validation, sécurité, API, composants) | | |
| 24 | +| `pnpm test:e2e` | Playwright : navigation, menu mobile, formulaire, débordements, axe, captures (`docs/screenshots/`) ; nécessite `pnpm build` au préalable et `pnpm exec playwright install chromium webkit` la première fois | | |
| 25 | +| `pnpm check` | lint + typecheck + tests + build | | |
| 26 | +| `pnpm icons` | régénère favicon / icônes à partir du logo (spirale dorée) | | |
| 27 | +| `pnpm format` | Prettier | | |
| 22 | 28 | |
| 23 | −## Learn More | |
| 29 | +## Structure | |
| 24 | 30 | |
| 25 | −To learn more about Next.js, take a look at the following resources: | |
| 26 | − | |
| 27 | −- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |
| 28 | −- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |
| 31 | +``` | |
| 32 | +content/ contenu administrable (site.json, home.json, pages.json) — voir docs/CLIENT_GUIDE.md | |
| 33 | +src/app/ routes (accueil, services, a-propos, temoignages, nous-joindre, confidentialite, api/contact) | |
| 34 | +src/components/ layout/ sections/ ui/ graphics/ forms/ | |
| 35 | +src/lib/ content.ts (schémas + chargeur), env.ts, validation.ts, security.ts, mail.ts, seo.ts, spiral.ts | |
| 36 | +src/styles/ tokens.css (design tokens : couleurs échantillonnées, typo, espacements…) | |
| 37 | +tests/unit, tests/e2e | |
| 38 | +docs/ plan, déploiement, guide client, rapport QA, registre des actifs, captures | |
| 39 | +references/ maquette client | |
| 40 | +``` | |
| 29 | 41 | |
| 30 | −You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! | |
| 42 | +## Principes | |
| 31 | 43 | |
| 32 | −## Deploy on Vercel | |
| 44 | +- **Fidélité** : couleurs échantillonnées sur la maquette, structure DOM = ordre visuel sur tous les écrans, motifs (spirale de Fibonacci, vagues, feuillage) en SVG. | |
| 45 | +- **Honnêteté du contenu** : aucune coordonnée, témoignage ou réalisation inventés. Les témoignages ne sont publiés que s'ils sont `approved: true` et la section `visible: true` ; les photos de la maquette sont remplacées par des illustrations vectorielles tant que le client n'a pas fourni d'images sous licence (`docs/ASSET_REGISTER.md`). | |
| 46 | +- **Sécurité** : CSP, en-têtes durcis, validation Zod client + serveur, honeypot, délai anti-robot, limitation de débit, dédoublonnage, journalisation sans donnée personnelle, secrets uniquement côté serveur. | |
| 47 | +- **Accessibilité** : WCAG 2.2 AA visé (axe sans violation sur toutes les pages), navigation clavier, `prefers-reduced-motion`, lien d'évitement, états focus visibles. | |
| 33 | 48 | |
| 34 | −The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |
| 49 | +## Documentation | |
| 35 | 50 | |
| 36 | −Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. | |
| 51 | +- `docs/IMPLEMENTATION_PLAN.md` — audit, architecture, décisions, critères d'acceptation | |
| 52 | +- `docs/DEPLOYMENT.md` — hébergement, DNS, HTTPS, variables, sauvegardes, rollback | |
| 53 | +- `docs/CLIENT_GUIDE.md` — modifier textes, services, témoignages, images, coordonnées | |
| 54 | +- `docs/QA_REPORT.md` — résultats réels des vérifications, limites, points à valider | |
| 55 | +- `docs/ASSET_REGISTER.md` — provenance, licence et statut de chaque actif | |
added
content/home.json
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +{ | |
| 2 | + "hero": { | |
| 3 | + "titleLines": ["Transformer", "vos idées en projets", "concrets"], | |
| 4 | + "subtitle": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.", | |
| 5 | + "cta": { "label": "Découvrir nos services", "href": "/services" }, | |
| 6 | + "image": { | |
| 7 | + "src": "", | |
| 8 | + "alt": "Bureau lumineux avec une plante, une tasse et un ordinateur portable" | |
| 9 | + } | |
| 10 | + }, | |
| 11 | + "services": { | |
| 12 | + "visible": true, | |
| 13 | + "title": "Nos services", | |
| 14 | + "items": [ | |
| 15 | + { | |
| 16 | + "slug": "recherche-de-subventions", | |
| 17 | + "icon": "search", | |
| 18 | + "title": "Recherche de subventions", | |
| 19 | + "summary": "Trouvez les programmes adaptés à vos projets.", | |
| 20 | + "description": "" | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "slug": "redaction-et-revision-de-demandes", | |
| 24 | + "icon": "document-edit", | |
| 25 | + "title": "Rédaction et révision de demandes", | |
| 26 | + "summary": "Présentez des projets solides et convaincants.", | |
| 27 | + "description": "" | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "slug": "redaction-et-revision-de-rapports", | |
| 31 | + "icon": "chart", | |
| 32 | + "title": "Rédaction et révision de rapports", | |
| 33 | + "summary": "Communiquez efficacement vos résultats.", | |
| 34 | + "description": "" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "slug": "reddition-de-comptes", | |
| 38 | + "icon": "check-circle", | |
| 39 | + "title": "Reddition de comptes", | |
| 40 | + "summary": "Assurez une gestion rigoureuse et conforme.", | |
| 41 | + "description": "" | |
| 42 | + } | |
| 43 | + ] | |
| 44 | + }, | |
| 45 | + "about": { | |
| 46 | + "visible": true, | |
| 47 | + "overtitle": "À propos de PHI Conseil", | |
| 48 | + "title": "Une approche stratégique pour maximiser vos opportunités", | |
| 49 | + "paragraph": "Forte d'une expertise en financement et en gestion de projets, Phi Conseil vous accompagne avec rigueur, créativité et bienveillance, pour donner vie à vos ambitions.", | |
| 50 | + "cta": { "label": "En savoir plus", "href": "/a-propos" }, | |
| 51 | + "benefits": [ | |
| 52 | + "Une expertise reconnue", | |
| 53 | + "Une approche personnalisée", | |
| 54 | + "Un accompagnement de A à Z", | |
| 55 | + "Un engagement envers votre réussite" | |
| 56 | + ], | |
| 57 | + "image": { | |
| 58 | + "src": "", | |
| 59 | + "alt": "Vase transparent avec feuillage posé sur des livres, lumière douce" | |
| 60 | + } | |
| 61 | + }, | |
| 62 | + "testimonials": { | |
| 63 | + "visible": false, | |
| 64 | + "previewOnly": true, | |
| 65 | + "items": [ | |
| 66 | + { | |
| 67 | + "quote": "« Grâce à l'accompagnement de Phi Conseil, nous avons obtenu un financement majeur pour notre projet. Leur professionnalisme et leur disponibilité font toute la différence. »", | |
| 68 | + "author": "Exemple de maquette (non vérifié)", | |
| 69 | + "role": "Témoignage fictif de la capture — à remplacer par un avis autorisé", | |
| 70 | + "approved": false | |
| 71 | + } | |
| 72 | + ] | |
| 73 | + }, | |
| 74 | + "callToAction": { | |
| 75 | + "visible": true, | |
| 76 | + "title": "Parlons de vos projets !", | |
| 77 | + "subtitle": "Contactez-nous dès aujourd'hui pour discuter de vos besoins.", | |
| 78 | + "cta": { "label": "Nous joindre", "href": "/nous-joindre" } | |
| 79 | + } | |
| 80 | +} | |
added
content/pages.json
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "services": { | |
| 3 | + "title": "Nos services", | |
| 4 | + "intro": "Quatre services complémentaires pour financer, documenter et rendre compte de vos projets.", | |
| 5 | + "seoDescription": "Recherche de subventions, rédaction et révision de demandes et de rapports, reddition de comptes : les services de PHI Conseil." | |
| 6 | + }, | |
| 7 | + "about": { | |
| 8 | + "title": "À propos", | |
| 9 | + "intro": "Une approche stratégique pour maximiser vos opportunités.", | |
| 10 | + "seoDescription": "Découvrez l'approche de PHI Conseil : expertise, personnalisation, accompagnement de A à Z et engagement envers votre réussite.", | |
| 11 | + "body": [ | |
| 12 | + "Forte d'une expertise en financement et en gestion de projets, Phi Conseil vous accompagne avec rigueur, créativité et bienveillance, pour donner vie à vos ambitions." | |
| 13 | + ] | |
| 14 | + }, | |
| 15 | + "testimonials": { | |
| 16 | + "title": "Témoignages", | |
| 17 | + "intro": "Les témoignages publiés sur cette page sont ceux de clients ayant autorisé leur diffusion.", | |
| 18 | + "emptyMessage": "Aucun témoignage n'est publié pour le moment.", | |
| 19 | + "seoDescription": "Témoignages de clients de PHI Conseil." | |
| 20 | + }, | |
| 21 | + "contact": { | |
| 22 | + "title": "Nous joindre", | |
| 23 | + "intro": "Décrivez-nous votre projet ou votre besoin : nous vous répondrons dans les meilleurs délais.", | |
| 24 | + "seoDescription": "Contactez PHI Conseil pour discuter de vos besoins en recherche de subventions, rédaction de demandes et reddition de comptes.", | |
| 25 | + "subjects": [ | |
| 26 | + "Recherche de subventions", | |
| 27 | + "Rédaction ou révision d'une demande", | |
| 28 | + "Rédaction ou révision d'un rapport", | |
| 29 | + "Reddition de comptes", | |
| 30 | + "Autre demande" | |
| 31 | + ], | |
| 32 | + "consentLabel": "J'accepte que mes renseignements soient utilisés pour traiter ma demande, conformément à la politique de confidentialité.", | |
| 33 | + "successTitle": "Message envoyé", | |
| 34 | + "successMessage": "Merci ! Votre message a bien été transmis. Nous vous répondrons dans les meilleurs délais." | |
| 35 | + }, | |
| 36 | + "privacy": { | |
| 37 | + "title": "Politique de confidentialité et mentions légales", | |
| 38 | + "seoDescription": "Politique de confidentialité et mentions légales de PHI Conseil.", | |
| 39 | + "lastUpdated": "2026-11-08", | |
| 40 | + "reviewed": false | |
| 41 | + } | |
| 42 | +} | |
added
content/site.json
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +{ | |
| 2 | + "brand": { | |
| 3 | + "name": "PHI Conseil", | |
| 4 | + "tagline": "STRATÉGIES · FINANCEMENT · RÉUSSITE", | |
| 5 | + "footerSignature": "DES STRATÉGIES QUI DONNENT VIE À VOS PROJETS.", | |
| 6 | + "legalName": "Phi Conseil" | |
| 7 | + }, | |
| 8 | + "navigation": [ | |
| 9 | + { "label": "Accueil", "href": "/" }, | |
| 10 | + { "label": "Nos services", "href": "/services" }, | |
| 11 | + { "label": "À propos", "href": "/a-propos" }, | |
| 12 | + { "label": "Témoignages", "href": "/temoignages" }, | |
| 13 | + { "label": "Nous joindre", "href": "/nous-joindre" } | |
| 14 | + ], | |
| 15 | + "headerCta": { "label": "Contactez-nous", "href": "/nous-joindre" }, | |
| 16 | + "contact": { | |
| 17 | + "email": "", | |
| 18 | + "phone": "", | |
| 19 | + "address": "", | |
| 20 | + "region": "Québec, Canada" | |
| 21 | + }, | |
| 22 | + "social": { | |
| 23 | + "linkedin": "" | |
| 24 | + }, | |
| 25 | + "legalLinks": [ | |
| 26 | + { "label": "Confidentialité", "href": "/confidentialite" }, | |
| 27 | + { "label": "Mentions légales", "href": "/confidentialite#mentions-legales" } | |
| 28 | + ], | |
| 29 | + "seo": { | |
| 30 | + "siteUrl": "https://www.example.invalid", | |
| 31 | + "defaultTitle": "PHI Conseil — Stratégies, financement, réussite", | |
| 32 | + "titleTemplate": "%s | PHI Conseil", | |
| 33 | + "defaultDescription": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.", | |
| 34 | + "locale": "fr_CA" | |
| 35 | + } | |
| 36 | +} | |
added
docs/ASSET_REGISTER.md
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +# Registre des actifs — PHI Conseil | |
| 2 | + | |
| 3 | +| Actif | Emplacement | Provenance | Licence | Statut | | |
| 4 | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------- | | |
| 5 | +| Maquette de l'accueil | `references/phi-conseil-homepage.jpeg` | fournie par le client (IMG_1079.JPG, 1024 × 1536) | usage interne de référence | référence, non publiée | | |
| 6 | +| Logo (spirale dorée + rectangle d'or) | `src/components/graphics/Logo.tsx`, `src/lib/spiral.ts`, `public/logo-mark.svg` | **recréé en SVG** d'après la maquette (construction Fibonacci 1-1-2-3-5-8-13-21) | œuvre dérivée de l'identité du client | ⚠️ à valider par le client ; remplacer par le vectoriel officiel s'il existe | | |
| 7 | +| Favicon / icônes | `src/app/icon.svg`, `src/app/favicon.ico`, `src/app/apple-icon.png`, `public/icon-192.png`, `public/icon-512.png` | générés depuis le logo par `scripts/generate-icons.mts` | idem logo | dérivés du logo, à régénérer si le logo change | | |
| 8 | +| Image Open Graph | `src/app/opengraph-image.tsx` | générée au build (texte + spirale) | — | OK | | |
| 9 | +| Photo du hero (bureau, plante, tasse, ordinateur) | `src/components/graphics/Placeholders.tsx` (`DeskScene`) | **illustration vectorielle de remplacement** créée pour le projet ; la photo de la maquette n'a pas été reprise (droits inconnus) | créée pour le projet | ⚠️ placeholder — fournir une photo sous licence | | |
| 10 | +| Photo « À propos » (vase, feuillage, livres) | `src/components/graphics/Placeholders.tsx` (`VaseScene`) | idem | créée pour le projet | ⚠️ placeholder — fournir une photo sous licence | | |
| 11 | +| Motifs (vagues, feuillage, spirales décoratives) | `src/components/graphics/*` | créés pour le projet | — | OK | | |
| 12 | +| Icônes de services et d'interface | `src/components/ui/ServiceIcon.tsx`, `Icons.tsx` | dessinées pour le projet (trait 1,5) | — | OK | | |
| 13 | +| Police titres **EB Garamond** | `next/font/google` (auto-hébergée au build) | Georg Duffner / Octavio Pardo, Google Fonts | SIL Open Font License 1.1 | OK ; remplacer par la police officielle du client si elle existe | | |
| 14 | +| Police corps **Figtree** | `next/font/google` | Erik Kennedy, Google Fonts | SIL Open Font License 1.1 | OK | | |
| 15 | +| Témoignage « Marie-Claude Deschamps, Directrice générale » | `content/home.json` (`approved: false`) | texte de la maquette, **identité non vérifiée** | — | ⚠️ NON PUBLIÉ ; obtenir un avis réel et une autorisation écrite | | |
| 16 | +| Textes marketing (hero, services, à propos, bandeau) | `content/*.json` | repris mot pour mot de la maquette | client | à confirmer par le client | | |
| 17 | +| Textes juridiques (confidentialité, mentions légales) | `src/app/(site)/confidentialite/page.tsx`, `pages.json` | gabarit rédigé pour le projet, décrit les pratiques réelles du site | — | ⚠️ à faire valider (`privacy.reviewed=false`) | | |
| 18 | +| Coordonnées, LinkedIn | `content/site.json` | **vides** (non fournies) | — | à compléter par le client | | |
added
docs/CLIENT_GUIDE.md
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +# Guide de gestion des contenus — PHI Conseil | |
| 2 | + | |
| 3 | +Tous les textes, services, avantages, témoignages, coordonnées et paramètres SEO se trouvent dans trois fichiers du dossier `content/`. Ils se modifient avec n'importe quel éditeur de texte (ou l'interface web du dépôt Git) ; aucune modification du code n'est nécessaire. À chaque mise à jour, le site est reconstruit et vérifié : une faute de structure (guillemet manquant, champ oublié) bloque la publication avec un message précis au lieu de casser le site. | |
| 4 | + | |
| 5 | +> Règle d'or : conservez les guillemets `"…"` et les virgules ; modifiez uniquement ce qui se trouve entre les guillemets, ou ajoutez/retirez des éléments complets dans les listes. | |
| 6 | + | |
| 7 | +## `content/site.json` — identité et coordonnées | |
| 8 | + | |
| 9 | +| Champ | Usage | | |
| 10 | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | |
| 11 | +| `brand.name`, `brand.tagline`, `brand.footerSignature`, `brand.legalName` | nom, signature de l'en-tête, phrase du pied de page, raison sociale du copyright | | |
| 12 | +| `navigation[]` | liens du menu (ordre = ordre affiché) | | |
| 13 | +| `headerCta` | bouton « Contactez-nous → » | | |
| 14 | +| `contact.email`, `contact.phone`, `contact.address`, `contact.region` | coordonnées ; **laissez `""` pour masquer** l'icône courriel / la ligne correspondante | | |
| 15 | +| `social.linkedin` | URL complète du profil LinkedIn ; `""` = icône masquée | | |
| 16 | +| `legalLinks[]` | liens juridiques du pied de page | | |
| 17 | +| `seo.siteUrl`, `seo.defaultTitle`, `seo.titleTemplate`, `seo.defaultDescription` | métadonnées par défaut | | |
| 18 | + | |
| 19 | +## `content/home.json` — page d'accueil | |
| 20 | + | |
| 21 | +- `hero.titleLines` : les lignes du grand titre (1 à 4). `hero.subtitle`, `hero.cta`. | |
| 22 | +- `hero.image.src` : chemin d'une photo placée dans `public/images/` (ex. `"/images/hero.jpg"`). Vide = illustration vectorielle de remplacement. Toujours renseigner `alt` (description pour les lecteurs d'écran). | |
| 23 | +- `services.items[]` : `slug` (identifiant URL, lettres minuscules et tirets), `icon` (`search`, `document-edit`, `chart`, `check-circle`), `title`, `summary` (accueil), `description` (page Services, facultatif). L'ordre du tableau = ordre d'affichage. | |
| 24 | +- `about` : `overtitle`, `title`, `paragraph`, `cta`, `benefits[]` (coches dorées), `image`. | |
| 25 | +- `testimonials.visible` : `true` pour afficher la section. **Un témoignage n'apparaît que si `approved: true`** (autorisation écrite de la personne citée). `previewOnly` permet d'afficher les exemples non approuvés uniquement sur un site de démonstration. | |
| 26 | +- `callToAction` : bandeau bleu final. | |
| 27 | +- Chaque section possède `visible` pour la masquer sans la supprimer. | |
| 28 | + | |
| 29 | +## `content/pages.json` — pages internes | |
| 30 | + | |
| 31 | +Titres, introductions, descriptions SEO des pages Services, À propos, Témoignages, Nous joindre (liste `subjects` du menu déroulant, texte de consentement, message de confirmation) et Confidentialité (`lastUpdated` au format AAAA-MM-JJ ; passez `reviewed` à `true` une fois le texte validé juridiquement pour retirer l'avertissement). | |
| 32 | + | |
| 33 | +## Images | |
| 34 | + | |
| 35 | +1. Préparer un JPEG/WebP de bonne qualité (hero : ≥ 1600 px de large ; À propos : format vertical ≥ 1000 × 1250 px). | |
| 36 | +2. Le déposer dans `public/images/`. | |
| 37 | +3. Renseigner `image.src` et `image.alt` dans `home.json`. | |
| 38 | +4. Noter la provenance et la licence dans `docs/ASSET_REGISTER.md`. | |
| 39 | + Le site convertit et redimensionne automatiquement (AVIF/WebP, tailles adaptées). | |
| 40 | + | |
| 41 | +## Logo et favicon | |
| 42 | + | |
| 43 | +Le logo actuel est une recréation SVG de la spirale dorée. Pour utiliser le logo officiel : remplacer le composant `src/components/graphics/Logo.tsx` par le SVG fourni, puis `pnpm icons` pour régénérer favicon et icônes (ou fournir directement `src/app/icon.svg`, `src/app/apple-icon.png`). | |
| 44 | + | |
| 45 | +## Publier une modification | |
| 46 | + | |
| 47 | +Sur le serveur d'hébergement : `pnpm build && pm2 restart phi-conseil` (voir `docs/DEPLOYMENT.md`), puis `git commit` pour conserver l'historique. Une erreur de contenu s'affiche au build sous la forme `Contenu invalide dans content/home.json : hero.subtitle : …`. | |
added
docs/DEPLOYMENT.md
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +# Déploiement — PHI Conseil | |
| 2 | + | |
| 3 | +## Cible | |
| 4 | + | |
| 5 | +Application Node.js (`next start`) derrière un reverse proxy TLS. Aucune base de données. Le build est statique pour toutes les pages ; seule `POST /api/contact` s'exécute à la demande. | |
| 6 | + | |
| 7 | +| Besoin | Valeur | | |
| 8 | +| -------- | ---------------------------------------------------------------- | | |
| 9 | +| Node.js | ≥ 20 (testé 26) | | |
| 10 | +| RAM | ~150 Mo au repos | | |
| 11 | +| Port | `PORT` (défaut 3000) | | |
| 12 | +| Fichiers | `.next/`, `public/`, `content/`, `node_modules/`, `package.json` | | |
| 13 | + | |
| 14 | +## Variables d'environnement | |
| 15 | + | |
| 16 | +Voir `.env.example`. En production : | |
| 17 | + | |
| 18 | +- `NEXT_PUBLIC_SITE_URL` — URL publique (canonical, sitemap, OG). **Inlinée au build** : rebuild si elle change. | |
| 19 | +- `NEXT_PUBLIC_CONTENT_PREVIEW` — `0` en production. `1` affiche les témoignages de maquette avec un bandeau « exemple non vérifié » (sites de démonstration uniquement ; inlinée au build). | |
| 20 | +- `CONTACT_TRANSPORT=smtp` + `CONTACT_TO`, `CONTACT_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS` — sans ces valeurs le formulaire fonctionne mais **n'envoie aucun courriel** (`CONTACT_TRANSPORT=log`) et l'utilisateur en est informé dans l'écran de confirmation. | |
| 21 | +- `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_S` — anti-abus (défaut 5 / 600 s par IP). Le limiteur est en mémoire : valable pour une instance unique (PM2 `fork`). Pour plusieurs instances, brancher Redis dans `src/lib/security.ts`. | |
| 22 | + | |
| 23 | +Le proxy doit transmettre `X-Forwarded-For` (Caddy et nginx le font par défaut) pour que la limitation par IP soit correcte. | |
| 24 | + | |
| 25 | +## Procédure générique (VPS / serveur Node) | |
| 26 | + | |
| 27 | +```bash | |
| 28 | +git clone <dépôt> phi-conseil && cd phi-conseil | |
| 29 | +pnpm install --frozen-lockfile | |
| 30 | +cp .env.example .env.local && $EDITOR .env.local | |
| 31 | +pnpm build | |
| 32 | +pm2 start pnpm --name phi-conseil -- start -p 3000 # ou systemd | |
| 33 | +``` | |
| 34 | + | |
| 35 | +Reverse proxy (exemple Caddy) : `phiconseil.example { reverse_proxy 127.0.0.1:3000 }` — Caddy obtient et renouvelle le certificat TLS automatiquement. HSTS est déjà émis par l'application. | |
| 36 | + | |
| 37 | +### Vercel (alternative) | |
| 38 | + | |
| 39 | +Importer le dépôt, définir les variables d'environnement, framework Next.js détecté automatiquement. Le limiteur en mémoire n'est pas fiable en serverless : remplacer par Upstash/Redis ou le middleware de limitation du fournisseur. | |
| 40 | + | |
| 41 | +## Procédure MacLustr (`mld`) — site de démonstration test.spboucher.ai | |
| 42 | + | |
| 43 | +1. Manifeste `M1M32:~/dispatch/apps/phi-conseil.json` (dir `~/apps/phi-conseil`, PM2 `pnpm start -p 8520`, `tunnel.domain = test.spboucher.ai`, hooks `post_sync` : `pnpm install --frozen-lockfile && pnpm build`). | |
| 44 | +2. `mld stage ~/Desktop/phi-conseil phi-conseil` puis `mld deploy phi-conseil`. | |
| 45 | +3. DNS GoDaddy `A test.spboucher.ai → 51.161.112.61` (passerelle BHS64) — déjà en place ; `mld` pose la route Caddy et vérifie la santé publique. | |
| 46 | +4. Mises à jour : modifier **sur le nœud** (clone git), `pnpm build && pm2 restart phi-conseil`, puis `git commit/push` depuis le nœud (règle remote-first MacLustr). | |
| 47 | + | |
| 48 | +## Sauvegardes et restauration | |
| 49 | + | |
| 50 | +- Le code et le contenu (`content/*.json`) sont versionnés dans Git : la sauvegarde est le dépôt distant. Restaurer = `git checkout <tag>` + `pnpm install && pnpm build`. | |
| 51 | +- Les messages du formulaire ne sont pas stockés par le site : ils vivent dans la boîte courriel destinataire (sauvegarde relevant du fournisseur de messagerie). | |
| 52 | +- Secrets (`.env.local`) : conserver une copie chiffrée hors du dépôt (gestionnaire de mots de passe). | |
| 53 | + | |
| 54 | +## Retour arrière | |
| 55 | + | |
| 56 | +1. `git log` → identifier la dernière version saine (chaque mise en production est taguée `vX.Y.Z`). | |
| 57 | +2. `git checkout vX.Y.Z && pnpm install --frozen-lockfile && pnpm build && pm2 restart phi-conseil`. | |
| 58 | +3. Vérifier `GET /` (200) et `GET /api/contact` (405 attendu) ; sur MacLustr, `mld status --live phi-conseil`. | |
| 59 | + | |
| 60 | +## Surveillance minimale | |
| 61 | + | |
| 62 | +- Santé : `GET /` doit répondre 200 (healthcheck PM2/mld). | |
| 63 | +- Journaux : `pm2 logs phi-conseil` — les lignes `[contact]` ne contiennent qu'une empreinte d'IP, jamais le contenu des messages. | |
| 64 | +- Erreurs de rendu : digest côté serveur (`console.error`) corrélable à l'écran d'erreur client. Un service d'erreurs (Sentry…) peut être ajouté dans `src/app/error.tsx` et `instrumentation.ts` ; non inclus par défaut pour éviter tout traceur non consenti. | |
| 65 | + | |
| 66 | +## Mise à jour des dépendances | |
| 67 | + | |
| 68 | +`pnpm outdated` puis `pnpm update --latest` en suivant les notes de migration de Next.js ; relancer `pnpm check` et `pnpm test:e2e` avant mise en production. Audit : `pnpm audit`. | |
added
docs/IMPLEMENTATION_PLAN.md
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +# Plan d'implémentation — PHI Conseil | |
| 2 | + | |
| 3 | +Date : 2026-11-08. Référence visuelle : `references/phi-conseil-homepage.jpeg` (1024 × 1536 px, capture desktop fournie par le client). | |
| 4 | + | |
| 5 | +## 1. Audit initial | |
| 6 | + | |
| 7 | +| Élément | État constaté | | |
| 8 | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 9 | +| Dépôt | Vide à l'origine (créé aujourd'hui). Scaffold `create-next-app` 16.4.5, TypeScript strict, Tailwind 4, ESLint 9, pnpm 11. | | |
| 10 | +| Capture | Présente et inspectée. Couleurs échantillonnées par région (médiane / mode / moyenne des pixels sombres) — voir `src/styles/tokens.css`. | | |
| 11 | +| Actifs client | **Aucun** fichier original fourni : pas de logo vectoriel, pas de photos sous licence, pas de polices, pas de coordonnées, pas de textes juridiques, pas de témoignage autorisé. | | |
| 12 | +| Hébergement | Non précisé. Cible par défaut : Node 20+ (`next start` derrière un reverse proxy TLS). Un déploiement sur le cluster MacLustr (`mld`) ou sur Vercel est documenté dans `docs/DEPLOYMENT.md`. | | |
| 13 | + | |
| 14 | +### Inconnues bloquantes (à valider par le client) | |
| 15 | + | |
| 16 | +1. Logo vectoriel officiel (sinon le SVG recréé `src/components/graphics/Logo.tsx` doit être approuvé). | |
| 17 | +2. Photos sous licence pour le hero et la section À propos (les illustrations livrées sont des **placeholders vectoriels** dans la palette, marqués comme tels dans `docs/ASSET_REGISTER.md`). | |
| 18 | +3. Coordonnées réelles (courriel, téléphone, adresse, LinkedIn) : laissées vides, les icônes/liens correspondants sont masqués tant que les champs du contenu sont vides. | |
| 19 | +4. Témoignage(s) autorisé(s) : la section est **masquée en production** (`testimonials.visible=false`) ; le texte de la maquette est conservé uniquement comme exemple de prévisualisation marqué « non vérifié ». | |
| 20 | +5. Textes juridiques (confidentialité, mentions légales) : gabarit fourni, à faire valider par un conseiller juridique. | |
| 21 | +6. Fournisseur de messagerie transactionnelle (SMTP) et adresses expéditeur/destinataire. | |
| 22 | +7. Choix CMS : voir §4. | |
| 23 | + | |
| 24 | +## 2. Architecture | |
| 25 | + | |
| 26 | +``` | |
| 27 | +content/ ← contenu administrable (JSON validé par Zod au build et à l'exécution) | |
| 28 | + site.json ← identité, navigation, coordonnées, liens sociaux, SEO par défaut | |
| 29 | + home.json ← hero, services, à-propos, témoignages, bandeau, visibilité des sections | |
| 30 | + pages.json ← pages internes (services, à propos, témoignages, nous joindre, confidentialité) | |
| 31 | +src/ | |
| 32 | + app/ | |
| 33 | + layout.tsx ← polices, métadonnées par défaut, Header/Footer | |
| 34 | + (site)/page.tsx ← accueil (7 sections) | |
| 35 | + (site)/services|a-propos|temoignages|nous-joindre|confidentialite/page.tsx | |
| 36 | + api/contact/route.ts ← POST : validation Zod, honeypot, limitation de débit, envoi SMTP | |
| 37 | + sitemap.ts, robots.ts, icon.svg, opengraph-image.tsx | |
| 38 | + components/ | |
| 39 | + layout/ Header, MobileNav, Footer | |
| 40 | + sections/ Hero, Services, About, Testimonials, CallToAction | |
| 41 | + graphics/ Logo, GoldenSpiral, WaveDivider, BotanicalMotif, placeholders photo | |
| 42 | + ui/ Button, Container, SectionTitle, Icon… | |
| 43 | + forms/ ContactForm (client) | |
| 44 | + lib/ content.ts (chargeur + schémas), env.ts, validation.ts, security.ts (rate limit, sanitisation), mail.ts, seo.ts | |
| 45 | + styles/ tokens.css (design tokens), globals.css | |
| 46 | +tests/ | |
| 47 | + unit/ Vitest + Testing Library (schémas, validation, rate limit, composants) | |
| 48 | + e2e/ Playwright (navigation, menu mobile, formulaire, liens, axe, captures) | |
| 49 | +docs/ IMPLEMENTATION_PLAN, DEPLOYMENT, CLIENT_GUIDE, QA_REPORT, ASSET_REGISTER | |
| 50 | +``` | |
| 51 | + | |
| 52 | +## 3. Design system | |
| 53 | + | |
| 54 | +- Tokens CSS (`src/styles/tokens.css`) : couleurs échantillonnées, échelle typographique `clamp()`, espacements, rayons, ombres, mouvement. Exposés à Tailwind 4 via `@theme`. | |
| 55 | +- Titres : **EB Garamond** (OFL) ; corps : **Figtree** (OFL). Chargées par `next/font/google` (auto-hébergées au build, `display: swap`). À remplacer par les polices officielles du client si elles existent. | |
| 56 | +- Motifs : spirale de Fibonacci (carrés + arcs de quart de cercle, ratio 1,618 approché par la suite 1-1-2-3-5-8-13-21), vagues, feuillage — tous en SVG inline. | |
| 57 | + | |
| 58 | +## 4. Contenu administrable — décision | |
| 59 | + | |
| 60 | +Le cahier des charges recommande un CMS maintenu (Payload / Sanity) tout en demandant d'éviter de surdimensionner si une solution statique convient et de **demander validation**. | |
| 61 | + | |
| 62 | +Choix livré : **contenu structuré en fichiers JSON versionnés** (`content/*.json`), validé par Zod, modifiable sans toucher au code applicatif, avec guide client. Raisons : aucun hébergement de base de données ni de compte tiers à gérer, pas de surface d'authentification à sécuriser, sauvegarde = historique Git, coût nul, déploiement statique possible. | |
| 63 | + | |
| 64 | +Compromis documentés : l'édition exige un éditeur de texte ou l'interface web Git ; pas d'aperçu WYSIWYG ni de gestion de médias par glisser-déposer. Chemin d'évolution : `src/lib/content.ts` est l'unique point d'accès au contenu ; brancher Payload (SQLite/Postgres, intégré à Next) ou Sanity consiste à remplacer ce module en conservant les schémas Zod. **Décision à valider par le client.** | |
| 65 | + | |
| 66 | +## 5. Étapes | |
| 67 | + | |
| 68 | +1. Audit + plan (ce document). ✔ | |
| 69 | +2. Design system : tokens, polices, UI de base, logo et motifs SVG. | |
| 70 | +3. Accueil fidèle : 7 sections, comparaison Playwright à 1024 px (largeur de la capture) puis 1440 px. | |
| 71 | +4. Pages internes + chargeur de contenu + visibilité des sections. | |
| 72 | +5. Formulaire de contact + API + messagerie SMTP configurable + protection antispam. | |
| 73 | +6. Validation : lint, typecheck, Vitest, Playwright (6 largeurs, axe), build de production. | |
| 74 | +7. Livraison : README, .env.example, docs/DEPLOYMENT, CLIENT_GUIDE, QA_REPORT, ASSET_REGISTER. | |
| 75 | + | |
| 76 | +## 6. Critères d'acceptation par section (checklist) | |
| 77 | + | |
| 78 | +- [ ] En-tête : logo + nom + signature, 5 liens, CTA bleu arrondi « Contactez-nous → », lien actif souligné, menu mobile accessible, en-tête collant sobre. | |
| 79 | +- [ ] Hero : fond ivoire, H1 serif 3 lignes, sous-titre, bouton bleu, illustration à droite avec spirale dorée superposée, vagues ivoire/bleu pâle en bas. | |
| 80 | +- [ ] Services : titre centré entre filets dorés, 4 cartes (disque bleu-gris + icône au trait), textes exacts. | |
| 81 | +- [ ] À propos : image verticale à gauche avec courbe ivoire, surtitre doré, H2, paragraphe, bouton contour, 4 bénéfices avec coches dorées séparés par un filet vertical. | |
| 82 | +- [ ] Témoignages : encadré bleu pâle arrondi, guillemets dorés, serif italique, pagination ; slider accessible ; masqué en prod sans avis validé. | |
| 83 | +- [ ] Bandeau : fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés à droite. | |
| 84 | +- [ ] Pied de page : logo + nom, liens, icônes LinkedIn/courriel (si fournis), copyright dynamique, signature, liens légaux. | |
| 85 | +- [ ] Responsive 360/390/768/1024/1440/1920 sans débordement horizontal. | |
| 86 | +- [ ] Formulaire : validation client/serveur, honeypot, rate limit, SMTP configurable, états succès/erreur, tests. | |
| 87 | +- [ ] Lint, typecheck, tests, build, axe : verts ou écarts documentés dans `docs/QA_REPORT.md`. | |
added
docs/screenshots/.gitkeep
+0 −0
modified
next.config.ts
+9 −1
@@ -1,9 +1,17 @@ | ||
| 1 | 1 | import type { NextConfig } from "next"; |
| 2 | +import { securityHeaders } from "./src/lib/security"; | |
| 2 | 3 | |
| 3 | 4 | const nextConfig: NextConfig = { |
| 4 | − /* config options here */ | |
| 5 | 5 | cacheComponents: true, |
| 6 | 6 | partialPrefetching: true, |
| 7 | + poweredByHeader: false, | |
| 8 | + reactStrictMode: true, | |
| 9 | + images: { | |
| 10 | + formats: ["image/avif", "image/webp"], | |
| 11 | + }, | |
| 12 | + async headers() { | |
| 13 | + return [{ source: "/(.*)", headers: securityHeaders }]; | |
| 14 | + }, | |
| 7 | 15 | turbopack: { |
| 8 | 16 | rules: { |
| 9 | 17 | "*.css": { |
modified
package.json
+27 −3
@@ -6,22 +6,46 @@ | ||
| 6 | 6 | "dev": "next dev", |
| 7 | 7 | "build": "next build", |
| 8 | 8 | "start": "next start", |
| 9 | − "lint": "eslint" | |
| 9 | + "lint": "eslint", | |
| 10 | + "typecheck": "tsc --noEmit", | |
| 11 | + "format": "prettier --write .", | |
| 12 | + "format:check": "prettier --check .", | |
| 13 | + "test": "vitest run", | |
| 14 | + "test:watch": "vitest", | |
| 15 | + "test:e2e": "playwright test", | |
| 16 | + "icons": "node --experimental-strip-types scripts/generate-icons.mts", | |
| 17 | + "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build" | |
| 10 | 18 | }, |
| 11 | 19 | "dependencies": { |
| 20 | + "@hookform/resolvers": "^7.0.3", | |
| 12 | 21 | "next": "16.4.5", |
| 22 | + "nodemailer": "^10.3.1", | |
| 13 | 23 | "react": "19.3.0", |
| 14 | − "react-dom": "19.3.0" | |
| 24 | + "react-dom": "19.3.0", | |
| 25 | + "react-hook-form": "^7.91.0", | |
| 26 | + "sharp": "^0.35.5", | |
| 27 | + "zod": "^4.6.7" | |
| 15 | 28 | }, |
| 16 | 29 | "devDependencies": { |
| 30 | + "@axe-core/playwright": "^4.14.0", | |
| 31 | + "@playwright/test": "^1.65.0", | |
| 17 | 32 | "@tailwindcss/turbopack": "^4", |
| 33 | + "@testing-library/jest-dom": "^7.0.1", | |
| 34 | + "@testing-library/react": "^16.3.3", | |
| 35 | + "@testing-library/user-event": "^14.6.8", | |
| 18 | 36 | "@types/node": "^20", |
| 37 | + "@types/nodemailer": "^8.0.2", | |
| 19 | 38 | "@types/react": "^19", |
| 20 | 39 | "@types/react-dom": "^19", |
| 40 | + "@vitejs/plugin-react": "^6.2.1", | |
| 21 | 41 | "eslint": "^9", |
| 22 | 42 | "eslint-config-next": "16.4.5", |
| 43 | + "jsdom": "^31.0.0", | |
| 44 | + "prettier": "^3.9.10", | |
| 45 | + "prettier-plugin-tailwindcss": "^0.8.1", | |
| 23 | 46 | "tailwindcss": "^4", |
| 24 | − "typescript": "^5" | |
| 47 | + "typescript": "^5", | |
| 48 | + "vitest": "^5.1.0" | |
| 25 | 49 | }, |
| 26 | 50 | "packageManager": "pnpm@11.1.2" |
| 27 | 51 | } |
added
playwright.config.ts
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +import { defineConfig, devices } from "@playwright/test"; | |
| 2 | + | |
| 3 | +const PORT = process.env.PW_PORT ?? "3100"; | |
| 4 | +const baseURL = `http://127.0.0.1:${PORT}`; | |
| 5 | + | |
| 6 | +export default defineConfig({ | |
| 7 | + testDir: "./tests/e2e", | |
| 8 | + fullyParallel: true, | |
| 9 | + retries: process.env.CI ? 1 : 0, | |
| 10 | + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", | |
| 11 | + use: { baseURL, trace: "retain-on-failure", locale: "fr-CA" }, | |
| 12 | + webServer: { | |
| 13 | + command: `pnpm start -p ${PORT}`, | |
| 14 | + url: baseURL, | |
| 15 | + reuseExistingServer: !process.env.CI, | |
| 16 | + timeout: 120_000, | |
| 17 | + env: { CONTACT_TRANSPORT: "log", NEXT_PUBLIC_SITE_URL: baseURL }, | |
| 18 | + }, | |
| 19 | + projects: [ | |
| 20 | + { | |
| 21 | + name: "desktop", | |
| 22 | + use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } }, | |
| 23 | + }, | |
| 24 | + { name: "mobile", use: { ...devices["iPhone 13"] } }, | |
| 25 | + ], | |
| 26 | +}); | |
modified
pnpm-lock.yaml
+1098 −5
@@ -8,47 +8,104 @@ importers: | ||
| 8 | 8 | |
| 9 | 9 | .: |
| 10 | 10 | dependencies: |
| 11 | + '@hookform/resolvers': | |
| 12 | + specifier: ^7.0.3 | |
| 13 | + version: 7.0.3(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.0))(zod@4.6.7) | |
| 11 | 14 | next: |
| 12 | 15 | specifier: 16.4.5 |
| 13 | − version: 16.4.5(@babel/core@7.29.13)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) | |
| 16 | + version: 16.4.5(@babel/core@7.29.13)(@playwright/test@1.65.0)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) | |
| 17 | + nodemailer: | |
| 18 | + specifier: ^10.3.1 | |
| 19 | + version: 10.3.1 | |
| 14 | 20 | react: |
| 15 | 21 | specifier: 19.3.0 |
| 16 | 22 | version: 19.3.0 |
| 17 | 23 | react-dom: |
| 18 | 24 | specifier: 19.3.0 |
| 19 | 25 | version: 19.3.0(react@19.3.0) |
| 26 | + react-hook-form: | |
| 27 | + specifier: ^7.91.0 | |
| 28 | + version: 7.91.0(@types/react@19.3.0)(react@19.3.0) | |
| 29 | + sharp: | |
| 30 | + specifier: ^0.35.5 | |
| 31 | + version: 0.35.5(@types/node@20.19.43) | |
| 32 | + zod: | |
| 33 | + specifier: ^4.6.7 | |
| 34 | + version: 4.6.7 | |
| 20 | 35 | devDependencies: |
| 36 | + '@axe-core/playwright': | |
| 37 | + specifier: ^4.14.0 | |
| 38 | + version: 4.14.0(playwright-core@1.65.0) | |
| 39 | + '@playwright/test': | |
| 40 | + specifier: ^1.65.0 | |
| 41 | + version: 1.65.0 | |
| 21 | 42 | '@tailwindcss/turbopack': |
| 22 | 43 | specifier: ^4 |
| 23 | 44 | version: 4.3.5 |
| 45 | + '@testing-library/jest-dom': | |
| 46 | + specifier: ^7.0.1 | |
| 47 | + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))) | |
| 48 | + '@testing-library/react': | |
| 49 | + specifier: ^16.3.3 | |
| 50 | + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) | |
| 51 | + '@testing-library/user-event': | |
| 52 | + specifier: ^14.6.8 | |
| 53 | + version: 14.6.8(@testing-library/dom@10.4.2) | |
| 24 | 54 | '@types/node': |
| 25 | 55 | specifier: ^20 |
| 26 | 56 | version: 20.19.43 |
| 57 | + '@types/nodemailer': | |
| 58 | + specifier: ^8.0.2 | |
| 59 | + version: 8.0.2 | |
| 27 | 60 | '@types/react': |
| 28 | 61 | specifier: ^19 |
| 29 | 62 | version: 19.3.0 |
| 30 | 63 | '@types/react-dom': |
| 31 | 64 | specifier: ^19 |
| 32 | 65 | version: 19.3.0(@types/react@19.3.0) |
| 66 | + '@vitejs/plugin-react': | |
| 67 | + specifier: ^6.2.1 | |
| 68 | + version: 6.2.1(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)) | |
| 33 | 69 | eslint: |
| 34 | 70 | specifier: ^9 |
| 35 | 71 | version: 9.39.5(jiti@2.7.0) |
| 36 | 72 | eslint-config-next: |
| 37 | 73 | specifier: 16.4.5 |
| 38 | 74 | version: 16.4.5(@typescript-eslint/parser@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) |
| 75 | + jsdom: | |
| 76 | + specifier: ^31.0.0 | |
| 77 | + version: 31.0.0 | |
| 78 | + prettier: | |
| 79 | + specifier: ^3.9.10 | |
| 80 | + version: 3.9.10 | |
| 81 | + prettier-plugin-tailwindcss: | |
| 82 | + specifier: ^0.8.1 | |
| 83 | + version: 0.8.1(prettier@3.9.10) | |
| 39 | 84 | tailwindcss: |
| 40 | 85 | specifier: ^4 |
| 41 | 86 | version: 4.3.5 |
| 42 | 87 | typescript: |
| 43 | 88 | specifier: ^5 |
| 44 | 89 | version: 5.9.3 |
| 90 | + vitest: | |
| 91 | + specifier: ^5.1.0 | |
| 92 | + version: 5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)) | |
| 45 | 93 | |
| 46 | 94 | packages: |
| 47 | 95 | |
| 96 | + '@adobe/css-tools@4.5.0': | |
| 97 | + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} | |
| 98 | + | |
| 48 | 99 | '@alloc/quick-lru@5.3.0': |
| 49 | 100 | resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} |
| 50 | 101 | engines: {node: '>=10'} |
| 51 | 102 | |
| 103 | + '@axe-core/playwright@4.14.0': | |
| 104 | + resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==} | |
| 105 | + engines: {node: '>=22.12.0'} | |
| 106 | + peerDependencies: | |
| 107 | + playwright-core: '>= 1.0.0' | |
| 108 | + | |
| 52 | 109 | '@babel/code-frame@7.29.7': |
| 53 | 110 | resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} |
| 54 | 111 | engines: {node: '>=6.9.0'} |
@@ -104,6 +161,10 @@ packages: | ||
| 104 | 161 | engines: {node: '>=6.0.0'} |
| 105 | 162 | hasBin: true |
| 106 | 163 | |
| 164 | + '@babel/runtime@7.29.13': | |
| 165 | + resolution: {integrity: sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==} | |
| 166 | + engines: {node: '>=6.9.0'} | |
| 167 | + | |
| 107 | 168 | '@babel/template@7.29.7': |
| 108 | 169 | resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} |
| 109 | 170 | engines: {node: '>=6.9.0'} |
@@ -163,6 +224,90 @@ packages: | ||
| 163 | 224 | resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} |
| 164 | 225 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} |
| 165 | 226 | |
| 227 | + '@exodus/bytes@1.16.0': | |
| 228 | + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} | |
| 229 | + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} | |
| 230 | + peerDependencies: | |
| 231 | + '@noble/hashes': ^1.8.0 || ^2.0.0 | |
| 232 | + peerDependenciesMeta: | |
| 233 | + '@noble/hashes': | |
| 234 | + optional: true | |
| 235 | + | |
| 236 | + '@hookform/resolvers@7.0.3': | |
| 237 | + resolution: {integrity: sha512-beGY1JY6K5cyWlswD4BVf7UTb5MxSjKkjljrRyLPT3KIQzsTU0jOibBKqtdcKqIZYQUUyURed7roLWZtSdAtiQ==} | |
| 238 | + peerDependencies: | |
| 239 | + '@standard-schema/spec': ^1.0.0 | |
| 240 | + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 | |
| 241 | + ajv: ^8.12.0 | |
| 242 | + ajv-errors: ^3.0.0 | |
| 243 | + ajv-formats: ^2.1.1 | |
| 244 | + arktype: ^2.0.0 | |
| 245 | + ata-validator: ^1.23.0 | |
| 246 | + class-transformer: '>=0.4.0' | |
| 247 | + class-validator: '>=0.12.0' | |
| 248 | + computed-types: ^1.0.0 | |
| 249 | + effect: ^4.0.0 | |
| 250 | + fluentvalidation-ts: ^3.0.0 | |
| 251 | + fp-ts: ^2.7.0 | |
| 252 | + io-ts: ^2.0.0 | |
| 253 | + joi: ^17.0.0 || ^18.0.0 | |
| 254 | + nope-validator: '>=0.12.0' | |
| 255 | + react-hook-form: ^7.55.0 | |
| 256 | + superstruct: '>=0.12.0' | |
| 257 | + typanion: ^3.3.2 | |
| 258 | + typebox: ^1.0.0 | |
| 259 | + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' | |
| 260 | + vest: '>=6.0.0' | |
| 261 | + yup: ^1.0.0 | |
| 262 | + zod: ^3.25.0 || ^4.0.0 | |
| 263 | + peerDependenciesMeta: | |
| 264 | + '@standard-schema/spec': | |
| 265 | + optional: true | |
| 266 | + '@vinejs/vine': | |
| 267 | + optional: true | |
| 268 | + ajv: | |
| 269 | + optional: true | |
| 270 | + ajv-errors: | |
| 271 | + optional: true | |
| 272 | + ajv-formats: | |
| 273 | + optional: true | |
| 274 | + arktype: | |
| 275 | + optional: true | |
| 276 | + ata-validator: | |
| 277 | + optional: true | |
| 278 | + class-transformer: | |
| 279 | + optional: true | |
| 280 | + class-validator: | |
| 281 | + optional: true | |
| 282 | + computed-types: | |
| 283 | + optional: true | |
| 284 | + effect: | |
| 285 | + optional: true | |
| 286 | + fluentvalidation-ts: | |
| 287 | + optional: true | |
| 288 | + fp-ts: | |
| 289 | + optional: true | |
| 290 | + io-ts: | |
| 291 | + optional: true | |
| 292 | + joi: | |
| 293 | + optional: true | |
| 294 | + nope-validator: | |
| 295 | + optional: true | |
| 296 | + superstruct: | |
| 297 | + optional: true | |
| 298 | + typanion: | |
| 299 | + optional: true | |
| 300 | + typebox: | |
| 301 | + optional: true | |
| 302 | + valibot: | |
| 303 | + optional: true | |
| 304 | + vest: | |
| 305 | + optional: true | |
| 306 | + yup: | |
| 307 | + optional: true | |
| 308 | + zod: | |
| 309 | + optional: true | |
| 310 | + | |
| 166 | 311 | '@humanfs/core@0.19.2': |
| 167 | 312 | resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} |
| 168 | 313 | engines: {node: '>=18.18.0'} |
@@ -435,9 +580,119 @@ packages: | ||
| 435 | 580 | resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} |
| 436 | 581 | engines: {node: '>=12.4.0'} |
| 437 | 582 | |
| 583 | + '@oxc-project/types@0.156.0': | |
| 584 | + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} | |
| 585 | + | |
| 586 | + '@playwright/test@1.65.0': | |
| 587 | + resolution: {integrity: sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==} | |
| 588 | + engines: {node: '>=20'} | |
| 589 | + hasBin: true | |
| 590 | + | |
| 591 | + '@rolldown/binding-android-arm-eabi@1.2.16': | |
| 592 | + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} | |
| 593 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 594 | + cpu: [arm] | |
| 595 | + os: [android] | |
| 596 | + | |
| 597 | + '@rolldown/binding-android-arm64@1.2.16': | |
| 598 | + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} | |
| 599 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 600 | + cpu: [arm64] | |
| 601 | + os: [android] | |
| 602 | + | |
| 603 | + '@rolldown/binding-darwin-arm64@1.2.16': | |
| 604 | + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} | |
| 605 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 606 | + cpu: [arm64] | |
| 607 | + os: [darwin] | |
| 608 | + | |
| 609 | + '@rolldown/binding-darwin-x64@1.2.16': | |
| 610 | + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} | |
| 611 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 612 | + cpu: [x64] | |
| 613 | + os: [darwin] | |
| 614 | + | |
| 615 | + '@rolldown/binding-freebsd-x64@1.2.16': | |
| 616 | + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} | |
| 617 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 618 | + cpu: [x64] | |
| 619 | + os: [freebsd] | |
| 620 | + | |
| 621 | + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': | |
| 622 | + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} | |
| 623 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 624 | + cpu: [arm] | |
| 625 | + os: [linux] | |
| 626 | + | |
| 627 | + '@rolldown/binding-linux-arm64-gnu@1.2.16': | |
| 628 | + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} | |
| 629 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 630 | + cpu: [arm64] | |
| 631 | + os: [linux] | |
| 632 | + libc: [glibc] | |
| 633 | + | |
| 634 | + '@rolldown/binding-linux-arm64-musl@1.2.16': | |
| 635 | + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} | |
| 636 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 637 | + cpu: [arm64] | |
| 638 | + os: [linux] | |
| 639 | + libc: [musl] | |
| 640 | + | |
| 641 | + '@rolldown/binding-linux-ppc64-gnu@1.2.16': | |
| 642 | + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} | |
| 643 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 644 | + cpu: [ppc64] | |
| 645 | + os: [linux] | |
| 646 | + libc: [glibc] | |
| 647 | + | |
| 648 | + '@rolldown/binding-linux-s390x-gnu@1.2.16': | |
| 649 | + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} | |
| 650 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 651 | + cpu: [s390x] | |
| 652 | + os: [linux] | |
| 653 | + libc: [glibc] | |
| 654 | + | |
| 655 | + '@rolldown/binding-linux-x64-gnu@1.2.16': | |
| 656 | + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} | |
| 657 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 658 | + cpu: [x64] | |
| 659 | + os: [linux] | |
| 660 | + libc: [glibc] | |
| 661 | + | |
| 662 | + '@rolldown/binding-linux-x64-musl@1.2.16': | |
| 663 | + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} | |
| 664 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 665 | + cpu: [x64] | |
| 666 | + os: [linux] | |
| 667 | + libc: [musl] | |
| 668 | + | |
| 669 | + '@rolldown/binding-openharmony-arm64@1.2.16': | |
| 670 | + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} | |
| 671 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 672 | + cpu: [arm64] | |
| 673 | + os: [openharmony] | |
| 674 | + | |
| 675 | + '@rolldown/binding-win32-arm64-msvc@1.2.16': | |
| 676 | + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} | |
| 677 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 678 | + cpu: [arm64] | |
| 679 | + os: [win32] | |
| 680 | + | |
| 681 | + '@rolldown/binding-win32-x64-msvc@1.2.16': | |
| 682 | + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} | |
| 683 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 684 | + cpu: [x64] | |
| 685 | + os: [win32] | |
| 686 | + | |
| 687 | + '@rolldown/pluginutils@1.0.1': | |
| 688 | + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} | |
| 689 | + | |
| 438 | 690 | '@rtsao/scc@1.1.0': |
| 439 | 691 | resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} |
| 440 | 692 | |
| 693 | + '@standard-schema/utils@0.3.0': | |
| 694 | + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} | |
| 695 | + | |
| 441 | 696 | '@swc/helpers@0.5.23': |
| 442 | 697 | resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} |
| 443 | 698 | |
@@ -532,6 +787,50 @@ packages: | ||
| 532 | 787 | '@tailwindcss/turbopack@4.3.5': |
| 533 | 788 | resolution: {integrity: sha512-0f0rOTyz4yGtPzpSiIP3ppAaYtMnQbGnEsHPGK4Yy8wNbE4xZWlNPjWtD6oyXvQZTg2N0XF4BZEhfsgvJe889g==} |
| 534 | 789 | |
| 790 | + '@testing-library/dom@10.4.2': | |
| 791 | + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} | |
| 792 | + engines: {node: '>=18'} | |
| 793 | + | |
| 794 | + '@testing-library/jest-dom@7.0.1': | |
| 795 | + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} | |
| 796 | + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} | |
| 797 | + peerDependencies: | |
| 798 | + '@testing-library/dom': '>=10 <11' | |
| 799 | + vitest: '>= 0.32' | |
| 800 | + peerDependenciesMeta: | |
| 801 | + vitest: | |
| 802 | + optional: true | |
| 803 | + | |
| 804 | + '@testing-library/react@16.3.3': | |
| 805 | + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} | |
| 806 | + engines: {node: '>=18'} | |
| 807 | + peerDependencies: | |
| 808 | + '@testing-library/dom': ^10.0.0 | |
| 809 | + '@types/react': ^18.0.0 || ^19.0.0 | |
| 810 | + '@types/react-dom': ^18.0.0 || ^19.0.0 | |
| 811 | + react: ^18.0.0 || ^19.0.0 | |
| 812 | + react-dom: ^18.0.0 || ^19.0.0 | |
| 813 | + peerDependenciesMeta: | |
| 814 | + '@types/react': | |
| 815 | + optional: true | |
| 816 | + '@types/react-dom': | |
| 817 | + optional: true | |
| 818 | + | |
| 819 | + '@testing-library/user-event@14.6.8': | |
| 820 | + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} | |
| 821 | + engines: {node: '>=12', npm: '>=6'} | |
| 822 | + peerDependencies: | |
| 823 | + '@testing-library/dom': '>=7.21.4' | |
| 824 | + | |
| 825 | + '@types/aria-query@5.0.4': | |
| 826 | + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} | |
| 827 | + | |
| 828 | + '@types/chai@5.2.3': | |
| 829 | + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} | |
| 830 | + | |
| 831 | + '@types/deep-eql@4.0.2': | |
| 832 | + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} | |
| 833 | + | |
| 535 | 834 | '@types/estree@1.0.10': |
| 536 | 835 | resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} |
| 537 | 836 | |
@@ -544,6 +843,9 @@ packages: | ||
| 544 | 843 | '@types/node@20.19.43': |
| 545 | 844 | resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} |
| 546 | 845 | |
| 846 | + '@types/nodemailer@8.0.2': | |
| 847 | + resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==} | |
| 848 | + | |
| 547 | 849 | '@types/react-dom@19.3.0': |
| 548 | 850 | resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} |
| 549 | 851 | peerDependencies: |
@@ -726,6 +1028,36 @@ packages: | ||
| 726 | 1028 | cpu: [x64] |
| 727 | 1029 | os: [win32] |
| 728 | 1030 | |
| 1031 | + '@vitejs/plugin-react@6.2.1': | |
| 1032 | + resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==} | |
| 1033 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 1034 | + peerDependencies: | |
| 1035 | + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0 | |
| 1036 | + babel-plugin-react-compiler: ^1.0.0 | |
| 1037 | + oxc-transform-react: '>=0.152.0 <0.157.0' | |
| 1038 | + vite: ^8.0.0 | |
| 1039 | + peerDependenciesMeta: | |
| 1040 | + '@rolldown/plugin-babel': | |
| 1041 | + optional: true | |
| 1042 | + babel-plugin-react-compiler: | |
| 1043 | + optional: true | |
| 1044 | + oxc-transform-react: | |
| 1045 | + optional: true | |
| 1046 | + | |
| 1047 | + '@vitest/mocker@5.1.0': | |
| 1048 | + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} | |
| 1049 | + peerDependencies: | |
| 1050 | + msw: ^2.4.9 || ^3.0.0 | |
| 1051 | + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 | |
| 1052 | + peerDependenciesMeta: | |
| 1053 | + msw: | |
| 1054 | + optional: true | |
| 1055 | + vite: | |
| 1056 | + optional: true | |
| 1057 | + | |
| 1058 | + '@vitest/spy@5.1.0': | |
| 1059 | + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} | |
| 1060 | + | |
| 729 | 1061 | acorn-jsx@5.3.2: |
| 730 | 1062 | resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} |
| 731 | 1063 | peerDependencies: |
@@ -739,13 +1071,24 @@ packages: | ||
| 739 | 1071 | ajv@6.15.0: |
| 740 | 1072 | resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} |
| 741 | 1073 | |
| 1074 | + ansi-regex@5.0.1: | |
| 1075 | + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 1076 | + engines: {node: '>=8'} | |
| 1077 | + | |
| 742 | 1078 | ansi-styles@4.3.0: |
| 743 | 1079 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} |
| 744 | 1080 | engines: {node: '>=8'} |
| 745 | 1081 | |
| 1082 | + ansi-styles@5.2.0: | |
| 1083 | + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} | |
| 1084 | + engines: {node: '>=10'} | |
| 1085 | + | |
| 746 | 1086 | argparse@2.0.1: |
| 747 | 1087 | resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} |
| 748 | 1088 | |
| 1089 | + aria-query@5.3.0: | |
| 1090 | + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} | |
| 1091 | + | |
| 749 | 1092 | aria-query@5.3.2: |
| 750 | 1093 | resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} |
| 751 | 1094 | engines: {node: '>= 0.4'} |
@@ -782,6 +1125,10 @@ packages: | ||
| 782 | 1125 | resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} |
| 783 | 1126 | engines: {node: '>= 0.4'} |
| 784 | 1127 | |
| 1128 | + assertion-error@2.0.1: | |
| 1129 | + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} | |
| 1130 | + engines: {node: '>=12'} | |
| 1131 | + | |
| 785 | 1132 | ast-types-flow@0.0.8: |
| 786 | 1133 | resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} |
| 787 | 1134 | |
@@ -813,6 +1160,12 @@ packages: | ||
| 813 | 1160 | engines: {node: '>=6.0.0'} |
| 814 | 1161 | hasBin: true |
| 815 | 1162 | |
| 1163 | + bcp-47-match@2.0.3: | |
| 1164 | + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} | |
| 1165 | + | |
| 1166 | + bcp-47@2.1.2: | |
| 1167 | + resolution: {integrity: sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==} | |
| 1168 | + | |
| 816 | 1169 | brace-expansion@1.1.21: |
| 817 | 1170 | resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} |
| 818 | 1171 | |
@@ -848,6 +1201,10 @@ packages: | ||
| 848 | 1201 | caniuse-lite@1.0.30001823: |
| 849 | 1202 | resolution: {integrity: sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==} |
| 850 | 1203 | |
| 1204 | + chai@6.3.0: | |
| 1205 | + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} | |
| 1206 | + engines: {node: '>=18'} | |
| 1207 | + | |
| 851 | 1208 | chalk@4.1.2: |
| 852 | 1209 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} |
| 853 | 1210 | engines: {node: '>=10'} |
@@ -872,12 +1229,19 @@ packages: | ||
| 872 | 1229 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} |
| 873 | 1230 | engines: {node: '>= 8'} |
| 874 | 1231 | |
| 1232 | + css.escape@1.5.1: | |
| 1233 | + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} | |
| 1234 | + | |
| 875 | 1235 | csstype@3.2.3: |
| 876 | 1236 | resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} |
| 877 | 1237 | |
| 878 | 1238 | damerau-levenshtein@1.0.8: |
| 879 | 1239 | resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} |
| 880 | 1240 | |
| 1241 | + data-urls@8.0.0: | |
| 1242 | + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} | |
| 1243 | + engines: {node: ^22.14.0 || >=24.0.0} | |
| 1244 | + | |
| 881 | 1245 | data-view-buffer@1.0.2: |
| 882 | 1246 | resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} |
| 883 | 1247 | engines: {node: '>= 0.4'} |
@@ -907,6 +1271,9 @@ packages: | ||
| 907 | 1271 | supports-color: |
| 908 | 1272 | optional: true |
| 909 | 1273 | |
| 1274 | + decimal.js@10.6.0: | |
| 1275 | + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} | |
| 1276 | + | |
| 910 | 1277 | deep-is@0.1.4: |
| 911 | 1278 | resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} |
| 912 | 1279 | |
@@ -918,6 +1285,10 @@ packages: | ||
| 918 | 1285 | resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} |
| 919 | 1286 | engines: {node: '>= 0.4'} |
| 920 | 1287 | |
| 1288 | + dequal@2.0.3: | |
| 1289 | + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} | |
| 1290 | + engines: {node: '>=6'} | |
| 1291 | + | |
| 921 | 1292 | detect-libc@2.1.2: |
| 922 | 1293 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} |
| 923 | 1294 | engines: {node: '>=8'} |
@@ -926,6 +1297,12 @@ packages: | ||
| 926 | 1297 | resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} |
| 927 | 1298 | engines: {node: '>=0.10.0'} |
| 928 | 1299 | |
| 1300 | + dom-accessibility-api@0.5.16: | |
| 1301 | + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} | |
| 1302 | + | |
| 1303 | + dom-accessibility-api@0.6.3: | |
| 1304 | + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} | |
| 1305 | + | |
| 929 | 1306 | dunder-proto@1.0.1: |
| 930 | 1307 | resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} |
| 931 | 1308 | engines: {node: '>= 0.4'} |
@@ -940,6 +1317,10 @@ packages: | ||
| 940 | 1317 | resolution: {integrity: sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==} |
| 941 | 1318 | engines: {node: '>=10.13.0'} |
| 942 | 1319 | |
| 1320 | + entities@9.0.0: | |
| 1321 | + resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==} | |
| 1322 | + engines: {node: '>=20.19.0'} | |
| 1323 | + | |
| 943 | 1324 | es-abstract-get@1.0.0: |
| 944 | 1325 | resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} |
| 945 | 1326 | engines: {node: '>= 0.4'} |
@@ -960,6 +1341,9 @@ packages: | ||
| 960 | 1341 | resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} |
| 961 | 1342 | engines: {node: '>= 0.4'} |
| 962 | 1343 | |
| 1344 | + es-module-lexer@2.3.2: | |
| 1345 | + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} | |
| 1346 | + | |
| 963 | 1347 | es-object-atoms@1.1.2: |
| 964 | 1348 | resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} |
| 965 | 1349 | engines: {node: '>= 0.4'} |
@@ -1101,10 +1485,17 @@ packages: | ||
| 1101 | 1485 | resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} |
| 1102 | 1486 | engines: {node: '>=4.0'} |
| 1103 | 1487 | |
| 1488 | + estree-walker@3.0.3: | |
| 1489 | + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} | |
| 1490 | + | |
| 1104 | 1491 | esutils@2.0.3: |
| 1105 | 1492 | resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} |
| 1106 | 1493 | engines: {node: '>=0.10.0'} |
| 1107 | 1494 | |
| 1495 | + expect-type@1.4.0: | |
| 1496 | + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} | |
| 1497 | + engines: {node: '>=12.0.0'} | |
| 1498 | + | |
| 1108 | 1499 | fast-deep-equal@3.1.3: |
| 1109 | 1500 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} |
| 1110 | 1501 | |
@@ -1153,6 +1544,11 @@ packages: | ||
| 1153 | 1544 | resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} |
| 1154 | 1545 | engines: {node: '>= 0.4'} |
| 1155 | 1546 | |
| 1547 | + fsevents@2.3.3: | |
| 1548 | + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | |
| 1549 | + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 1550 | + os: [darwin] | |
| 1551 | + | |
| 1156 | 1552 | function-bind@1.1.2: |
| 1157 | 1553 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} |
| 1158 | 1554 | |
@@ -1246,6 +1642,10 @@ packages: | ||
| 1246 | 1642 | hermes-parser@0.25.1: |
| 1247 | 1643 | resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} |
| 1248 | 1644 | |
| 1645 | + html-encoding-sniffer@7.0.0: | |
| 1646 | + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} | |
| 1647 | + engines: {node: ^22.13.0 || >=24.0.0} | |
| 1648 | + | |
| 1249 | 1649 | ignore@5.3.2: |
| 1250 | 1650 | resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} |
| 1251 | 1651 | engines: {node: '>= 4'} |
@@ -1262,10 +1662,20 @@ packages: | ||
| 1262 | 1662 | resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} |
| 1263 | 1663 | engines: {node: '>=0.8.19'} |
| 1264 | 1664 | |
| 1665 | + indent-string@4.0.0: | |
| 1666 | + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} | |
| 1667 | + engines: {node: '>=8'} | |
| 1668 | + | |
| 1265 | 1669 | internal-slot@1.1.0: |
| 1266 | 1670 | resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} |
| 1267 | 1671 | engines: {node: '>= 0.4'} |
| 1268 | 1672 | |
| 1673 | + is-alphabetical@2.0.1: | |
| 1674 | + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} | |
| 1675 | + | |
| 1676 | + is-alphanumerical@2.0.1: | |
| 1677 | + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} | |
| 1678 | + | |
| 1269 | 1679 | is-array-buffer@3.0.5: |
| 1270 | 1680 | resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} |
| 1271 | 1681 | engines: {node: '>= 0.4'} |
@@ -1301,6 +1711,9 @@ packages: | ||
| 1301 | 1711 | resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} |
| 1302 | 1712 | engines: {node: '>= 0.4'} |
| 1303 | 1713 | |
| 1714 | + is-decimal@2.0.1: | |
| 1715 | + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} | |
| 1716 | + | |
| 1304 | 1717 | is-document.all@1.0.0: |
| 1305 | 1718 | resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} |
| 1306 | 1719 | engines: {node: '>= 0.4'} |
@@ -1337,6 +1750,9 @@ packages: | ||
| 1337 | 1750 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} |
| 1338 | 1751 | engines: {node: '>=0.12.0'} |
| 1339 | 1752 | |
| 1753 | + is-potential-custom-element-name@1.0.1: | |
| 1754 | + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} | |
| 1755 | + | |
| 1340 | 1756 | is-regex@1.2.1: |
| 1341 | 1757 | resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} |
| 1342 | 1758 | engines: {node: '>= 0.4'} |
@@ -1394,6 +1810,15 @@ packages: | ||
| 1394 | 1810 | resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} |
| 1395 | 1811 | hasBin: true |
| 1396 | 1812 | |
| 1813 | + jsdom@31.0.0: | |
| 1814 | + resolution: {integrity: sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==} | |
| 1815 | + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} | |
| 1816 | + peerDependencies: | |
| 1817 | + '@napi-rs/canvas': ^1.0.13 | |
| 1818 | + peerDependenciesMeta: | |
| 1819 | + '@napi-rs/canvas': | |
| 1820 | + optional: true | |
| 1821 | + | |
| 1397 | 1822 | jsesc@3.1.0: |
| 1398 | 1823 | resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} |
| 1399 | 1824 | engines: {node: '>=6'} |
@@ -1520,9 +1945,17 @@ packages: | ||
| 1520 | 1945 | resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} |
| 1521 | 1946 | hasBin: true |
| 1522 | 1947 | |
| 1948 | + lru-cache@11.5.3: | |
| 1949 | + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} | |
| 1950 | + engines: {node: 20 || >=22} | |
| 1951 | + | |
| 1523 | 1952 | lru-cache@5.1.1: |
| 1524 | 1953 | resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} |
| 1525 | 1954 | |
| 1955 | + lz-string@1.5.0: | |
| 1956 | + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} | |
| 1957 | + hasBin: true | |
| 1958 | + | |
| 1526 | 1959 | magic-string@1.4.5: |
| 1527 | 1960 | resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} |
| 1528 | 1961 | |
@@ -1538,6 +1971,10 @@ packages: | ||
| 1538 | 1971 | resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} |
| 1539 | 1972 | engines: {node: '>=8.6'} |
| 1540 | 1973 | |
| 1974 | + min-indent@1.0.1: | |
| 1975 | + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} | |
| 1976 | + engines: {node: '>=4'} | |
| 1977 | + | |
| 1541 | 1978 | minimatch@10.2.6: |
| 1542 | 1979 | resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} |
| 1543 | 1980 | engines: {node: 18 || 20 || >=22} |
@@ -1593,6 +2030,10 @@ packages: | ||
| 1593 | 2030 | resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==} |
| 1594 | 2031 | engines: {node: '>=18'} |
| 1595 | 2032 | |
| 2033 | + nodemailer@10.3.1: | |
| 2034 | + resolution: {integrity: sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==} | |
| 2035 | + engines: {node: '>=20.0.0'} | |
| 2036 | + | |
| 1596 | 2037 | object-assign@4.1.1: |
| 1597 | 2038 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} |
| 1598 | 2039 | engines: {node: '>=0.10.0'} |
@@ -1625,6 +2066,10 @@ packages: | ||
| 1625 | 2066 | resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} |
| 1626 | 2067 | engines: {node: '>= 0.4'} |
| 1627 | 2068 | |
| 2069 | + obug@3.0.0: | |
| 2070 | + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} | |
| 2071 | + engines: {node: '>=12.20.0'} | |
| 2072 | + | |
| 1628 | 2073 | optionator@0.9.4: |
| 1629 | 2074 | resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} |
| 1630 | 2075 | engines: {node: '>= 0.8.0'} |
@@ -1645,6 +2090,12 @@ packages: | ||
| 1645 | 2090 | resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} |
| 1646 | 2091 | engines: {node: '>=6'} |
| 1647 | 2092 | |
| 2093 | + parse5-parser-stream@8.1.0: | |
| 2094 | + resolution: {integrity: sha512-58MPfNTA+DsIzro41rU94xghnu/h2N4yEPdPdGPlZVHM/QvA5jlVGYVo7IzlHbFLHYx3cZUnr9s9HBrA0tEYyw==} | |
| 2095 | + | |
| 2096 | + parse5@8.1.0: | |
| 2097 | + resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==} | |
| 2098 | + | |
| 1648 | 2099 | path-exists@4.0.0: |
| 1649 | 2100 | resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} |
| 1650 | 2101 | engines: {node: '>=8'} |
@@ -1667,6 +2118,16 @@ packages: | ||
| 1667 | 2118 | resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} |
| 1668 | 2119 | engines: {node: '>=12'} |
| 1669 | 2120 | |
| 2121 | + playwright-core@1.65.0: | |
| 2122 | + resolution: {integrity: sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==} | |
| 2123 | + engines: {node: '>=20'} | |
| 2124 | + hasBin: true | |
| 2125 | + | |
| 2126 | + playwright@1.65.0: | |
| 2127 | + resolution: {integrity: sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==} | |
| 2128 | + engines: {node: '>=20'} | |
| 2129 | + hasBin: true | |
| 2130 | + | |
| 1670 | 2131 | possible-typed-array-names@1.1.0: |
| 1671 | 2132 | resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} |
| 1672 | 2133 | engines: {node: '>= 0.4'} |
@@ -1675,10 +2136,78 @@ packages: | ||
| 1675 | 2136 | resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} |
| 1676 | 2137 | engines: {node: ^10 || ^12 || >=14} |
| 1677 | 2138 | |
| 2139 | + postcss@8.5.32: | |
| 2140 | + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} | |
| 2141 | + engines: {node: ^10 || ^12 || >=14} | |
| 2142 | + | |
| 1678 | 2143 | prelude-ls@1.2.1: |
| 1679 | 2144 | resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} |
| 1680 | 2145 | engines: {node: '>= 0.8.0'} |
| 1681 | 2146 | |
| 2147 | + prettier-plugin-tailwindcss@0.8.1: | |
| 2148 | + resolution: {integrity: sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==} | |
| 2149 | + engines: {node: '>=20.19'} | |
| 2150 | + peerDependencies: | |
| 2151 | + '@ianvs/prettier-plugin-sort-imports': '*' | |
| 2152 | + '@prettier/plugin-hermes': '*' | |
| 2153 | + '@prettier/plugin-oxc': '*' | |
| 2154 | + '@prettier/plugin-pug': '*' | |
| 2155 | + '@shopify/prettier-plugin-liquid': '*' | |
| 2156 | + '@trivago/prettier-plugin-sort-imports': '*' | |
| 2157 | + '@zackad/prettier-plugin-twig': '*' | |
| 2158 | + prettier: ^3.0 | |
| 2159 | + prettier-plugin-astro: '*' | |
| 2160 | + prettier-plugin-css-order: '*' | |
| 2161 | + prettier-plugin-jsdoc: '*' | |
| 2162 | + prettier-plugin-marko: '*' | |
| 2163 | + prettier-plugin-multiline-arrays: '*' | |
| 2164 | + prettier-plugin-organize-attributes: '*' | |
| 2165 | + prettier-plugin-organize-imports: '*' | |
| 2166 | + prettier-plugin-sort-imports: '*' | |
| 2167 | + prettier-plugin-svelte: '*' | |
| 2168 | + peerDependenciesMeta: | |
| 2169 | + '@ianvs/prettier-plugin-sort-imports': | |
| 2170 | + optional: true | |
| 2171 | + '@prettier/plugin-hermes': | |
| 2172 | + optional: true | |
| 2173 | + '@prettier/plugin-oxc': | |
| 2174 | + optional: true | |
| 2175 | + '@prettier/plugin-pug': | |
| 2176 | + optional: true | |
| 2177 | + '@shopify/prettier-plugin-liquid': | |
| 2178 | + optional: true | |
| 2179 | + '@trivago/prettier-plugin-sort-imports': | |
| 2180 | + optional: true | |
| 2181 | + '@zackad/prettier-plugin-twig': | |
| 2182 | + optional: true | |
| 2183 | + prettier-plugin-astro: | |
| 2184 | + optional: true | |
| 2185 | + prettier-plugin-css-order: | |
| 2186 | + optional: true | |
| 2187 | + prettier-plugin-jsdoc: | |
| 2188 | + optional: true | |
| 2189 | + prettier-plugin-marko: | |
| 2190 | + optional: true | |
| 2191 | + prettier-plugin-multiline-arrays: | |
| 2192 | + optional: true | |
| 2193 | + prettier-plugin-organize-attributes: | |
| 2194 | + optional: true | |
| 2195 | + prettier-plugin-organize-imports: | |
| 2196 | + optional: true | |
| 2197 | + prettier-plugin-sort-imports: | |
| 2198 | + optional: true | |
| 2199 | + prettier-plugin-svelte: | |
| 2200 | + optional: true | |
| 2201 | + | |
| 2202 | + prettier@3.9.10: | |
| 2203 | + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} | |
| 2204 | + engines: {node: '>=14'} | |
| 2205 | + hasBin: true | |
| 2206 | + | |
| 2207 | + pretty-format@27.5.1: | |
| 2208 | + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} | |
| 2209 | + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} | |
| 2210 | + | |
| 1682 | 2211 | prop-types@15.8.1: |
| 1683 | 2212 | resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} |
| 1684 | 2213 | |
@@ -1694,13 +2223,30 @@ packages: | ||
| 1694 | 2223 | peerDependencies: |
| 1695 | 2224 | react: ^19.3.0 |
| 1696 | 2225 | |
| 2226 | + react-hook-form@7.91.0: | |
| 2227 | + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} | |
| 2228 | + engines: {node: '>=18.0.0'} | |
| 2229 | + peerDependencies: | |
| 2230 | + '@types/react': '*' | |
| 2231 | + react: ^16.8.0 || ^17 || ^18 || ^19 | |
| 2232 | + peerDependenciesMeta: | |
| 2233 | + '@types/react': | |
| 2234 | + optional: true | |
| 2235 | + | |
| 1697 | 2236 | react-is@16.13.1: |
| 1698 | 2237 | resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} |
| 1699 | 2238 | |
| 2239 | + react-is@17.0.2: | |
| 2240 | + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} | |
| 2241 | + | |
| 1700 | 2242 | react@19.3.0: |
| 1701 | 2243 | resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} |
| 1702 | 2244 | engines: {node: '>=0.10.0'} |
| 1703 | 2245 | |
| 2246 | + redent@3.0.0: | |
| 2247 | + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} | |
| 2248 | + engines: {node: '>=8'} | |
| 2249 | + | |
| 1704 | 2250 | reflect.getprototypeof@1.0.10: |
| 1705 | 2251 | resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} |
| 1706 | 2252 | engines: {node: '>= 0.4'} |
@@ -1725,6 +2271,11 @@ packages: | ||
| 1725 | 2271 | resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} |
| 1726 | 2272 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} |
| 1727 | 2273 | |
| 2274 | + rolldown@1.2.16: | |
| 2275 | + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} | |
| 2276 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 2277 | + hasBin: true | |
| 2278 | + | |
| 1728 | 2279 | run-parallel@1.2.0: |
| 1729 | 2280 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} |
| 1730 | 2281 | |
@@ -1740,6 +2291,10 @@ packages: | ||
| 1740 | 2291 | resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} |
| 1741 | 2292 | engines: {node: '>= 0.4'} |
| 1742 | 2293 | |
| 2294 | + saxes@6.0.0: | |
| 2295 | + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} | |
| 2296 | + engines: {node: '>=v12.22.7'} | |
| 2297 | + | |
| 1743 | 2298 | scheduler@0.28.0: |
| 1744 | 2299 | resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} |
| 1745 | 2300 | |
@@ -1804,6 +2359,9 @@ packages: | ||
| 1804 | 2359 | stable-hash@0.0.5: |
| 1805 | 2360 | resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} |
| 1806 | 2361 | |
| 2362 | + std-env@4.3.0: | |
| 2363 | + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} | |
| 2364 | + | |
| 1807 | 2365 | stop-iteration-iterator@1.1.0: |
| 1808 | 2366 | resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} |
| 1809 | 2367 | engines: {node: '>= 0.4'} |
@@ -1835,6 +2393,10 @@ packages: | ||
| 1835 | 2393 | resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} |
| 1836 | 2394 | engines: {node: '>=4'} |
| 1837 | 2395 | |
| 2396 | + strip-indent@3.0.0: | |
| 2397 | + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} | |
| 2398 | + engines: {node: '>=8'} | |
| 2399 | + | |
| 1838 | 2400 | strip-json-comments@3.1.1: |
| 1839 | 2401 | resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} |
| 1840 | 2402 | engines: {node: '>=8'} |
@@ -1867,14 +2429,37 @@ packages: | ||
| 1867 | 2429 | resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} |
| 1868 | 2430 | engines: {node: '>=6'} |
| 1869 | 2431 | |
| 2432 | + tinybench@6.2.3: | |
| 2433 | + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} | |
| 2434 | + engines: {node: '>=20.0.0'} | |
| 2435 | + | |
| 2436 | + tinyexec@1.3.1: | |
| 2437 | + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} | |
| 2438 | + engines: {node: '>=18'} | |
| 2439 | + | |
| 1870 | 2440 | tinyglobby@0.2.17: |
| 1871 | 2441 | resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} |
| 1872 | 2442 | engines: {node: '>=12.0.0'} |
| 1873 | 2443 | |
| 2444 | + tldts-core@7.4.20: | |
| 2445 | + resolution: {integrity: sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==} | |
| 2446 | + | |
| 2447 | + tldts@7.4.19: | |
| 2448 | + resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==} | |
| 2449 | + hasBin: true | |
| 2450 | + | |
| 1874 | 2451 | to-regex-range@5.0.1: |
| 1875 | 2452 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} |
| 1876 | 2453 | engines: {node: '>=8.0'} |
| 1877 | 2454 | |
| 2455 | + tough-cookie@6.1.0: | |
| 2456 | + resolution: {integrity: sha512-FTxc8u8Kl2tz0Lat1u8ksvcy8bwHRyEMawwTWybo51JqaC6O7SvfuhAKg+Ld6VqyIe2QRJBzY9HA7y2WyFRT2w==} | |
| 2457 | + engines: {node: '>=16'} | |
| 2458 | + | |
| 2459 | + tr46@7.0.0: | |
| 2460 | + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} | |
| 2461 | + engines: {node: ^22.14.0 || >=24.0.0} | |
| 2462 | + | |
| 1878 | 2463 | ts-api-utils@2.5.0: |
| 1879 | 2464 | resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} |
| 1880 | 2465 | engines: {node: '>=18.12'} |
@@ -1926,6 +2511,10 @@ packages: | ||
| 1926 | 2511 | undici-types@6.21.0: |
| 1927 | 2512 | resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} |
| 1928 | 2513 | |
| 2514 | + undici@8.12.4: | |
| 2515 | + resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==} | |
| 2516 | + engines: {node: '>=22.19.0'} | |
| 2517 | + | |
| 1929 | 2518 | unrs-resolver@1.13.0: |
| 1930 | 2519 | resolution: {integrity: sha512-mcPQo9Vm6kXd84d/WqMiYBi2CcA57QHBX/ev5PoUEb8b2oQzUvJ6xYsfseQblnCncgnVmCZGWiFwK7KkS5JZJA==} |
| 1931 | 2520 | |
@@ -1938,6 +2527,106 @@ packages: | ||
| 1938 | 2527 | uri-js@4.4.1: |
| 1939 | 2528 | resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} |
| 1940 | 2529 | |
| 2530 | + vite@8.3.6: | |
| 2531 | + resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==} | |
| 2532 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 2533 | + hasBin: true | |
| 2534 | + peerDependencies: | |
| 2535 | + '@types/node': ^20.19.0 || >=22.12.0 | |
| 2536 | + '@vitejs/devtools': ^0.7.1 || ^0.8.0 | |
| 2537 | + esbuild: ^0.27.0 || ^0.28.0 | |
| 2538 | + jiti: '>=1.21.0' | |
| 2539 | + less: ^4.0.0 | |
| 2540 | + sass: ^1.70.0 | |
| 2541 | + sass-embedded: ^1.70.0 | |
| 2542 | + stylus: '>=0.54.8' | |
| 2543 | + sugarss: ^5.0.0 | |
| 2544 | + terser: ^5.16.0 | |
| 2545 | + tsx: ^4.8.1 | |
| 2546 | + yaml: ^2.4.2 | |
| 2547 | + peerDependenciesMeta: | |
| 2548 | + '@types/node': | |
| 2549 | + optional: true | |
| 2550 | + '@vitejs/devtools': | |
| 2551 | + optional: true | |
| 2552 | + esbuild: | |
| 2553 | + optional: true | |
| 2554 | + jiti: | |
| 2555 | + optional: true | |
| 2556 | + less: | |
| 2557 | + optional: true | |
| 2558 | + sass: | |
| 2559 | + optional: true | |
| 2560 | + sass-embedded: | |
| 2561 | + optional: true | |
| 2562 | + stylus: | |
| 2563 | + optional: true | |
| 2564 | + sugarss: | |
| 2565 | + optional: true | |
| 2566 | + terser: | |
| 2567 | + optional: true | |
| 2568 | + tsx: | |
| 2569 | + optional: true | |
| 2570 | + yaml: | |
| 2571 | + optional: true | |
| 2572 | + | |
| 2573 | + vitest@5.1.0: | |
| 2574 | + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} | |
| 2575 | + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} | |
| 2576 | + hasBin: true | |
| 2577 | + peerDependencies: | |
| 2578 | + '@edge-runtime/vm': '*' | |
| 2579 | + '@opentelemetry/api': ^1.9.0 | |
| 2580 | + '@types/node': ^22.0.0 || >=24.0.0 | |
| 2581 | + '@vitest/browser-playwright': 5.1.0 | |
| 2582 | + '@vitest/browser-preview': 5.1.0 | |
| 2583 | + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 | |
| 2584 | + '@vitest/coverage-istanbul': 5.1.0 | |
| 2585 | + '@vitest/coverage-v8': 5.1.0 | |
| 2586 | + '@vitest/ui': 5.1.0 | |
| 2587 | + happy-dom: '*' | |
| 2588 | + jsdom: '*' | |
| 2589 | + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 | |
| 2590 | + peerDependenciesMeta: | |
| 2591 | + '@edge-runtime/vm': | |
| 2592 | + optional: true | |
| 2593 | + '@opentelemetry/api': | |
| 2594 | + optional: true | |
| 2595 | + '@types/node': | |
| 2596 | + optional: true | |
| 2597 | + '@vitest/browser-playwright': | |
| 2598 | + optional: true | |
| 2599 | + '@vitest/browser-preview': | |
| 2600 | + optional: true | |
| 2601 | + '@vitest/browser-webdriverio': | |
| 2602 | + optional: true | |
| 2603 | + '@vitest/coverage-istanbul': | |
| 2604 | + optional: true | |
| 2605 | + '@vitest/coverage-v8': | |
| 2606 | + optional: true | |
| 2607 | + '@vitest/ui': | |
| 2608 | + optional: true | |
| 2609 | + happy-dom: | |
| 2610 | + optional: true | |
| 2611 | + jsdom: | |
| 2612 | + optional: true | |
| 2613 | + | |
| 2614 | + w3c-xmlserializer@6.0.0: | |
| 2615 | + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} | |
| 2616 | + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} | |
| 2617 | + | |
| 2618 | + webidl-conversions@8.0.1: | |
| 2619 | + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} | |
| 2620 | + engines: {node: '>=20'} | |
| 2621 | + | |
| 2622 | + whatwg-mimetype@5.0.0: | |
| 2623 | + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} | |
| 2624 | + engines: {node: '>=20'} | |
| 2625 | + | |
| 2626 | + whatwg-url@17.3.0: | |
| 2627 | + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} | |
| 2628 | + engines: {node: ^22.14.0 || >=24.0.0} | |
| 2629 | + | |
| 1941 | 2630 | which-boxed-primitive@1.1.1: |
| 1942 | 2631 | resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} |
| 1943 | 2632 | engines: {node: '>= 0.4'} |
@@ -1959,10 +2648,22 @@ packages: | ||
| 1959 | 2648 | engines: {node: '>= 8'} |
| 1960 | 2649 | hasBin: true |
| 1961 | 2650 | |
| 2651 | + why-is-node-running@3.2.1: | |
| 2652 | + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} | |
| 2653 | + engines: {node: '>=20.11'} | |
| 2654 | + hasBin: true | |
| 2655 | + | |
| 1962 | 2656 | word-wrap@1.2.5: |
| 1963 | 2657 | resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} |
| 1964 | 2658 | engines: {node: '>=0.10.0'} |
| 1965 | 2659 | |
| 2660 | + xml-name-validator@5.0.0: | |
| 2661 | + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} | |
| 2662 | + engines: {node: '>=18'} | |
| 2663 | + | |
| 2664 | + xmlchars@2.2.0: | |
| 2665 | + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} | |
| 2666 | + | |
| 1966 | 2667 | yallist@3.1.1: |
| 1967 | 2668 | resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} |
| 1968 | 2669 | |
@@ -1981,8 +2682,15 @@ packages: | ||
| 1981 | 2682 | |
| 1982 | 2683 | snapshots: |
| 1983 | 2684 | |
| 2685 | + '@adobe/css-tools@4.5.0': {} | |
| 2686 | + | |
| 1984 | 2687 | '@alloc/quick-lru@5.3.0': {} |
| 1985 | 2688 | |
| 2689 | + '@axe-core/playwright@4.14.0(playwright-core@1.65.0)': | |
| 2690 | + dependencies: | |
| 2691 | + axe-core: 4.14.0 | |
| 2692 | + playwright-core: 1.65.0 | |
| 2693 | + | |
| 1986 | 2694 | '@babel/code-frame@7.29.7': |
| 1987 | 2695 | dependencies: |
| 1988 | 2696 | '@babel/helper-validator-identifier': 7.29.7 |
@@ -2060,6 +2768,8 @@ snapshots: | ||
| 2060 | 2768 | dependencies: |
| 2061 | 2769 | '@babel/types': 7.29.11 |
| 2062 | 2770 | |
| 2771 | + '@babel/runtime@7.29.13': {} | |
| 2772 | + | |
| 2063 | 2773 | '@babel/template@7.29.7': |
| 2064 | 2774 | dependencies: |
| 2065 | 2775 | '@babel/code-frame': 7.29.7 |
@@ -2139,6 +2849,15 @@ snapshots: | ||
| 2139 | 2849 | '@eslint/core': 0.17.0 |
| 2140 | 2850 | levn: 0.4.1 |
| 2141 | 2851 | |
| 2852 | + '@exodus/bytes@1.16.0': {} | |
| 2853 | + | |
| 2854 | + '@hookform/resolvers@7.0.3(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.0))(zod@4.6.7)': | |
| 2855 | + dependencies: | |
| 2856 | + '@standard-schema/utils': 0.3.0 | |
| 2857 | + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.0) | |
| 2858 | + optionalDependencies: | |
| 2859 | + zod: 4.6.7 | |
| 2860 | + | |
| 2142 | 2861 | '@humanfs/core@0.19.2': |
| 2143 | 2862 | dependencies: |
| 2144 | 2863 | '@humanfs/types': 0.15.0 |
@@ -2155,8 +2874,7 @@ snapshots: | ||
| 2155 | 2874 | |
| 2156 | 2875 | '@humanwhocodes/retry@0.4.3': {} |
| 2157 | 2876 | |
| 2158 | − '@img/colour@1.1.0': | |
| 2159 | − optional: true | |
| 2877 | + '@img/colour@1.1.0': {} | |
| 2160 | 2878 | |
| 2161 | 2879 | '@img/sharp-darwin-arm64@0.35.5': |
| 2162 | 2880 | optionalDependencies: |
@@ -2328,8 +3046,63 @@ snapshots: | ||
| 2328 | 3046 | |
| 2329 | 3047 | '@nolyfill/is-core-module@1.0.39': {} |
| 2330 | 3048 | |
| 3049 | + '@oxc-project/types@0.156.0': {} | |
| 3050 | + | |
| 3051 | + '@playwright/test@1.65.0': | |
| 3052 | + dependencies: | |
| 3053 | + playwright: 1.65.0 | |
| 3054 | + | |
| 3055 | + '@rolldown/binding-android-arm-eabi@1.2.16': | |
| 3056 | + optional: true | |
| 3057 | + | |
| 3058 | + '@rolldown/binding-android-arm64@1.2.16': | |
| 3059 | + optional: true | |
| 3060 | + | |
| 3061 | + '@rolldown/binding-darwin-arm64@1.2.16': | |
| 3062 | + optional: true | |
| 3063 | + | |
| 3064 | + '@rolldown/binding-darwin-x64@1.2.16': | |
| 3065 | + optional: true | |
| 3066 | + | |
| 3067 | + '@rolldown/binding-freebsd-x64@1.2.16': | |
| 3068 | + optional: true | |
| 3069 | + | |
| 3070 | + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': | |
| 3071 | + optional: true | |
| 3072 | + | |
| 3073 | + '@rolldown/binding-linux-arm64-gnu@1.2.16': | |
| 3074 | + optional: true | |
| 3075 | + | |
| 3076 | + '@rolldown/binding-linux-arm64-musl@1.2.16': | |
| 3077 | + optional: true | |
| 3078 | + | |
| 3079 | + '@rolldown/binding-linux-ppc64-gnu@1.2.16': | |
| 3080 | + optional: true | |
| 3081 | + | |
| 3082 | + '@rolldown/binding-linux-s390x-gnu@1.2.16': | |
| 3083 | + optional: true | |
| 3084 | + | |
| 3085 | + '@rolldown/binding-linux-x64-gnu@1.2.16': | |
| 3086 | + optional: true | |
| 3087 | + | |
| 3088 | + '@rolldown/binding-linux-x64-musl@1.2.16': | |
| 3089 | + optional: true | |
| 3090 | + | |
| 3091 | + '@rolldown/binding-openharmony-arm64@1.2.16': | |
| 3092 | + optional: true | |
| 3093 | + | |
| 3094 | + '@rolldown/binding-win32-arm64-msvc@1.2.16': | |
| 3095 | + optional: true | |
| 3096 | + | |
| 3097 | + '@rolldown/binding-win32-x64-msvc@1.2.16': | |
| 3098 | + optional: true | |
| 3099 | + | |
| 3100 | + '@rolldown/pluginutils@1.0.1': {} | |
| 3101 | + | |
| 2331 | 3102 | '@rtsao/scc@1.1.0': {} |
| 2332 | 3103 | |
| 3104 | + '@standard-schema/utils@0.3.0': {} | |
| 3105 | + | |
| 2333 | 3106 | '@swc/helpers@0.5.23': |
| 2334 | 3107 | dependencies: |
| 2335 | 3108 | tslib: 2.9.1 |
@@ -2402,6 +3175,52 @@ snapshots: | ||
| 2402 | 3175 | '@tailwindcss/oxide': 4.3.5 |
| 2403 | 3176 | tailwindcss: 4.3.5 |
| 2404 | 3177 | |
| 3178 | + '@testing-library/dom@10.4.2': | |
| 3179 | + dependencies: | |
| 3180 | + '@babel/code-frame': 7.29.7 | |
| 3181 | + '@babel/runtime': 7.29.13 | |
| 3182 | + '@types/aria-query': 5.0.4 | |
| 3183 | + aria-query: 5.3.0 | |
| 3184 | + dom-accessibility-api: 0.5.16 | |
| 3185 | + lz-string: 1.5.0 | |
| 3186 | + picocolors: 1.1.1 | |
| 3187 | + pretty-format: 27.5.1 | |
| 3188 | + | |
| 3189 | + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)))': | |
| 3190 | + dependencies: | |
| 3191 | + '@adobe/css-tools': 4.5.0 | |
| 3192 | + '@testing-library/dom': 10.4.2 | |
| 3193 | + aria-query: 5.3.2 | |
| 3194 | + css.escape: 1.5.1 | |
| 3195 | + dom-accessibility-api: 0.6.3 | |
| 3196 | + picocolors: 1.1.1 | |
| 3197 | + redent: 3.0.0 | |
| 3198 | + optionalDependencies: | |
| 3199 | + vitest: 5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)) | |
| 3200 | + | |
| 3201 | + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': | |
| 3202 | + dependencies: | |
| 3203 | + '@babel/runtime': 7.29.13 | |
| 3204 | + '@testing-library/dom': 10.4.2 | |
| 3205 | + react: 19.3.0 | |
| 3206 | + react-dom: 19.3.0(react@19.3.0) | |
| 3207 | + optionalDependencies: | |
| 3208 | + '@types/react': 19.3.0 | |
| 3209 | + '@types/react-dom': 19.3.0(@types/react@19.3.0) | |
| 3210 | + | |
| 3211 | + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)': | |
| 3212 | + dependencies: | |
| 3213 | + '@testing-library/dom': 10.4.2 | |
| 3214 | + | |
| 3215 | + '@types/aria-query@5.0.4': {} | |
| 3216 | + | |
| 3217 | + '@types/chai@5.2.3': | |
| 3218 | + dependencies: | |
| 3219 | + '@types/deep-eql': 4.0.2 | |
| 3220 | + assertion-error: 2.0.1 | |
| 3221 | + | |
| 3222 | + '@types/deep-eql@4.0.2': {} | |
| 3223 | + | |
| 2405 | 3224 | '@types/estree@1.0.10': {} |
| 2406 | 3225 | |
| 2407 | 3226 | '@types/json-schema@7.0.15': {} |
@@ -2412,6 +3231,10 @@ snapshots: | ||
| 2412 | 3231 | dependencies: |
| 2413 | 3232 | undici-types: 6.21.0 |
| 2414 | 3233 | |
| 3234 | + '@types/nodemailer@8.0.2': | |
| 3235 | + dependencies: | |
| 3236 | + '@types/node': 20.19.43 | |
| 3237 | + | |
| 2415 | 3238 | '@types/react-dom@19.3.0(@types/react@19.3.0)': |
| 2416 | 3239 | dependencies: |
| 2417 | 3240 | '@types/react': 19.3.0 |
@@ -2574,6 +3397,22 @@ snapshots: | ||
| 2574 | 3397 | '@unrs/resolver-binding-win32-x64-msvc@1.13.0': |
| 2575 | 3398 | optional: true |
| 2576 | 3399 | |
| 3400 | + '@vitejs/plugin-react@6.2.1(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))': | |
| 3401 | + dependencies: | |
| 3402 | + '@rolldown/pluginutils': 1.0.1 | |
| 3403 | + vite: 8.3.6(@types/node@20.19.43)(jiti@2.7.0) | |
| 3404 | + | |
| 3405 | + '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0))': | |
| 3406 | + dependencies: | |
| 3407 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 3408 | + '@vitest/spy': 5.1.0 | |
| 3409 | + estree-walker: 3.0.3 | |
| 3410 | + magic-string: 1.4.5 | |
| 3411 | + optionalDependencies: | |
| 3412 | + vite: 8.3.6(@types/node@20.19.43)(jiti@2.7.0) | |
| 3413 | + | |
| 3414 | + '@vitest/spy@5.1.0': {} | |
| 3415 | + | |
| 2577 | 3416 | acorn-jsx@5.3.2(acorn@8.19.0): |
| 2578 | 3417 | dependencies: |
| 2579 | 3418 | acorn: 8.19.0 |
@@ -2587,12 +3426,20 @@ snapshots: | ||
| 2587 | 3426 | json-schema-traverse: 0.4.1 |
| 2588 | 3427 | uri-js: 4.4.1 |
| 2589 | 3428 | |
| 3429 | + ansi-regex@5.0.1: {} | |
| 3430 | + | |
| 2590 | 3431 | ansi-styles@4.3.0: |
| 2591 | 3432 | dependencies: |
| 2592 | 3433 | color-convert: 2.0.1 |
| 2593 | 3434 | |
| 3435 | + ansi-styles@5.2.0: {} | |
| 3436 | + | |
| 2594 | 3437 | argparse@2.0.1: {} |
| 2595 | 3438 | |
| 3439 | + aria-query@5.3.0: | |
| 3440 | + dependencies: | |
| 3441 | + dequal: 2.0.3 | |
| 3442 | + | |
| 2596 | 3443 | aria-query@5.3.2: {} |
| 2597 | 3444 | |
| 2598 | 3445 | array-buffer-byte-length@1.0.2: |
@@ -2662,6 +3509,8 @@ snapshots: | ||
| 2662 | 3509 | get-intrinsic: 1.3.0 |
| 2663 | 3510 | is-array-buffer: 3.0.5 |
| 2664 | 3511 | |
| 3512 | + assertion-error@2.0.1: {} | |
| 3513 | + | |
| 2665 | 3514 | ast-types-flow@0.0.8: {} |
| 2666 | 3515 | |
| 2667 | 3516 | async-function@1.0.0: {} |
@@ -2680,6 +3529,14 @@ snapshots: | ||
| 2680 | 3529 | |
| 2681 | 3530 | baseline-browser-mapping@2.11.35: {} |
| 2682 | 3531 | |
| 3532 | + bcp-47-match@2.0.3: {} | |
| 3533 | + | |
| 3534 | + bcp-47@2.1.2: | |
| 3535 | + dependencies: | |
| 3536 | + is-alphabetical: 2.0.1 | |
| 3537 | + is-alphanumerical: 2.0.1 | |
| 3538 | + is-decimal: 2.0.1 | |
| 3539 | + | |
| 2683 | 3540 | brace-expansion@1.1.21: |
| 2684 | 3541 | dependencies: |
| 2685 | 3542 | balanced-match: 1.0.2 |
@@ -2722,6 +3579,8 @@ snapshots: | ||
| 2722 | 3579 | |
| 2723 | 3580 | caniuse-lite@1.0.30001823: {} |
| 2724 | 3581 | |
| 3582 | + chai@6.3.0: {} | |
| 3583 | + | |
| 2725 | 3584 | chalk@4.1.2: |
| 2726 | 3585 | dependencies: |
| 2727 | 3586 | ansi-styles: 4.3.0 |
@@ -2745,10 +3604,19 @@ snapshots: | ||
| 2745 | 3604 | shebang-command: 2.0.0 |
| 2746 | 3605 | which: 2.0.2 |
| 2747 | 3606 | |
| 3607 | + css.escape@1.5.1: {} | |
| 3608 | + | |
| 2748 | 3609 | csstype@3.2.3: {} |
| 2749 | 3610 | |
| 2750 | 3611 | damerau-levenshtein@1.0.8: {} |
| 2751 | 3612 | |
| 3613 | + data-urls@8.0.0: | |
| 3614 | + dependencies: | |
| 3615 | + whatwg-mimetype: 5.0.0 | |
| 3616 | + whatwg-url: 17.3.0 | |
| 3617 | + transitivePeerDependencies: | |
| 3618 | + - '@noble/hashes' | |
| 3619 | + | |
| 2752 | 3620 | data-view-buffer@1.0.2: |
| 2753 | 3621 | dependencies: |
| 2754 | 3622 | call-bound: 1.0.4 |
@@ -2775,6 +3643,8 @@ snapshots: | ||
| 2775 | 3643 | dependencies: |
| 2776 | 3644 | ms: 2.1.3 |
| 2777 | 3645 | |
| 3646 | + decimal.js@10.6.0: {} | |
| 3647 | + | |
| 2778 | 3648 | deep-is@0.1.4: {} |
| 2779 | 3649 | |
| 2780 | 3650 | define-data-property@1.1.4: |
@@ -2789,12 +3659,18 @@ snapshots: | ||
| 2789 | 3659 | has-property-descriptors: 1.0.2 |
| 2790 | 3660 | object-keys: 1.1.1 |
| 2791 | 3661 | |
| 3662 | + dequal@2.0.3: {} | |
| 3663 | + | |
| 2792 | 3664 | detect-libc@2.1.2: {} |
| 2793 | 3665 | |
| 2794 | 3666 | doctrine@2.1.0: |
| 2795 | 3667 | dependencies: |
| 2796 | 3668 | esutils: 2.0.3 |
| 2797 | 3669 | |
| 3670 | + dom-accessibility-api@0.5.16: {} | |
| 3671 | + | |
| 3672 | + dom-accessibility-api@0.6.3: {} | |
| 3673 | + | |
| 2798 | 3674 | dunder-proto@1.0.1: |
| 2799 | 3675 | dependencies: |
| 2800 | 3676 | call-bind-apply-helpers: 1.0.2 |
@@ -2810,6 +3686,8 @@ snapshots: | ||
| 2810 | 3686 | graceful-fs: 4.2.11 |
| 2811 | 3687 | tapable: 2.3.3 |
| 2812 | 3688 | |
| 3689 | + entities@9.0.0: {} | |
| 3690 | + | |
| 2813 | 3691 | es-abstract-get@1.0.0: |
| 2814 | 3692 | dependencies: |
| 2815 | 3693 | es-errors: 1.3.0 |
@@ -2897,6 +3775,8 @@ snapshots: | ||
| 2897 | 3775 | iterator.prototype: 1.1.5 |
| 2898 | 3776 | math-intrinsics: 1.1.0 |
| 2899 | 3777 | |
| 3778 | + es-module-lexer@2.3.2: {} | |
| 3779 | + | |
| 2900 | 3780 | es-object-atoms@1.1.2: |
| 2901 | 3781 | dependencies: |
| 2902 | 3782 | es-errors: 1.3.0 |
@@ -3128,8 +4008,14 @@ snapshots: | ||
| 3128 | 4008 | |
| 3129 | 4009 | estraverse@5.3.0: {} |
| 3130 | 4010 | |
| 4011 | + estree-walker@3.0.3: | |
| 4012 | + dependencies: | |
| 4013 | + '@types/estree': 1.0.10 | |
| 4014 | + | |
| 3131 | 4015 | esutils@2.0.3: {} |
| 3132 | 4016 | |
| 4017 | + expect-type@1.4.0: {} | |
| 4018 | + | |
| 3133 | 4019 | fast-deep-equal@3.1.3: {} |
| 3134 | 4020 | |
| 3135 | 4021 | fast-glob@3.3.1: |
@@ -3176,6 +4062,9 @@ snapshots: | ||
| 3176 | 4062 | dependencies: |
| 3177 | 4063 | is-callable: 1.2.7 |
| 3178 | 4064 | |
| 4065 | + fsevents@2.3.3: | |
| 4066 | + optional: true | |
| 4067 | + | |
| 3179 | 4068 | function-bind@1.1.2: {} |
| 3180 | 4069 | |
| 3181 | 4070 | function.prototype.name@1.2.0: |
@@ -3273,6 +4162,12 @@ snapshots: | ||
| 3273 | 4162 | dependencies: |
| 3274 | 4163 | hermes-estree: 0.25.1 |
| 3275 | 4164 | |
| 4165 | + html-encoding-sniffer@7.0.0: | |
| 4166 | + dependencies: | |
| 4167 | + '@exodus/bytes': 1.16.0 | |
| 4168 | + transitivePeerDependencies: | |
| 4169 | + - '@noble/hashes' | |
| 4170 | + | |
| 3276 | 4171 | ignore@5.3.2: {} |
| 3277 | 4172 | |
| 3278 | 4173 | ignore@7.0.14: {} |
@@ -3284,12 +4179,21 @@ snapshots: | ||
| 3284 | 4179 | |
| 3285 | 4180 | imurmurhash@0.1.4: {} |
| 3286 | 4181 | |
| 4182 | + indent-string@4.0.0: {} | |
| 4183 | + | |
| 3287 | 4184 | internal-slot@1.1.0: |
| 3288 | 4185 | dependencies: |
| 3289 | 4186 | es-errors: 1.3.0 |
| 3290 | 4187 | hasown: 2.0.4 |
| 3291 | 4188 | side-channel: 1.1.1 |
| 3292 | 4189 | |
| 4190 | + is-alphabetical@2.0.1: {} | |
| 4191 | + | |
| 4192 | + is-alphanumerical@2.0.1: | |
| 4193 | + dependencies: | |
| 4194 | + is-alphabetical: 2.0.1 | |
| 4195 | + is-decimal: 2.0.1 | |
| 4196 | + | |
| 3293 | 4197 | is-array-buffer@3.0.5: |
| 3294 | 4198 | dependencies: |
| 3295 | 4199 | call-bind: 1.0.9 |
@@ -3334,6 +4238,8 @@ snapshots: | ||
| 3334 | 4238 | call-bound: 1.0.4 |
| 3335 | 4239 | has-tostringtag: 1.0.2 |
| 3336 | 4240 | |
| 4241 | + is-decimal@2.0.1: {} | |
| 4242 | + | |
| 3337 | 4243 | is-document.all@1.0.0: |
| 3338 | 4244 | dependencies: |
| 3339 | 4245 | call-bound: 1.0.4 |
@@ -3367,6 +4273,8 @@ snapshots: | ||
| 3367 | 4273 | |
| 3368 | 4274 | is-number@7.0.0: {} |
| 3369 | 4275 | |
| 4276 | + is-potential-custom-element-name@1.0.1: {} | |
| 4277 | + | |
| 3370 | 4278 | is-regex@1.2.1: |
| 3371 | 4279 | dependencies: |
| 3372 | 4280 | call-bound: 1.0.4 |
@@ -3427,6 +4335,29 @@ snapshots: | ||
| 3427 | 4335 | dependencies: |
| 3428 | 4336 | argparse: 2.0.1 |
| 3429 | 4337 | |
| 4338 | + jsdom@31.0.0: | |
| 4339 | + dependencies: | |
| 4340 | + '@exodus/bytes': 1.16.0 | |
| 4341 | + bcp-47: 2.1.2 | |
| 4342 | + bcp-47-match: 2.0.3 | |
| 4343 | + data-urls: 8.0.0 | |
| 4344 | + decimal.js: 10.6.0 | |
| 4345 | + html-encoding-sniffer: 7.0.0 | |
| 4346 | + is-potential-custom-element-name: 1.0.1 | |
| 4347 | + lru-cache: 11.5.3 | |
| 4348 | + parse5: 8.1.0 | |
| 4349 | + parse5-parser-stream: 8.1.0 | |
| 4350 | + saxes: 6.0.0 | |
| 4351 | + tough-cookie: 6.1.0 | |
| 4352 | + undici: 8.12.4 | |
| 4353 | + w3c-xmlserializer: 6.0.0 | |
| 4354 | + webidl-conversions: 8.0.1 | |
| 4355 | + whatwg-mimetype: 5.0.0 | |
| 4356 | + whatwg-url: 17.3.0 | |
| 4357 | + xml-name-validator: 5.0.0 | |
| 4358 | + transitivePeerDependencies: | |
| 4359 | + - '@noble/hashes' | |
| 4360 | + | |
| 3430 | 4361 | jsesc@3.1.0: {} |
| 3431 | 4362 | |
| 3432 | 4363 | json-buffer@3.0.1: {} |
@@ -3522,10 +4453,14 @@ snapshots: | ||
| 3522 | 4453 | dependencies: |
| 3523 | 4454 | js-tokens: 4.0.0 |
| 3524 | 4455 | |
| 4456 | + lru-cache@11.5.3: {} | |
| 4457 | + | |
| 3525 | 4458 | lru-cache@5.1.1: |
| 3526 | 4459 | dependencies: |
| 3527 | 4460 | yallist: 3.1.1 |
| 3528 | 4461 | |
| 4462 | + lz-string@1.5.0: {} | |
| 4463 | + | |
| 3529 | 4464 | magic-string@1.4.5: |
| 3530 | 4465 | dependencies: |
| 3531 | 4466 | '@jridgewell/sourcemap-codec': 1.6.0 |
@@ -3539,6 +4474,8 @@ snapshots: | ||
| 3539 | 4474 | braces: 3.0.3 |
| 3540 | 4475 | picomatch: 2.3.2 |
| 3541 | 4476 | |
| 4477 | + min-indent@1.0.1: {} | |
| 4478 | + | |
| 3542 | 4479 | minimatch@10.2.6: |
| 3543 | 4480 | dependencies: |
| 3544 | 4481 | brace-expansion: 5.0.12 |
@@ -3557,7 +4494,7 @@ snapshots: | ||
| 3557 | 4494 | |
| 3558 | 4495 | natural-compare@1.4.0: {} |
| 3559 | 4496 | |
| 3560 | − next@16.4.5(@babel/core@7.29.13)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): | |
| 4497 | + next@16.4.5(@babel/core@7.29.13)(@playwright/test@1.65.0)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): | |
| 3561 | 4498 | dependencies: |
| 3562 | 4499 | '@next/env': 16.4.5 |
| 3563 | 4500 | '@swc/helpers': 0.5.23 |
@@ -3576,6 +4513,7 @@ snapshots: | ||
| 3576 | 4513 | '@next/swc-linux-x64-musl': 16.4.5 |
| 3577 | 4514 | '@next/swc-win32-arm64-msvc': 16.4.5 |
| 3578 | 4515 | '@next/swc-win32-x64-msvc': 16.4.5 |
| 4516 | + '@playwright/test': 1.65.0 | |
| 3579 | 4517 | sharp: 0.35.5(@types/node@20.19.43) |
| 3580 | 4518 | transitivePeerDependencies: |
| 3581 | 4519 | - '@babel/core' |
@@ -3591,6 +4529,8 @@ snapshots: | ||
| 3591 | 4529 | |
| 3592 | 4530 | node-releases@2.0.63: {} |
| 3593 | 4531 | |
| 4532 | + nodemailer@10.3.1: {} | |
| 4533 | + | |
| 3594 | 4534 | object-assign@4.1.1: {} |
| 3595 | 4535 | |
| 3596 | 4536 | object-inspect@1.13.4: {} |
@@ -3633,6 +4573,8 @@ snapshots: | ||
| 3633 | 4573 | define-properties: 1.2.1 |
| 3634 | 4574 | es-object-atoms: 1.1.2 |
| 3635 | 4575 | |
| 4576 | + obug@3.0.0: {} | |
| 4577 | + | |
| 3636 | 4578 | optionator@0.9.4: |
| 3637 | 4579 | dependencies: |
| 3638 | 4580 | deep-is: 0.1.4 |
@@ -3661,6 +4603,14 @@ snapshots: | ||
| 3661 | 4603 | dependencies: |
| 3662 | 4604 | callsites: 3.1.0 |
| 3663 | 4605 | |
| 4606 | + parse5-parser-stream@8.1.0: | |
| 4607 | + dependencies: | |
| 4608 | + parse5: 8.1.0 | |
| 4609 | + | |
| 4610 | + parse5@8.1.0: | |
| 4611 | + dependencies: | |
| 4612 | + entities: 9.0.0 | |
| 4613 | + | |
| 3664 | 4614 | path-exists@4.0.0: {} |
| 3665 | 4615 | |
| 3666 | 4616 | path-key@3.1.1: {} |
@@ -3673,6 +4623,12 @@ snapshots: | ||
| 3673 | 4623 | |
| 3674 | 4624 | picomatch@4.0.7: {} |
| 3675 | 4625 | |
| 4626 | + playwright-core@1.65.0: {} | |
| 4627 | + | |
| 4628 | + playwright@1.65.0: | |
| 4629 | + dependencies: | |
| 4630 | + playwright-core: 1.65.0 | |
| 4631 | + | |
| 3676 | 4632 | possible-typed-array-names@1.1.0: {} |
| 3677 | 4633 | |
| 3678 | 4634 | postcss@8.5.23: |
@@ -3681,8 +4637,26 @@ snapshots: | ||
| 3681 | 4637 | picocolors: 1.1.1 |
| 3682 | 4638 | source-map-js: 1.2.2 |
| 3683 | 4639 | |
| 4640 | + postcss@8.5.32: | |
| 4641 | + dependencies: | |
| 4642 | + nanoid: 3.3.20 | |
| 4643 | + picocolors: 1.1.1 | |
| 4644 | + source-map-js: 1.2.2 | |
| 4645 | + | |
| 3684 | 4646 | prelude-ls@1.2.1: {} |
| 3685 | 4647 | |
| 4648 | + prettier-plugin-tailwindcss@0.8.1(prettier@3.9.10): | |
| 4649 | + dependencies: | |
| 4650 | + prettier: 3.9.10 | |
| 4651 | + | |
| 4652 | + prettier@3.9.10: {} | |
| 4653 | + | |
| 4654 | + pretty-format@27.5.1: | |
| 4655 | + dependencies: | |
| 4656 | + ansi-regex: 5.0.1 | |
| 4657 | + ansi-styles: 5.2.0 | |
| 4658 | + react-is: 17.0.2 | |
| 4659 | + | |
| 3686 | 4660 | prop-types@15.8.1: |
| 3687 | 4661 | dependencies: |
| 3688 | 4662 | loose-envify: 1.4.0 |
@@ -3698,10 +4672,23 @@ snapshots: | ||
| 3698 | 4672 | react: 19.3.0 |
| 3699 | 4673 | scheduler: 0.28.0 |
| 3700 | 4674 | |
| 4675 | + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.0): | |
| 4676 | + dependencies: | |
| 4677 | + react: 19.3.0 | |
| 4678 | + optionalDependencies: | |
| 4679 | + '@types/react': 19.3.0 | |
| 4680 | + | |
| 3701 | 4681 | react-is@16.13.1: {} |
| 3702 | 4682 | |
| 4683 | + react-is@17.0.2: {} | |
| 4684 | + | |
| 3703 | 4685 | react@19.3.0: {} |
| 3704 | 4686 | |
| 4687 | + redent@3.0.0: | |
| 4688 | + dependencies: | |
| 4689 | + indent-string: 4.0.0 | |
| 4690 | + strip-indent: 3.0.0 | |
| 4691 | + | |
| 3705 | 4692 | reflect.getprototypeof@1.0.10: |
| 3706 | 4693 | dependencies: |
| 3707 | 4694 | call-bind: 1.0.9 |
@@ -3737,6 +4724,27 @@ snapshots: | ||
| 3737 | 4724 | |
| 3738 | 4725 | reusify@1.1.0: {} |
| 3739 | 4726 | |
| 4727 | + rolldown@1.2.16: | |
| 4728 | + dependencies: | |
| 4729 | + '@oxc-project/types': 0.156.0 | |
| 4730 | + '@rolldown/pluginutils': 1.0.1 | |
| 4731 | + optionalDependencies: | |
| 4732 | + '@rolldown/binding-android-arm-eabi': 1.2.16 | |
| 4733 | + '@rolldown/binding-android-arm64': 1.2.16 | |
| 4734 | + '@rolldown/binding-darwin-arm64': 1.2.16 | |
| 4735 | + '@rolldown/binding-darwin-x64': 1.2.16 | |
| 4736 | + '@rolldown/binding-freebsd-x64': 1.2.16 | |
| 4737 | + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 | |
| 4738 | + '@rolldown/binding-linux-arm64-gnu': 1.2.16 | |
| 4739 | + '@rolldown/binding-linux-arm64-musl': 1.2.16 | |
| 4740 | + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 | |
| 4741 | + '@rolldown/binding-linux-s390x-gnu': 1.2.16 | |
| 4742 | + '@rolldown/binding-linux-x64-gnu': 1.2.16 | |
| 4743 | + '@rolldown/binding-linux-x64-musl': 1.2.16 | |
| 4744 | + '@rolldown/binding-openharmony-arm64': 1.2.16 | |
| 4745 | + '@rolldown/binding-win32-arm64-msvc': 1.2.16 | |
| 4746 | + '@rolldown/binding-win32-x64-msvc': 1.2.16 | |
| 4747 | + | |
| 3740 | 4748 | run-parallel@1.2.0: |
| 3741 | 4749 | dependencies: |
| 3742 | 4750 | queue-microtask: 1.2.3 |
@@ -3760,6 +4768,10 @@ snapshots: | ||
| 3760 | 4768 | es-errors: 1.3.0 |
| 3761 | 4769 | is-regex: 1.2.1 |
| 3762 | 4770 | |
| 4771 | + saxes@6.0.0: | |
| 4772 | + dependencies: | |
| 4773 | + xmlchars: 2.2.0 | |
| 4774 | + | |
| 3763 | 4775 | scheduler@0.28.0: {} |
| 3764 | 4776 | |
| 3765 | 4777 | semver@6.3.2: {} |
@@ -3820,7 +4832,6 @@ snapshots: | ||
| 3820 | 4832 | '@img/sharp-win32-ia32': 0.35.5 |
| 3821 | 4833 | '@img/sharp-win32-x64': 0.35.5 |
| 3822 | 4834 | '@types/node': 20.19.43 |
| 3823 | − optional: true | |
| 3824 | 4835 | |
| 3825 | 4836 | shebang-command@2.0.0: |
| 3826 | 4837 | dependencies: |
@@ -3860,6 +4871,8 @@ snapshots: | ||
| 3860 | 4871 | |
| 3861 | 4872 | stable-hash@0.0.5: {} |
| 3862 | 4873 | |
| 4874 | + std-env@4.3.0: {} | |
| 4875 | + | |
| 3863 | 4876 | stop-iteration-iterator@1.1.0: |
| 3864 | 4877 | dependencies: |
| 3865 | 4878 | es-errors: 1.3.0 |
@@ -3918,6 +4931,10 @@ snapshots: | ||
| 3918 | 4931 | |
| 3919 | 4932 | strip-bom@3.0.0: {} |
| 3920 | 4933 | |
| 4934 | + strip-indent@3.0.0: | |
| 4935 | + dependencies: | |
| 4936 | + min-indent: 1.0.1 | |
| 4937 | + | |
| 3921 | 4938 | strip-json-comments@3.1.1: {} |
| 3922 | 4939 | |
| 3923 | 4940 | styled-jsx@5.1.6(@babel/core@7.29.13)(react@19.3.0): |
@@ -3937,15 +4954,33 @@ snapshots: | ||
| 3937 | 4954 | |
| 3938 | 4955 | tapable@2.3.3: {} |
| 3939 | 4956 | |
| 4957 | + tinybench@6.2.3: {} | |
| 4958 | + | |
| 4959 | + tinyexec@1.3.1: {} | |
| 4960 | + | |
| 3940 | 4961 | tinyglobby@0.2.17: |
| 3941 | 4962 | dependencies: |
| 3942 | 4963 | fdir: 6.5.0(picomatch@4.0.7) |
| 3943 | 4964 | picomatch: 4.0.7 |
| 3944 | 4965 | |
| 4966 | + tldts-core@7.4.20: {} | |
| 4967 | + | |
| 4968 | + tldts@7.4.19: | |
| 4969 | + dependencies: | |
| 4970 | + tldts-core: 7.4.20 | |
| 4971 | + | |
| 3945 | 4972 | to-regex-range@5.0.1: |
| 3946 | 4973 | dependencies: |
| 3947 | 4974 | is-number: 7.0.0 |
| 3948 | 4975 | |
| 4976 | + tough-cookie@6.1.0: | |
| 4977 | + dependencies: | |
| 4978 | + tldts: 7.4.19 | |
| 4979 | + | |
| 4980 | + tr46@7.0.0: | |
| 4981 | + dependencies: | |
| 4982 | + punycode: 2.3.1 | |
| 4983 | + | |
| 3949 | 4984 | ts-api-utils@2.5.0(typescript@5.9.3): |
| 3950 | 4985 | dependencies: |
| 3951 | 4986 | typescript: 5.9.3 |
@@ -4017,6 +5052,8 @@ snapshots: | ||
| 4017 | 5052 | |
| 4018 | 5053 | undici-types@6.21.0: {} |
| 4019 | 5054 | |
| 5055 | + undici@8.12.4: {} | |
| 5056 | + | |
| 4020 | 5057 | unrs-resolver@1.13.0: |
| 4021 | 5058 | dependencies: |
| 4022 | 5059 | napi-postinstall: 0.3.8 |
@@ -4053,6 +5090,56 @@ snapshots: | ||
| 4053 | 5090 | dependencies: |
| 4054 | 5091 | punycode: 2.3.1 |
| 4055 | 5092 | |
| 5093 | + vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0): | |
| 5094 | + dependencies: | |
| 5095 | + lightningcss: 1.33.0 | |
| 5096 | + picomatch: 4.0.7 | |
| 5097 | + postcss: 8.5.32 | |
| 5098 | + rolldown: 1.2.16 | |
| 5099 | + tinyglobby: 0.2.17 | |
| 5100 | + optionalDependencies: | |
| 5101 | + '@types/node': 20.19.43 | |
| 5102 | + fsevents: 2.3.3 | |
| 5103 | + jiti: 2.7.0 | |
| 5104 | + | |
| 5105 | + vitest@5.1.0(@types/node@20.19.43)(jsdom@31.0.0)(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)): | |
| 5106 | + dependencies: | |
| 5107 | + '@types/chai': 5.2.3 | |
| 5108 | + '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@20.19.43)(jiti@2.7.0)) | |
| 5109 | + chai: 6.3.0 | |
| 5110 | + es-module-lexer: 2.3.2 | |
| 5111 | + expect-type: 1.4.0 | |
| 5112 | + magic-string: 1.4.5 | |
| 5113 | + obug: 3.0.0 | |
| 5114 | + picomatch: 4.0.7 | |
| 5115 | + std-env: 4.3.0 | |
| 5116 | + tinybench: 6.2.3 | |
| 5117 | + tinyexec: 1.3.1 | |
| 5118 | + tinyglobby: 0.2.17 | |
| 5119 | + vite: 8.3.6(@types/node@20.19.43)(jiti@2.7.0) | |
| 5120 | + why-is-node-running: 3.2.1 | |
| 5121 | + optionalDependencies: | |
| 5122 | + '@types/node': 20.19.43 | |
| 5123 | + jsdom: 31.0.0 | |
| 5124 | + transitivePeerDependencies: | |
| 5125 | + - msw | |
| 5126 | + | |
| 5127 | + w3c-xmlserializer@6.0.0: | |
| 5128 | + dependencies: | |
| 5129 | + xml-name-validator: 5.0.0 | |
| 5130 | + | |
| 5131 | + webidl-conversions@8.0.1: {} | |
| 5132 | + | |
| 5133 | + whatwg-mimetype@5.0.0: {} | |
| 5134 | + | |
| 5135 | + whatwg-url@17.3.0: | |
| 5136 | + dependencies: | |
| 5137 | + '@exodus/bytes': 1.16.0 | |
| 5138 | + tr46: 7.0.0 | |
| 5139 | + webidl-conversions: 8.0.1 | |
| 5140 | + transitivePeerDependencies: | |
| 5141 | + - '@noble/hashes' | |
| 5142 | + | |
| 4056 | 5143 | which-boxed-primitive@1.1.1: |
| 4057 | 5144 | dependencies: |
| 4058 | 5145 | is-bigint: 1.1.0 |
@@ -4098,8 +5185,14 @@ snapshots: | ||
| 4098 | 5185 | dependencies: |
| 4099 | 5186 | isexe: 2.0.0 |
| 4100 | 5187 | |
| 5188 | + why-is-node-running@3.2.1: {} | |
| 5189 | + | |
| 4101 | 5190 | word-wrap@1.2.5: {} |
| 4102 | 5191 | |
| 5192 | + xml-name-validator@5.0.0: {} | |
| 5193 | + | |
| 5194 | + xmlchars@2.2.0: {} | |
| 5195 | + | |
| 4103 | 5196 | yallist@3.1.1: {} |
| 4104 | 5197 | |
| 4105 | 5198 | yocto-queue@0.1.0: {} |
deleted
public/file.svg
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> | |
| \ No newline at end of file | ||
deleted
public/globe.svg
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> | |
| \ No newline at end of file | ||
added
public/icon-192.png
+0 −0
Binary file not shown.
added
public/icon-512.png
+0 −0
Binary file not shown.
added
public/logo-mark.svg
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25.2 -6.2 36.4 23.4"> | |
| 2 | + | |
| 3 | +<g fill="none" stroke="#c7a075" stroke-width="0.22" stroke-linecap="round"><rect x="0" y="0" width="1" height="1" stroke-width="0.14" opacity="0.8"/><rect x="1" y="0" width="1" height="1" stroke-width="0.14" opacity="0.8"/><rect x="0" y="1" width="2" height="2" stroke-width="0.14" opacity="0.8"/><rect x="-3" y="0" width="3" height="3" stroke-width="0.14" opacity="0.8"/><rect x="-3" y="-5" width="5" height="5" stroke-width="0.14" opacity="0.8"/><rect x="2" y="-5" width="8" height="8" stroke-width="0.14" opacity="0.8"/><rect x="-3" y="3" width="13" height="13" stroke-width="0.14" opacity="0.8"/><rect x="-24" y="-5" width="21" height="21" stroke-width="0.14" opacity="0.8"/><path d="M 0 1 A 1 1 0 0 0 1 0 A 1 1 0 0 1 2 1 A 2 2 0 0 1 0 3 A 3 3 0 0 1 -3 0 A 5 5 0 0 1 2 -5 A 8 8 0 0 1 10 3 A 13 13 0 0 1 -3 16 A 21 21 0 0 1 -24 -5" stroke="#c7a075"/></g> | |
| 4 | +</svg> | |
| \ No newline at end of file | ||
deleted
public/next.svg
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> | |
| \ No newline at end of file | ||
deleted
public/vercel.svg
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> | |
| \ No newline at end of file | ||
deleted
public/window.svg
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> | |
| \ No newline at end of file | ||
added
references/phi-conseil-homepage.jpeg
+0 −0
Binary file not shown.
added
scripts/generate-icons.mts
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +/** | |
| 2 | + * Génère le favicon et les icônes à partir du logo (spirale dorée), jamais | |
| 3 | + * d'icône générique. Usage : pnpm icons | |
| 4 | + */ | |
| 5 | +import { writeFileSync, mkdirSync } from "node:fs"; | |
| 6 | +import sharp from "sharp"; | |
| 7 | +import { buildSpiral } from "../src/lib/spiral.ts"; | |
| 8 | + | |
| 9 | +const { squares, path, bbox } = buildSpiral(8); | |
| 10 | +const pad = 1.2; | |
| 11 | +const vb = `${bbox.x - pad} ${bbox.y - pad} ${bbox.w + pad * 2} ${bbox.h + pad * 2}`; | |
| 12 | +const gold = "#c7a075"; | |
| 13 | +const ivory = "#f5eee4"; | |
| 14 | + | |
| 15 | +function svg({ background, size }: { background: boolean; size?: number }) { | |
| 16 | + const rects = squares | |
| 17 | + .map( | |
| 18 | + (s) => | |
| 19 | + `<rect x="${s.x}" y="${s.y}" width="${s.s}" height="${s.s}" stroke-width="${background ? 0.32 : 0.14}" opacity="0.8"/>`, | |
| 20 | + ) | |
| 21 | + .join(""); | |
| 22 | + const dim = size ? ` width="${size}" height="${size}"` : ""; | |
| 23 | + // Le rectangle d'or est 34×21 : on le centre dans un carré pour l'icône. | |
| 24 | + const side = Math.max(bbox.w, bbox.h) + pad * 2; | |
| 25 | + const sq = `${bbox.x + bbox.w / 2 - side / 2} ${bbox.y + bbox.h / 2 - side / 2} ${side} ${side}`; | |
| 26 | + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${background ? sq : vb}"${dim}> | |
| 27 | +${background ? `<rect x="${bbox.x + bbox.w / 2 - side / 2}" y="${bbox.y + bbox.h / 2 - side / 2}" width="${side}" height="${side}" rx="${side * 0.2}" fill="${ivory}"/>` : ""} | |
| 28 | +<g fill="none" stroke="${gold}" stroke-width="${background ? 0.55 : 0.22}" stroke-linecap="round">${rects}<path d="${path}" stroke="${background ? "#b48a5a" : gold}"/></g> | |
| 29 | +</svg>`; | |
| 30 | +} | |
| 31 | + | |
| 32 | +mkdirSync("src/app", { recursive: true }); | |
| 33 | +mkdirSync("public", { recursive: true }); | |
| 34 | +writeFileSync("src/app/icon.svg", svg({ background: true })); | |
| 35 | +writeFileSync("public/logo-mark.svg", svg({ background: false })); | |
| 36 | + | |
| 37 | +const png = (s: number) => sharp(Buffer.from(svg({ background: true, size: s }))).png(); | |
| 38 | +await png(180).toFile("src/app/apple-icon.png"); | |
| 39 | +await png(512).toFile("public/icon-512.png"); | |
| 40 | +await png(192).toFile("public/icon-192.png"); | |
| 41 | +// favicon.ico : PNG 48 px encapsulé (format accepté par les navigateurs modernes) | |
| 42 | +const p48 = await png(48).toBuffer(); | |
| 43 | +const ico = Buffer.alloc(6 + 16); | |
| 44 | +ico.writeUInt16LE(0, 0); | |
| 45 | +ico.writeUInt16LE(1, 2); | |
| 46 | +ico.writeUInt16LE(1, 4); | |
| 47 | +ico.writeUInt8(48, 6); | |
| 48 | +ico.writeUInt8(48, 7); | |
| 49 | +ico.writeUInt8(0, 8); | |
| 50 | +ico.writeUInt8(0, 9); | |
| 51 | +ico.writeUInt16LE(1, 10); | |
| 52 | +ico.writeUInt16LE(32, 12); | |
| 53 | +ico.writeUInt32LE(p48.length, 14); | |
| 54 | +ico.writeUInt32LE(22, 18); | |
| 55 | +writeFileSync("src/app/favicon.ico", Buffer.concat([ico, p48])); | |
| 56 | +console.log( | |
| 57 | + "Icônes générées : src/app/icon.svg, favicon.ico, apple-icon.png, public/icon-*.png, public/logo-mark.svg", | |
| 58 | +); | |
added
scripts/overflow.mjs
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import { chromium } from "@playwright/test"; | |
| 2 | +const b = await chromium.launch(); | |
| 3 | +const p = await b.newPage({ viewport: { width: 360, height: 800 } }); | |
| 4 | +await p.goto(process.argv[2] ?? "http://127.0.0.1:3100/", { waitUntil: "networkidle" }); | |
| 5 | +const r = await p.evaluate(() => { | |
| 6 | + const w = document.documentElement.clientWidth; | |
| 7 | + const out = []; | |
| 8 | + for (const el of document.querySelectorAll("body *")) { | |
| 9 | + const r = el.getBoundingClientRect(); | |
| 10 | + if (r.right > w + 1 || r.left < -1) | |
| 11 | + out.push( | |
| 12 | + `${el.tagName.toLowerCase()}.${[...el.classList].slice(0, 4).join(".")} left=${Math.round(r.left)} right=${Math.round(r.right)}`, | |
| 13 | + ); | |
| 14 | + } | |
| 15 | + return { scrollWidth: document.documentElement.scrollWidth, w, out: out.slice(0, 25) }; | |
| 16 | +}); | |
| 17 | +console.log(JSON.stringify(r, null, 1)); | |
| 18 | +await b.close(); | |
added
scripts/shot.mjs
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +import { chromium } from "@playwright/test"; | |
| 2 | +const [, , url, out, w, h, full] = process.argv; | |
| 3 | +const b = await chromium.launch(); | |
| 4 | +const p = await b.newPage({ viewport: { width: +w, height: +(h || 900) } }); | |
| 5 | +await p.goto(url, { waitUntil: "networkidle" }); | |
| 6 | +await p.waitForTimeout(300); | |
| 7 | +await p.screenshot({ path: out, fullPage: full !== "0" }); | |
| 8 | +await b.close(); | |
| 9 | +console.log(out); | |
added
src/app/(site)/a-propos/page.tsx
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { home, pages } from "@/lib/content"; | |
| 3 | +import { pageMetadata } from "@/lib/seo"; | |
| 4 | +import { CheckBadge } from "@/components/ui/Icons"; | |
| 5 | +import { Button } from "@/components/ui/Button"; | |
| 6 | +import { VaseScene } from "@/components/graphics/Placeholders"; | |
| 7 | +import Image from "next/image"; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = pageMetadata({ | |
| 10 | + title: pages.about.title, | |
| 11 | + description: pages.about.seoDescription, | |
| 12 | + path: "/a-propos", | |
| 13 | +}); | |
| 14 | + | |
| 15 | +export default function AboutPage() { | |
| 16 | + const { about } = home; | |
| 17 | + return ( | |
| 18 | + <> | |
| 19 | + <section className="section-y bg-ivory-100"> | |
| 20 | + <div className="container-site"> | |
| 21 | + <p className="overtitle">{about.overtitle}</p> | |
| 22 | + <h1 className="text-h2 mt-3 max-w-[40rem]">{pages.about.intro}</h1> | |
| 23 | + </div> | |
| 24 | + </section> | |
| 25 | + <section className="section-y bg-ivory-50"> | |
| 26 | + <div className="container-site grid items-start gap-10 lg:grid-cols-[minmax(0,0.4fr)_minmax(0,0.6fr)]"> | |
| 27 | + <div className="relative aspect-[4/5] overflow-hidden rounded-[var(--radius-xl)]"> | |
| 28 | + {about.image.src ? ( | |
| 29 | + <Image | |
| 30 | + src={about.image.src} | |
| 31 | + alt={about.image.alt} | |
| 32 | + fill | |
| 33 | + sizes="(min-width:1024px) 40vw, 100vw" | |
| 34 | + className="object-cover" | |
| 35 | + /> | |
| 36 | + ) : ( | |
| 37 | + <VaseScene className="absolute inset-0 h-full w-full" /> | |
| 38 | + )} | |
| 39 | + </div> | |
| 40 | + <div> | |
| 41 | + {pages.about.body.map((p, i) => ( | |
| 42 | + <p key={i} className="text-lead text-ink-700 mt-0 mb-5"> | |
| 43 | + {p} | |
| 44 | + </p> | |
| 45 | + ))} | |
| 46 | + <h2 className="text-h4 mt-8">Nos engagements</h2> | |
| 47 | + <ul className="mt-4 flex flex-col gap-3"> | |
| 48 | + {about.benefits.map((b) => ( | |
| 49 | + <li key={b} className="text-ink-900 flex items-start gap-3"> | |
| 50 | + <CheckBadge className="mt-0.5 h-5 w-5 shrink-0" /> | |
| 51 | + <span>{b}</span> | |
| 52 | + </li> | |
| 53 | + ))} | |
| 54 | + </ul> | |
| 55 | + <div className="mt-8"> | |
| 56 | + <Button href="/nous-joindre" size="lg"> | |
| 57 | + Nous joindre | |
| 58 | + </Button> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + </section> | |
| 63 | + </> | |
| 64 | + ); | |
| 65 | +} | |
added
src/app/(site)/confidentialite/page.tsx
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { pages, site } from "@/lib/content"; | |
| 3 | +import { pageMetadata } from "@/lib/seo"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = pageMetadata({ | |
| 6 | + title: pages.privacy.title, | |
| 7 | + description: pages.privacy.seoDescription, | |
| 8 | + path: "/confidentialite", | |
| 9 | +}); | |
| 10 | + | |
| 11 | +const dateFr = new Intl.DateTimeFormat("fr-CA", { dateStyle: "long", timeZone: "UTC" }); | |
| 12 | + | |
| 13 | +export default function PrivacyPage() { | |
| 14 | + const p = pages.privacy; | |
| 15 | + const email = site.contact.email; | |
| 16 | + return ( | |
| 17 | + <> | |
| 18 | + <section className="section-y bg-ivory-100"> | |
| 19 | + <div className="container-site"> | |
| 20 | + <h1 className="text-h2 max-w-[44rem]">{p.title}</h1> | |
| 21 | + <p className="text-ink-500 mt-4 text-sm"> | |
| 22 | + Dernière mise à jour : {dateFr.format(new Date(`${p.lastUpdated}T00:00:00Z`))} | |
| 23 | + </p> | |
| 24 | + {!p.reviewed ? ( | |
| 25 | + <p | |
| 26 | + role="note" | |
| 27 | + className="border-gold-500/60 bg-gold-300/30 text-ink-900 mt-4 max-w-[44rem] rounded-md border px-4 py-3 text-sm" | |
| 28 | + > | |
| 29 | + Gabarit en attente de validation juridique. Ce texte décrit les pratiques réelles du | |
| 30 | + site mais doit être revu par le client ou son conseiller avant publication définitive. | |
| 31 | + </p> | |
| 32 | + ) : null} | |
| 33 | + </div> | |
| 34 | + </section> | |
| 35 | + <section className="section-y bg-ivory-50"> | |
| 36 | + <div className="container-site prose-site max-w-[44rem]"> | |
| 37 | + <h2 className="text-h4">1. Renseignements recueillis</h2> | |
| 38 | + <p className="mt-3"> | |
| 39 | + Le site ne recueille que les renseignements que vous saisissez volontairement dans le | |
| 40 | + formulaire « Nous joindre » : nom, adresse courriel, organisation (facultatif), sujet et | |
| 41 | + message. Aucun témoin (cookie) de suivi publicitaire ni outil d’analyse tiers n’est | |
| 42 | + utilisé. | |
| 43 | + </p> | |
| 44 | + <h2 className="text-h4 mt-10">2. Utilisation</h2> | |
| 45 | + <p className="mt-3"> | |
| 46 | + Ces renseignements servent uniquement à répondre à votre demande. Ils sont transmis par | |
| 47 | + courriel à {site.brand.legalName} et ne sont ni vendus ni partagés avec des tiers, sauf | |
| 48 | + obligation légale. | |
| 49 | + </p> | |
| 50 | + <h2 className="text-h4 mt-10">3. Conservation</h2> | |
| 51 | + <p className="mt-3"> | |
| 52 | + Les messages reçus sont conservés le temps nécessaire au traitement de la demande et au | |
| 53 | + suivi de la relation d’affaires, puis supprimés. Le site lui-même ne stocke pas les | |
| 54 | + messages : seules des traces techniques anonymisées (empreinte d’adresse IP, horodatage) | |
| 55 | + sont journalisées pour prévenir les abus. | |
| 56 | + </p> | |
| 57 | + <h2 className="text-h4 mt-10">4. Vos droits</h2> | |
| 58 | + <p className="mt-3"> | |
| 59 | + Conformément à la Loi sur la protection des renseignements personnels dans le secteur | |
| 60 | + privé (Québec), vous pouvez demander l’accès à vos renseignements, leur rectification ou | |
| 61 | + leur suppression | |
| 62 | + {email ? ( | |
| 63 | + <> | |
| 64 | + {" "} | |
| 65 | + en écrivant à{" "} | |
| 66 | + <a className="underline underline-offset-4" href={`mailto:${email}`}> | |
| 67 | + {email} | |
| 68 | + </a> | |
| 69 | + </> | |
| 70 | + ) : ( | |
| 71 | + " par l’entremise du formulaire de contact" | |
| 72 | + )} | |
| 73 | + . | |
| 74 | + </p> | |
| 75 | + <h2 className="text-h4 mt-10">5. Sécurité</h2> | |
| 76 | + <p className="mt-3"> | |
| 77 | + Le site est servi exclusivement en HTTPS. Les données du formulaire sont validées et | |
| 78 | + transmises de façon sécurisée au fournisseur de messagerie configuré par{" "} | |
| 79 | + {site.brand.legalName}. | |
| 80 | + </p> | |
| 81 | + <h2 id="mentions-legales" className="text-h4 mt-12 scroll-mt-28"> | |
| 82 | + Mentions légales | |
| 83 | + </h2> | |
| 84 | + <p className="mt-3"> | |
| 85 | + Éditeur du site : {site.brand.legalName} | |
| 86 | + {site.contact.address ? `, ${site.contact.address}` : ""}. | |
| 87 | + {email ? ` Contact : ${email}.` : ""} | |
| 88 | + </p> | |
| 89 | + <p className="mt-3"> | |
| 90 | + Les textes, le logo et les éléments graphiques de ce site sont la propriété de{" "} | |
| 91 | + {site.brand.legalName} ou de leurs auteurs respectifs et ne peuvent être reproduits sans | |
| 92 | + autorisation. Les crédits des photographies et polices figurent dans le registre des | |
| 93 | + actifs du site. | |
| 94 | + </p> | |
| 95 | + </div> | |
| 96 | + </section> | |
| 97 | + </> | |
| 98 | + ); | |
| 99 | +} | |
added
src/app/(site)/nous-joindre/page.tsx
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { pages, site } from "@/lib/content"; | |
| 3 | +import { pageMetadata } from "@/lib/seo"; | |
| 4 | +import { ContactForm } from "@/components/forms/ContactForm"; | |
| 5 | +import { LinkedInIcon, MailIcon } from "@/components/ui/Icons"; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = pageMetadata({ | |
| 8 | + title: pages.contact.title, | |
| 9 | + description: pages.contact.seoDescription, | |
| 10 | + path: "/nous-joindre", | |
| 11 | +}); | |
| 12 | + | |
| 13 | +export default function ContactPage() { | |
| 14 | + const c = pages.contact; | |
| 15 | + const hasCoords = | |
| 16 | + site.contact.email || site.contact.phone || site.contact.address || site.social.linkedin; | |
| 17 | + return ( | |
| 18 | + <> | |
| 19 | + <section className="section-y bg-ivory-100"> | |
| 20 | + <div className="container-site"> | |
| 21 | + <h1 className="text-h2">{c.title}</h1> | |
| 22 | + <p className="text-lead text-ink-700 mt-4 max-w-[40rem]">{c.intro}</p> | |
| 23 | + </div> | |
| 24 | + </section> | |
| 25 | + <section className="section-y bg-ivory-50"> | |
| 26 | + <div className="container-site grid gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.5fr)]"> | |
| 27 | + <ContactForm | |
| 28 | + subjects={c.subjects} | |
| 29 | + consentLabel={c.consentLabel} | |
| 30 | + successTitle={c.successTitle} | |
| 31 | + successMessage={c.successMessage} | |
| 32 | + /> | |
| 33 | + <aside aria-label="Coordonnées" className="bg-pale-200/60 rounded-[var(--radius-lg)] p-7"> | |
| 34 | + <h2 className="text-h4">Coordonnées</h2> | |
| 35 | + {hasCoords ? ( | |
| 36 | + <ul className="text-ink-900 mt-4 flex flex-col gap-3"> | |
| 37 | + {site.contact.email ? ( | |
| 38 | + <li className="flex items-center gap-3"> | |
| 39 | + <MailIcon className="text-navy-800 h-5 w-5" /> | |
| 40 | + <a | |
| 41 | + className="underline-offset-4 hover:underline" | |
| 42 | + href={`mailto:${site.contact.email}`} | |
| 43 | + > | |
| 44 | + {site.contact.email} | |
| 45 | + </a> | |
| 46 | + </li> | |
| 47 | + ) : null} | |
| 48 | + {site.contact.phone ? <li>{site.contact.phone}</li> : null} | |
| 49 | + {site.contact.address ? ( | |
| 50 | + <li className="whitespace-pre-line">{site.contact.address}</li> | |
| 51 | + ) : null} | |
| 52 | + {site.social.linkedin ? ( | |
| 53 | + <li className="flex items-center gap-3"> | |
| 54 | + <LinkedInIcon className="text-navy-800 h-5 w-5" /> | |
| 55 | + <a | |
| 56 | + className="underline-offset-4 hover:underline" | |
| 57 | + href={site.social.linkedin} | |
| 58 | + target="_blank" | |
| 59 | + rel="noopener noreferrer" | |
| 60 | + > | |
| 61 | + LinkedIn (nouvelle fenêtre) | |
| 62 | + </a> | |
| 63 | + </li> | |
| 64 | + ) : null} | |
| 65 | + </ul> | |
| 66 | + ) : ( | |
| 67 | + <p className="text-ink-700 mt-4"> | |
| 68 | + Le formulaire ci-contre est le moyen privilégié pour nous joindre. | |
| 69 | + {site.contact.region ? ` Nous servons la clientèle de ${site.contact.region}.` : ""} | |
| 70 | + </p> | |
| 71 | + )} | |
| 72 | + </aside> | |
| 73 | + </div> | |
| 74 | + </section> | |
| 75 | + </> | |
| 76 | + ); | |
| 77 | +} | |
added
src/app/(site)/page.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { Hero } from "@/components/sections/Hero"; | |
| 2 | +import { Services } from "@/components/sections/Services"; | |
| 3 | +import { About } from "@/components/sections/About"; | |
| 4 | +import { Testimonials } from "@/components/sections/Testimonials"; | |
| 5 | +import { CallToAction } from "@/components/sections/CallToAction"; | |
| 6 | +import { organizationJsonLd } from "@/lib/seo"; | |
| 7 | + | |
| 8 | +export default function HomePage() { | |
| 9 | + return ( | |
| 10 | + <> | |
| 11 | + <script | |
| 12 | + type="application/ld+json" | |
| 13 | + dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd()) }} | |
| 14 | + /> | |
| 15 | + <Hero /> | |
| 16 | + <Services /> | |
| 17 | + <About /> | |
| 18 | + <Testimonials /> | |
| 19 | + <CallToAction /> | |
| 20 | + </> | |
| 21 | + ); | |
| 22 | +} | |
added
src/app/(site)/services/page.tsx
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { home, pages } from "@/lib/content"; | |
| 3 | +import { pageMetadata } from "@/lib/seo"; | |
| 4 | +import { SectionTitle } from "@/components/ui/SectionTitle"; | |
| 5 | +import { ServiceIcon } from "@/components/ui/ServiceIcon"; | |
| 6 | +import { Button } from "@/components/ui/Button"; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = pageMetadata({ | |
| 9 | + title: pages.services.title, | |
| 10 | + description: pages.services.seoDescription, | |
| 11 | + path: "/services", | |
| 12 | +}); | |
| 13 | + | |
| 14 | +export default function ServicesPage() { | |
| 15 | + return ( | |
| 16 | + <> | |
| 17 | + <section className="section-y bg-ivory-100"> | |
| 18 | + <div className="container-site"> | |
| 19 | + <SectionTitle as="h1" id="services-title"> | |
| 20 | + {pages.services.title} | |
| 21 | + </SectionTitle> | |
| 22 | + <p className="text-lead text-ink-700 mx-auto mt-6 max-w-[42rem] text-center"> | |
| 23 | + {pages.services.intro} | |
| 24 | + </p> | |
| 25 | + </div> | |
| 26 | + </section> | |
| 27 | + <section className="section-y bg-ivory-50" aria-label="Détail des services"> | |
| 28 | + <div className="container-site grid gap-8 md:grid-cols-2"> | |
| 29 | + {home.services.items.map((s) => ( | |
| 30 | + <article | |
| 31 | + key={s.slug} | |
| 32 | + id={s.slug} | |
| 33 | + className="border-pale-300/70 scroll-mt-28 rounded-[var(--radius-lg)] border bg-white/60 p-7 md:p-9" | |
| 34 | + > | |
| 35 | + <span className="bg-pale-200 text-navy-800 flex h-14 w-14 items-center justify-center rounded-full"> | |
| 36 | + <ServiceIcon name={s.icon} className="h-7 w-7" /> | |
| 37 | + </span> | |
| 38 | + <h2 className="text-h4 mt-5">{s.title}</h2> | |
| 39 | + <p className="text-ink-900 mt-3">{s.summary}</p> | |
| 40 | + {s.description ? <p className="text-ink-700 mt-3">{s.description}</p> : null} | |
| 41 | + </article> | |
| 42 | + ))} | |
| 43 | + </div> | |
| 44 | + <div className="container-site mt-12 text-center"> | |
| 45 | + <Button href="/nous-joindre" size="lg"> | |
| 46 | + Discuter de votre projet | |
| 47 | + </Button> | |
| 48 | + </div> | |
| 49 | + </section> | |
| 50 | + </> | |
| 51 | + ); | |
| 52 | +} | |
added
src/app/(site)/temoignages/page.tsx
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { pages, displayableTestimonials } from "@/lib/content"; | |
| 3 | +import { pageMetadata } from "@/lib/seo"; | |
| 4 | +import { TestimonialSlider } from "@/components/sections/TestimonialSlider"; | |
| 5 | +import { Button } from "@/components/ui/Button"; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = pageMetadata({ | |
| 8 | + title: pages.testimonials.title, | |
| 9 | + description: pages.testimonials.seoDescription, | |
| 10 | + path: "/temoignages", | |
| 11 | +}); | |
| 12 | + | |
| 13 | +export default function TestimonialsPage() { | |
| 14 | + const preview = process.env.NEXT_PUBLIC_CONTENT_PREVIEW === "1"; | |
| 15 | + const { items, isPreview } = displayableTestimonials(preview); | |
| 16 | + return ( | |
| 17 | + <> | |
| 18 | + <section className="section-y bg-ivory-100"> | |
| 19 | + <div className="container-site"> | |
| 20 | + <h1 className="text-h2">{pages.testimonials.title}</h1> | |
| 21 | + <p className="text-lead text-ink-700 mt-4 max-w-[40rem]">{pages.testimonials.intro}</p> | |
| 22 | + </div> | |
| 23 | + </section> | |
| 24 | + <section className="section-y bg-ivory-50"> | |
| 25 | + <div className="container-site"> | |
| 26 | + {items.length > 0 ? ( | |
| 27 | + <> | |
| 28 | + {isPreview ? ( | |
| 29 | + <p | |
| 30 | + role="status" | |
| 31 | + className="border-gold-500/60 bg-gold-300/30 text-ink-900 mb-4 rounded-md border px-4 py-2 text-sm" | |
| 32 | + > | |
| 33 | + Prévisualisation : témoignage de maquette non vérifié. | |
| 34 | + </p> | |
| 35 | + ) : null} | |
| 36 | + <TestimonialSlider items={items} /> | |
| 37 | + </> | |
| 38 | + ) : ( | |
| 39 | + <div className="bg-pale-200 rounded-[var(--radius-xl)] px-8 py-12 text-center"> | |
| 40 | + <p className="text-navy-900 font-serif text-xl">{pages.testimonials.emptyMessage}</p> | |
| 41 | + <div className="mt-6"> | |
| 42 | + <Button href="/nous-joindre">Parlons de votre projet</Button> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + )} | |
| 46 | + </div> | |
| 47 | + </section> | |
| 48 | + </> | |
| 49 | + ); | |
| 50 | +} | |
added
src/app/api/contact/route.ts
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +import { NextResponse } from "next/server"; | |
| 2 | +import { contactSchema } from "@/lib/validation"; | |
| 3 | +import { getEnv } from "@/lib/env"; | |
| 4 | +import { getMailer } from "@/lib/mail"; | |
| 5 | +import { clientIp, createDedupe, createMemoryRateLimiter, fingerprint } from "@/lib/security"; | |
| 6 | + | |
| 7 | +const MAX_BODY_BYTES = 32 * 1024; | |
| 8 | +const MIN_FILL_MS = 2500; | |
| 9 | + | |
| 10 | +let limiter: ReturnType<typeof createMemoryRateLimiter> | null = null; | |
| 11 | +const dedupe = createDedupe(600); | |
| 12 | + | |
| 13 | +function getLimiter() { | |
| 14 | + if (!limiter) { | |
| 15 | + const env = getEnv(); | |
| 16 | + limiter = createMemoryRateLimiter(env.CONTACT_RATE_LIMIT, env.CONTACT_RATE_WINDOW_S); | |
| 17 | + } | |
| 18 | + return limiter; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export async function POST(request: Request) { | |
| 22 | + const ip = clientIp(request.headers); | |
| 23 | + const ipHash = fingerprint(ip); | |
| 24 | + | |
| 25 | + const len = Number(request.headers.get("content-length") ?? "0"); | |
| 26 | + if (len > MAX_BODY_BYTES) { | |
| 27 | + return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 }); | |
| 28 | + } | |
| 29 | + if (!(request.headers.get("content-type") ?? "").includes("application/json")) { | |
| 30 | + return NextResponse.json({ ok: false, error: "Format non pris en charge." }, { status: 415 }); | |
| 31 | + } | |
| 32 | + | |
| 33 | + const rl = getLimiter().check(ipHash); | |
| 34 | + if (!rl.allowed) { | |
| 35 | + return NextResponse.json( | |
| 36 | + { ok: false, error: "Trop de tentatives." }, | |
| 37 | + { status: 429, headers: { "Retry-After": String(rl.retryAfterS) } }, | |
| 38 | + ); | |
| 39 | + } | |
| 40 | + | |
| 41 | + let raw: unknown; | |
| 42 | + try { | |
| 43 | + const text = await request.text(); | |
| 44 | + if (text.length > MAX_BODY_BYTES) { | |
| 45 | + return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 }); | |
| 46 | + } | |
| 47 | + raw = JSON.parse(text); | |
| 48 | + } catch { | |
| 49 | + return NextResponse.json({ ok: false, error: "Requête invalide." }, { status: 400 }); | |
| 50 | + } | |
| 51 | + | |
| 52 | + const parsed = contactSchema.safeParse(raw); | |
| 53 | + if (!parsed.success) { | |
| 54 | + // Honeypot rempli → réponse « succès » silencieuse pour ne pas renseigner les robots. | |
| 55 | + const honeypot = parsed.error.issues.some((i) => i.path[0] === "website"); | |
| 56 | + if (honeypot) { | |
| 57 | + console.info(`[contact] honeypot déclenché ip=${ipHash}`); | |
| 58 | + return NextResponse.json({ ok: true, delivered: false }); | |
| 59 | + } | |
| 60 | + const fields = Object.fromEntries( | |
| 61 | + parsed.error.issues.map((i) => [i.path.join("."), i.message]), | |
| 62 | + ); | |
| 63 | + return NextResponse.json( | |
| 64 | + { ok: false, error: "Certains champs sont invalides.", fields }, | |
| 65 | + { status: 400 }, | |
| 66 | + ); | |
| 67 | + } | |
| 68 | + | |
| 69 | + const data = parsed.data; | |
| 70 | + if (data.startedAt !== undefined && Date.now() - data.startedAt < MIN_FILL_MS) { | |
| 71 | + console.info(`[contact] soumission trop rapide ip=${ipHash}`); | |
| 72 | + return NextResponse.json({ ok: true, delivered: false }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + const key = fingerprint(`${data.email}|${data.subject}|${data.message}`); | |
| 76 | + if (dedupe.isDuplicate(key)) { | |
| 77 | + return NextResponse.json({ ok: true, delivered: false, duplicate: true }); | |
| 78 | + } | |
| 79 | + | |
| 80 | + try { | |
| 81 | + const result = await getMailer().send(data, { ipHash }); | |
| 82 | + console.info( | |
| 83 | + `[contact] reçu ip=${ipHash} delivered=${result.delivered} id=${result.id ?? "-"}`, | |
| 84 | + ); | |
| 85 | + return NextResponse.json({ ok: true, delivered: result.delivered }); | |
| 86 | + } catch (e) { | |
| 87 | + console.error( | |
| 88 | + `[contact] échec d'envoi ip=${ipHash}`, | |
| 89 | + e instanceof Error ? e.message : "erreur", | |
| 90 | + ); | |
| 91 | + return NextResponse.json( | |
| 92 | + { ok: false, error: "L'envoi a échoué. Veuillez réessayer plus tard." }, | |
| 93 | + { status: 502 }, | |
| 94 | + ); | |
| 95 | + } | |
| 96 | +} | |
added
src/app/apple-icon.png
+0 −0
Binary file not shown.
added
src/app/error.tsx
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect } from "react"; | |
| 4 | +import { Button } from "@/components/ui/Button"; | |
| 5 | + | |
| 6 | +export default function ErrorPage({ | |
| 7 | + error, | |
| 8 | + reset, | |
| 9 | +}: { | |
| 10 | + error: Error & { digest?: string }; | |
| 11 | + reset: () => void; | |
| 12 | +}) { | |
| 13 | + useEffect(() => { | |
| 14 | + // Journal côté client sans détail sensible ; le digest permet la corrélation serveur. | |
| 15 | + console.error("Erreur de rendu", error.digest ?? ""); | |
| 16 | + }, [error]); | |
| 17 | + return ( | |
| 18 | + <section className="section-y bg-ivory-100"> | |
| 19 | + <div className="container-site py-16 text-center"> | |
| 20 | + <h1 className="text-h2">Une erreur est survenue</h1> | |
| 21 | + <p className="text-lead text-ink-700 mx-auto mt-4 max-w-[32rem]"> | |
| 22 | + Veuillez réessayer. Si le problème persiste, contactez-nous. | |
| 23 | + </p> | |
| 24 | + <div className="mt-8 flex justify-center gap-3"> | |
| 25 | + <Button onClick={reset} arrow={false}> | |
| 26 | + Réessayer | |
| 27 | + </Button> | |
| 28 | + <Button href="/" variant="outline"> | |
| 29 | + Accueil | |
| 30 | + </Button> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + </section> | |
| 34 | + ); | |
| 35 | +} | |
modified
src/app/favicon.ico
+0 −0
Binary file not shown.
modified
src/app/globals.css
+153 −16
@@ -1,26 +1,163 @@ | ||
| 1 | 1 | @import "tailwindcss"; |
| 2 | +@import "../styles/tokens.css"; | |
| 2 | 3 | |
| 3 | −:root { | |
| 4 | − --background: #ffffff; | |
| 5 | − --foreground: #171717; | |
| 4 | +@theme inline { | |
| 5 | + --color-ivory-50: var(--color-ivory-50); | |
| 6 | + --color-ivory-100: var(--color-ivory-100); | |
| 7 | + --color-ivory-200: var(--color-ivory-200); | |
| 8 | + --color-ivory-300: var(--color-ivory-300); | |
| 9 | + --color-pale-100: var(--color-pale-100); | |
| 10 | + --color-pale-200: var(--color-pale-200); | |
| 11 | + --color-pale-300: var(--color-pale-300); | |
| 12 | + --color-pale-400: var(--color-pale-400); | |
| 13 | + --color-slate-500: var(--color-slate-500); | |
| 14 | + --color-slate-600: var(--color-slate-600); | |
| 15 | + --color-slate-700: var(--color-slate-700); | |
| 16 | + --color-navy-800: var(--color-navy-800); | |
| 17 | + --color-navy-900: var(--color-navy-900); | |
| 18 | + --color-gold-300: var(--color-gold-300); | |
| 19 | + --color-gold-400: var(--color-gold-400); | |
| 20 | + --color-gold-500: var(--color-gold-500); | |
| 21 | + --color-gold-600: var(--color-gold-600); | |
| 22 | + --color-gold-700: var(--color-gold-700); | |
| 23 | + --color-ink-900: var(--color-ink-900); | |
| 24 | + --color-ink-700: var(--color-ink-700); | |
| 25 | + --color-ink-500: var(--color-ink-500); | |
| 26 | + --color-ink-300: var(--color-ink-300); | |
| 27 | + --color-danger: var(--color-danger); | |
| 28 | + --color-success: var(--color-success); | |
| 29 | + | |
| 30 | + --font-serif: var(--font-serif); | |
| 31 | + --font-sans: var(--font-sans); | |
| 32 | + | |
| 33 | + --radius-sm: var(--radius-sm); | |
| 34 | + --radius-md: var(--radius-md); | |
| 35 | + --radius-lg: var(--radius-lg); | |
| 36 | + --radius-xl: var(--radius-xl); | |
| 37 | + --radius-pill: var(--radius-pill); | |
| 38 | + | |
| 39 | + --shadow-sm: var(--shadow-sm); | |
| 40 | + --shadow-md: var(--shadow-md); | |
| 41 | + --shadow-lg: var(--shadow-lg); | |
| 42 | + | |
| 43 | + --ease-out: var(--ease-out); | |
| 6 | 44 | } |
| 7 | 45 | |
| 8 | −@theme inline { | |
| 9 | − --color-background: var(--background); | |
| 10 | − --color-foreground: var(--foreground); | |
| 11 | − --font-sans: var(--font-geist-sans); | |
| 12 | − --font-mono: var(--font-geist-mono); | |
| 46 | +@layer base { | |
| 47 | + html { | |
| 48 | + scroll-behavior: smooth; | |
| 49 | + -webkit-text-size-adjust: 100%; | |
| 50 | + } | |
| 51 | + @media (prefers-reduced-motion: reduce) { | |
| 52 | + html { | |
| 53 | + scroll-behavior: auto; | |
| 54 | + } | |
| 55 | + *, | |
| 56 | + *::before, | |
| 57 | + *::after { | |
| 58 | + animation-duration: 0.01ms !important; | |
| 59 | + animation-iteration-count: 1 !important; | |
| 60 | + transition-duration: 0.01ms !important; | |
| 61 | + } | |
| 62 | + } | |
| 63 | + body { | |
| 64 | + background: var(--color-ivory-50); | |
| 65 | + color: var(--color-ink-700); | |
| 66 | + font-family: var(--font-sans); | |
| 67 | + font-size: var(--text-base); | |
| 68 | + line-height: var(--leading-normal); | |
| 69 | + overflow-x: clip; | |
| 70 | + } | |
| 71 | + h1, | |
| 72 | + h2, | |
| 73 | + h3, | |
| 74 | + h4 { | |
| 75 | + font-family: var(--font-serif); | |
| 76 | + color: var(--color-navy-900); | |
| 77 | + font-weight: 500; | |
| 78 | + letter-spacing: -0.01em; | |
| 79 | + text-wrap: balance; | |
| 80 | + } | |
| 81 | + p { | |
| 82 | + text-wrap: pretty; | |
| 83 | + } | |
| 84 | + :focus-visible { | |
| 85 | + outline: 3px solid var(--color-slate-500); | |
| 86 | + outline-offset: 3px; | |
| 87 | + border-radius: var(--radius-sm); | |
| 88 | + } | |
| 89 | + ::selection { | |
| 90 | + background: var(--color-gold-300); | |
| 91 | + color: var(--color-navy-900); | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +@layer components { | |
| 96 | + .container-site { | |
| 97 | + width: 100%; | |
| 98 | + max-width: var(--container-max); | |
| 99 | + margin-inline: auto; | |
| 100 | + padding-inline: var(--container-pad); | |
| 101 | + } | |
| 102 | + .section-y { | |
| 103 | + padding-block: var(--section-y); | |
| 104 | + } | |
| 105 | + .overtitle { | |
| 106 | + font-family: var(--font-sans); | |
| 107 | + font-size: var(--text-xs); | |
| 108 | + font-weight: 600; | |
| 109 | + letter-spacing: var(--tracking-caps); | |
| 110 | + text-transform: uppercase; | |
| 111 | + color: var(--color-gold-700); | |
| 112 | + } | |
| 113 | + .skip-link { | |
| 114 | + position: absolute; | |
| 115 | + left: 1rem; | |
| 116 | + top: -100%; | |
| 117 | + z-index: 100; | |
| 118 | + padding: 0.5rem 1rem; | |
| 119 | + background: var(--color-navy-900); | |
| 120 | + color: white; | |
| 121 | + border-radius: var(--radius-sm); | |
| 122 | + } | |
| 123 | + .skip-link:focus { | |
| 124 | + top: 1rem; | |
| 125 | + } | |
| 126 | + .text-display { | |
| 127 | + font-size: var(--text-display); | |
| 128 | + line-height: var(--leading-tight); | |
| 129 | + } | |
| 130 | + .text-h2 { | |
| 131 | + font-size: var(--text-4xl); | |
| 132 | + line-height: var(--leading-snug); | |
| 133 | + } | |
| 134 | + .text-h3 { | |
| 135 | + font-size: var(--text-3xl); | |
| 136 | + line-height: var(--leading-snug); | |
| 137 | + } | |
| 138 | + .text-h4 { | |
| 139 | + font-size: var(--text-xl); | |
| 140 | + line-height: var(--leading-snug); | |
| 141 | + } | |
| 142 | + .text-lead { | |
| 143 | + font-size: var(--text-lg); | |
| 144 | + } | |
| 13 | 145 | } |
| 14 | 146 | |
| 15 | −@media (prefers-color-scheme: dark) { | |
| 16 | − :root { | |
| 17 | − --background: #0a0a0a; | |
| 18 | − --foreground: #ededed; | |
| 147 | +@layer components { | |
| 148 | + .prose-site p { | |
| 149 | + color: var(--color-ink-700); | |
| 150 | + } | |
| 151 | + .prose-site a { | |
| 152 | + color: var(--color-navy-900); | |
| 19 | 153 | } |
| 20 | 154 | } |
| 21 | 155 | |
| 22 | −body { | |
| 23 | − background: var(--background); | |
| 24 | − color: var(--foreground); | |
| 25 | − font-family: Arial, Helvetica, sans-serif; | |
| 156 | +@keyframes fade-in { | |
| 157 | + from { | |
| 158 | + opacity: 0; | |
| 159 | + } | |
| 160 | + to { | |
| 161 | + opacity: 1; | |
| 162 | + } | |
| 26 | 163 | } |
added
src/app/icon.svg
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25.2 -12.7 36.4 36.4"> | |
| 2 | +<rect x="-25.2" y="-12.7" width="36.4" height="36.4" rx="7.28" fill="#f5eee4"/> | |
| 3 | +<g fill="none" stroke="#c7a075" stroke-width="0.55" stroke-linecap="round"><rect x="0" y="0" width="1" height="1" stroke-width="0.32" opacity="0.8"/><rect x="1" y="0" width="1" height="1" stroke-width="0.32" opacity="0.8"/><rect x="0" y="1" width="2" height="2" stroke-width="0.32" opacity="0.8"/><rect x="-3" y="0" width="3" height="3" stroke-width="0.32" opacity="0.8"/><rect x="-3" y="-5" width="5" height="5" stroke-width="0.32" opacity="0.8"/><rect x="2" y="-5" width="8" height="8" stroke-width="0.32" opacity="0.8"/><rect x="-3" y="3" width="13" height="13" stroke-width="0.32" opacity="0.8"/><rect x="-24" y="-5" width="21" height="21" stroke-width="0.32" opacity="0.8"/><path d="M 0 1 A 1 1 0 0 0 1 0 A 1 1 0 0 1 2 1 A 2 2 0 0 1 0 3 A 3 3 0 0 1 -3 0 A 5 5 0 0 1 2 -5 A 8 8 0 0 1 10 3 A 13 13 0 0 1 -3 16 A 21 21 0 0 1 -24 -5" stroke="#b48a5a"/></g> | |
| 4 | +</svg> | |
| \ No newline at end of file | ||
modified
src/app/layout.tsx
+48 −16
@@ -1,29 +1,61 @@ | ||
| 1 | −import type { Metadata } from "next"; | |
| 2 | −import { Geist, Geist_Mono } from "next/font/google"; | |
| 1 | +import type { Metadata, Viewport } from "next"; | |
| 2 | +import { EB_Garamond, Figtree } from "next/font/google"; | |
| 3 | +import { site } from "@/lib/content"; | |
| 4 | +import { siteUrl } from "@/lib/env"; | |
| 5 | +import { Header } from "@/components/layout/Header"; | |
| 6 | +import { Footer } from "@/components/layout/Footer"; | |
| 3 | 7 | import "./globals.css"; |
| 4 | 8 | |
| 5 | −const geistSans = Geist({ | |
| 6 | − variable: "--font-geist-sans", | |
| 7 | − subsets: ["latin"], | |
| 9 | +const garamond = EB_Garamond({ | |
| 10 | + variable: "--font-eb-garamond", | |
| 11 | + subsets: ["latin", "latin-ext"], | |
| 12 | + weight: ["400", "500", "600"], | |
| 13 | + style: ["normal", "italic"], | |
| 14 | + display: "swap", | |
| 8 | 15 | }); |
| 9 | 16 | |
| 10 | −const geistMono = Geist_Mono({ | |
| 11 | − variable: "--font-geist-mono", | |
| 12 | − subsets: ["latin"], | |
| 17 | +const figtree = Figtree({ | |
| 18 | + variable: "--font-figtree", | |
| 19 | + subsets: ["latin", "latin-ext"], | |
| 20 | + weight: ["400", "500", "600"], | |
| 21 | + display: "swap", | |
| 13 | 22 | }); |
| 14 | 23 | |
| 15 | 24 | export const metadata: Metadata = { |
| 16 | − title: "Create Next App", | |
| 17 | − description: "Generated by create next app", | |
| 25 | + metadataBase: new URL(siteUrl(site.seo.siteUrl)), | |
| 26 | + title: { default: site.seo.defaultTitle, template: site.seo.titleTemplate }, | |
| 27 | + description: site.seo.defaultDescription, | |
| 28 | + applicationName: site.brand.name, | |
| 29 | + openGraph: { | |
| 30 | + type: "website", | |
| 31 | + locale: site.seo.locale, | |
| 32 | + siteName: site.brand.name, | |
| 33 | + title: site.seo.defaultTitle, | |
| 34 | + description: site.seo.defaultDescription, | |
| 35 | + }, | |
| 36 | + twitter: { card: "summary_large_image" }, | |
| 37 | + robots: { index: true, follow: true }, | |
| 18 | 38 | }; |
| 19 | 39 | |
| 20 | −export default function RootLayout({ children }: LayoutProps<"/">) { | |
| 40 | +export const viewport: Viewport = { | |
| 41 | + themeColor: "#f5eee4", | |
| 42 | + width: "device-width", | |
| 43 | + initialScale: 1, | |
| 44 | +}; | |
| 45 | + | |
| 46 | +export default function RootLayout({ children }: { children: React.ReactNode }) { | |
| 21 | 47 | return ( |
| 22 | − <html | |
| 23 | − lang="en" | |
| 24 | − className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} | |
| 25 | − > | |
| 26 | − <body className="min-h-full flex flex-col">{children}</body> | |
| 48 | + <html lang="fr-CA" className={`${garamond.variable} ${figtree.variable} h-full antialiased`}> | |
| 49 | + <body className="flex min-h-full flex-col"> | |
| 50 | + <a href="#contenu" className="skip-link"> | |
| 51 | + Aller au contenu | |
| 52 | + </a> | |
| 53 | + <Header /> | |
| 54 | + <main id="contenu" className="flex-1"> | |
| 55 | + {children} | |
| 56 | + </main> | |
| 57 | + <Footer /> | |
| 58 | + </body> | |
| 27 | 59 | </html> |
| 28 | 60 | ); |
| 29 | 61 | } |
added
src/app/manifest.ts
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import type { MetadataRoute } from "next"; | |
| 2 | +import { site } from "@/lib/content"; | |
| 3 | + | |
| 4 | +export default function manifest(): MetadataRoute.Manifest { | |
| 5 | + return { | |
| 6 | + name: site.brand.name, | |
| 7 | + short_name: site.brand.name, | |
| 8 | + description: site.seo.defaultDescription, | |
| 9 | + start_url: "/", | |
| 10 | + display: "browser", | |
| 11 | + background_color: "#f5eee4", | |
| 12 | + theme_color: "#f5eee4", | |
| 13 | + icons: [ | |
| 14 | + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, | |
| 15 | + { src: "/icon-512.png", sizes: "512x512", type: "image/png" }, | |
| 16 | + ], | |
| 17 | + }; | |
| 18 | +} | |
added
src/app/not-found.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import { Button } from "@/components/ui/Button"; | |
| 2 | + | |
| 3 | +export default function NotFound() { | |
| 4 | + return ( | |
| 5 | + <section className="section-y bg-ivory-100"> | |
| 6 | + <div className="container-site py-16 text-center"> | |
| 7 | + <p className="overtitle">Erreur 404</p> | |
| 8 | + <h1 className="text-h2 mt-3">Cette page n’existe pas</h1> | |
| 9 | + <p className="text-lead text-ink-700 mx-auto mt-4 max-w-[32rem]"> | |
| 10 | + L’adresse demandée est introuvable. Revenez à l’accueil ou contactez-nous. | |
| 11 | + </p> | |
| 12 | + <div className="mt-8 flex justify-center gap-3"> | |
| 13 | + <Button href="/">Accueil</Button> | |
| 14 | + <Button href="/nous-joindre" variant="outline"> | |
| 15 | + Nous joindre | |
| 16 | + </Button> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + </section> | |
| 20 | + ); | |
| 21 | +} | |
added
src/app/opengraph-image.tsx
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import { ImageResponse } from "next/og"; | |
| 2 | +import { site } from "@/lib/content"; | |
| 3 | +import { buildSpiral } from "@/lib/spiral"; | |
| 4 | + | |
| 5 | +export const alt = `${site.brand.name} — ${site.brand.tagline}`; | |
| 6 | +export const size = { width: 1200, height: 630 }; | |
| 7 | +export const contentType = "image/png"; | |
| 8 | + | |
| 9 | +export default function OpenGraphImage() { | |
| 10 | + const { squares, path, bbox } = buildSpiral(8); | |
| 11 | + return new ImageResponse( | |
| 12 | + <div | |
| 13 | + style={{ | |
| 14 | + width: "100%", | |
| 15 | + height: "100%", | |
| 16 | + display: "flex", | |
| 17 | + alignItems: "center", | |
| 18 | + background: "#f5eee4", | |
| 19 | + color: "#1d3047", | |
| 20 | + padding: 80, | |
| 21 | + fontFamily: "Georgia, serif", | |
| 22 | + }} | |
| 23 | + > | |
| 24 | + <div style={{ display: "flex", flexDirection: "column", flex: 1 }}> | |
| 25 | + <div style={{ fontSize: 88, lineHeight: 1 }}>{site.brand.name}</div> | |
| 26 | + <div | |
| 27 | + style={{ | |
| 28 | + fontSize: 22, | |
| 29 | + letterSpacing: 6, | |
| 30 | + marginTop: 24, | |
| 31 | + color: "#3a4652", | |
| 32 | + fontFamily: "sans-serif", | |
| 33 | + }} | |
| 34 | + > | |
| 35 | + {site.brand.tagline} | |
| 36 | + </div> | |
| 37 | + <div | |
| 38 | + style={{ | |
| 39 | + fontSize: 30, | |
| 40 | + marginTop: 48, | |
| 41 | + color: "#3a4652", | |
| 42 | + fontFamily: "sans-serif", | |
| 43 | + maxWidth: 620, | |
| 44 | + }} | |
| 45 | + > | |
| 46 | + {site.seo.defaultDescription} | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | + <svg | |
| 50 | + width="400" | |
| 51 | + height="247" | |
| 52 | + viewBox={`${bbox.x} ${bbox.y} ${bbox.w} ${bbox.h}`} | |
| 53 | + fill="none" | |
| 54 | + stroke="#c7a075" | |
| 55 | + strokeWidth="0.25" | |
| 56 | + > | |
| 57 | + {squares.map((s, i) => ( | |
| 58 | + <rect key={i} x={s.x} y={s.y} width={s.s} height={s.s} /> | |
| 59 | + ))} | |
| 60 | + <path d={path} strokeWidth="0.4" /> | |
| 61 | + </svg> | |
| 62 | + </div>, | |
| 63 | + size, | |
| 64 | + ); | |
| 65 | +} | |
deleted
src/app/page.tsx
+0 −69
@@ -1,69 +0,0 @@ | ||
| 1 | −import Image from "next/image"; | |
| 2 | − | |
| 3 | −export default function Home() { | |
| 4 | − return ( | |
| 5 | − <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> | |
| 6 | − <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> | |
| 7 | − <Image | |
| 8 | − className="dark:invert h-5 w-[100px]" | |
| 9 | − src="/next.svg" | |
| 10 | − alt="Next.js logo" | |
| 11 | − width={100} | |
| 12 | − height={20} | |
| 13 | − priority | |
| 14 | − /> | |
| 15 | − <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> | |
| 16 | − <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> | |
| 17 | − To get started, edit the{" "} | |
| 18 | − <code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]"> | |
| 19 | − page.tsx | |
| 20 | − </code>{" "} | |
| 21 | − file. | |
| 22 | − </h1> | |
| 23 | − <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> | |
| 24 | − Looking for a starting point or more instructions? Head over to{" "} | |
| 25 | − <a | |
| 26 | − href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 27 | − className="font-medium text-zinc-950 dark:text-zinc-50" | |
| 28 | − > | |
| 29 | − Templates | |
| 30 | − </a>{" "} | |
| 31 | − or the{" "} | |
| 32 | − <a | |
| 33 | − href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 34 | − className="font-medium text-zinc-950 dark:text-zinc-50" | |
| 35 | − > | |
| 36 | − Learning | |
| 37 | − </a>{" "} | |
| 38 | − center. | |
| 39 | − </p> | |
| 40 | − </div> | |
| 41 | − <div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> | |
| 42 | − <a | |
| 43 | − className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" | |
| 44 | − href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 45 | − target="_blank" | |
| 46 | − rel="noopener noreferrer" | |
| 47 | − > | |
| 48 | − <Image | |
| 49 | − className="dark:invert h-[14px] w-4" | |
| 50 | − src="/vercel.svg" | |
| 51 | − alt="Vercel logomark" | |
| 52 | − width={16} | |
| 53 | − height={14} | |
| 54 | − /> | |
| 55 | − Deploy Now | |
| 56 | − </a> | |
| 57 | − <a | |
| 58 | − className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" | |
| 59 | − href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |
| 60 | − target="_blank" | |
| 61 | − rel="noopener noreferrer" | |
| 62 | − > | |
| 63 | − Documentation | |
| 64 | − </a> | |
| 65 | − </div> | |
| 66 | − </main> | |
| 67 | − </div> | |
| 68 | − ); | |
| 69 | −} | |
added
src/app/robots.ts
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +import type { MetadataRoute } from "next"; | |
| 2 | +import { baseUrl } from "@/lib/seo"; | |
| 3 | + | |
| 4 | +export default function robots(): MetadataRoute.Robots { | |
| 5 | + return { | |
| 6 | + rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }], | |
| 7 | + sitemap: `${baseUrl()}/sitemap.xml`, | |
| 8 | + }; | |
| 9 | +} | |
added
src/app/sitemap.ts
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +import type { MetadataRoute } from "next"; | |
| 2 | +import { baseUrl } from "@/lib/seo"; | |
| 3 | + | |
| 4 | +export default function sitemap(): MetadataRoute.Sitemap { | |
| 5 | + const base = baseUrl(); | |
| 6 | + const routes: { path: string; priority: number; changeFrequency: "monthly" | "yearly" }[] = [ | |
| 7 | + { path: "/", priority: 1, changeFrequency: "monthly" }, | |
| 8 | + { path: "/services", priority: 0.9, changeFrequency: "monthly" }, | |
| 9 | + { path: "/a-propos", priority: 0.8, changeFrequency: "monthly" }, | |
| 10 | + { path: "/temoignages", priority: 0.6, changeFrequency: "monthly" }, | |
| 11 | + { path: "/nous-joindre", priority: 0.8, changeFrequency: "yearly" }, | |
| 12 | + { path: "/confidentialite", priority: 0.3, changeFrequency: "yearly" }, | |
| 13 | + ]; | |
| 14 | + return routes.map((r) => ({ | |
| 15 | + url: `${base}${r.path}`, | |
| 16 | + changeFrequency: r.changeFrequency, | |
| 17 | + priority: r.priority, | |
| 18 | + })); | |
| 19 | +} | |
added
src/components/forms/ContactForm.tsx
+275 −0
@@ -0,0 +1,275 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useId, useRef, useState } from "react"; | |
| 4 | +import { useForm } from "react-hook-form"; | |
| 5 | +import { zodResolver } from "@hookform/resolvers/zod"; | |
| 6 | +import { contactFormSchema, type ContactFormValues } from "@/lib/validation"; | |
| 7 | +import { Button } from "@/components/ui/Button"; | |
| 8 | + | |
| 9 | +type Status = | |
| 10 | + | { kind: "idle" } | |
| 11 | + | { kind: "submitting" } | |
| 12 | + | { kind: "success"; delivered: boolean } | |
| 13 | + | { kind: "error"; message: string }; | |
| 14 | + | |
| 15 | +export function ContactForm({ | |
| 16 | + subjects, | |
| 17 | + consentLabel, | |
| 18 | + successTitle, | |
| 19 | + successMessage, | |
| 20 | +}: { | |
| 21 | + subjects: string[]; | |
| 22 | + consentLabel: string; | |
| 23 | + successTitle: string; | |
| 24 | + successMessage: string; | |
| 25 | +}) { | |
| 26 | + const id = useId(); | |
| 27 | + const startedAt = useRef<number>(0); | |
| 28 | + useEffect(() => { | |
| 29 | + startedAt.current = Date.now(); | |
| 30 | + }, []); | |
| 31 | + const [status, setStatus] = useState<Status>({ kind: "idle" }); | |
| 32 | + const { | |
| 33 | + register, | |
| 34 | + handleSubmit, | |
| 35 | + reset, | |
| 36 | + formState: { errors, isSubmitting }, | |
| 37 | + } = useForm<ContactFormValues>({ | |
| 38 | + resolver: zodResolver(contactFormSchema), | |
| 39 | + defaultValues: { | |
| 40 | + name: "", | |
| 41 | + email: "", | |
| 42 | + organization: "", | |
| 43 | + subject: "", | |
| 44 | + message: "", | |
| 45 | + consent: undefined, | |
| 46 | + }, | |
| 47 | + }); | |
| 48 | + | |
| 49 | + const submit = async (values: ContactFormValues) => { | |
| 50 | + setStatus({ kind: "submitting" }); | |
| 51 | + try { | |
| 52 | + const form = document.getElementById(`${id}-form`) as HTMLFormElement | null; | |
| 53 | + const honeypot = | |
| 54 | + (form?.elements.namedItem("website") as HTMLInputElement | null)?.value ?? ""; | |
| 55 | + const res = await fetch("/api/contact", { | |
| 56 | + method: "POST", | |
| 57 | + headers: { "Content-Type": "application/json", Accept: "application/json" }, | |
| 58 | + body: JSON.stringify({ ...values, website: honeypot, startedAt: startedAt.current }), | |
| 59 | + }); | |
| 60 | + const data = (await res.json().catch(() => ({}))) as { | |
| 61 | + ok?: boolean; | |
| 62 | + delivered?: boolean; | |
| 63 | + error?: string; | |
| 64 | + }; | |
| 65 | + if (!res.ok || !data.ok) { | |
| 66 | + setStatus({ | |
| 67 | + kind: "error", | |
| 68 | + message: | |
| 69 | + res.status === 429 | |
| 70 | + ? "Trop de tentatives. Veuillez réessayer dans quelques minutes." | |
| 71 | + : (data.error ?? "L’envoi a échoué. Veuillez réessayer ou nous écrire directement."), | |
| 72 | + }); | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + setStatus({ kind: "success", delivered: data.delivered !== false }); | |
| 76 | + reset(); | |
| 77 | + } catch { | |
| 78 | + setStatus({ | |
| 79 | + kind: "error", | |
| 80 | + message: "Connexion impossible. Vérifiez votre réseau puis réessayez.", | |
| 81 | + }); | |
| 82 | + } | |
| 83 | + }; | |
| 84 | + | |
| 85 | + if (status.kind === "success") { | |
| 86 | + return ( | |
| 87 | + <div | |
| 88 | + role="status" | |
| 89 | + aria-live="polite" | |
| 90 | + className="border-success/30 rounded-[var(--radius-lg)] border bg-white/70 p-8" | |
| 91 | + > | |
| 92 | + <h2 className="text-h4 text-success">{successTitle}</h2> | |
| 93 | + <p className="text-ink-700 mt-3">{successMessage}</p> | |
| 94 | + {!status.delivered ? ( | |
| 95 | + <p className="text-ink-500 mt-3 text-sm"> | |
| 96 | + Note : le service de messagerie n’est pas encore configuré sur cet environnement ; le | |
| 97 | + message a été reçu mais pas relayé par courriel. | |
| 98 | + </p> | |
| 99 | + ) : null} | |
| 100 | + <div className="mt-6"> | |
| 101 | + <Button variant="outline" arrow={false} onClick={() => setStatus({ kind: "idle" })}> | |
| 102 | + Envoyer un autre message | |
| 103 | + </Button> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + ); | |
| 107 | + } | |
| 108 | + | |
| 109 | + const field = (name: keyof ContactFormValues) => ({ | |
| 110 | + "aria-invalid": errors[name] ? true : undefined, | |
| 111 | + "aria-describedby": errors[name] ? `${id}-${name}-error` : undefined, | |
| 112 | + }); | |
| 113 | + const err = (name: keyof ContactFormValues) => | |
| 114 | + errors[name] ? ( | |
| 115 | + <p id={`${id}-${name}-error`} role="alert" className="text-danger mt-1.5 text-sm"> | |
| 116 | + {errors[name]?.message as string} | |
| 117 | + </p> | |
| 118 | + ) : null; | |
| 119 | + | |
| 120 | + const input = | |
| 121 | + "mt-1.5 w-full rounded-md border border-pale-300 bg-white px-3.5 py-2.5 text-ink-900 placeholder:text-ink-300 focus:border-slate-500 aria-[invalid]:border-danger"; | |
| 122 | + const label = "block text-sm font-medium text-ink-900"; | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <form | |
| 126 | + id={`${id}-form`} | |
| 127 | + onSubmit={(e) => void handleSubmit(submit)(e)} | |
| 128 | + noValidate | |
| 129 | + className="flex flex-col gap-5" | |
| 130 | + aria-describedby={status.kind === "error" ? `${id}-status` : undefined} | |
| 131 | + > | |
| 132 | + <div className="grid gap-5 sm:grid-cols-2"> | |
| 133 | + <div> | |
| 134 | + <label htmlFor={`${id}-name`} className={label}> | |
| 135 | + Nom <span aria-hidden="true">*</span> | |
| 136 | + </label> | |
| 137 | + <input | |
| 138 | + id={`${id}-name`} | |
| 139 | + type="text" | |
| 140 | + autoComplete="name" | |
| 141 | + required | |
| 142 | + maxLength={120} | |
| 143 | + className={input} | |
| 144 | + {...register("name")} | |
| 145 | + {...field("name")} | |
| 146 | + /> | |
| 147 | + {err("name")} | |
| 148 | + </div> | |
| 149 | + <div> | |
| 150 | + <label htmlFor={`${id}-email`} className={label}> | |
| 151 | + Courriel <span aria-hidden="true">*</span> | |
| 152 | + </label> | |
| 153 | + <input | |
| 154 | + id={`${id}-email`} | |
| 155 | + type="email" | |
| 156 | + autoComplete="email" | |
| 157 | + required | |
| 158 | + maxLength={254} | |
| 159 | + className={input} | |
| 160 | + {...register("email")} | |
| 161 | + {...field("email")} | |
| 162 | + /> | |
| 163 | + {err("email")} | |
| 164 | + </div> | |
| 165 | + </div> | |
| 166 | + <div className="grid gap-5 sm:grid-cols-2"> | |
| 167 | + <div> | |
| 168 | + <label htmlFor={`${id}-organization`} className={label}> | |
| 169 | + Organisation <span className="text-ink-500 font-normal">(facultatif)</span> | |
| 170 | + </label> | |
| 171 | + <input | |
| 172 | + id={`${id}-organization`} | |
| 173 | + type="text" | |
| 174 | + autoComplete="organization" | |
| 175 | + maxLength={160} | |
| 176 | + className={input} | |
| 177 | + {...register("organization")} | |
| 178 | + {...field("organization")} | |
| 179 | + /> | |
| 180 | + {err("organization")} | |
| 181 | + </div> | |
| 182 | + <div> | |
| 183 | + <label htmlFor={`${id}-subject`} className={label}> | |
| 184 | + Sujet <span aria-hidden="true">*</span> | |
| 185 | + </label> | |
| 186 | + <select | |
| 187 | + id={`${id}-subject`} | |
| 188 | + required | |
| 189 | + className={input} | |
| 190 | + defaultValue="" | |
| 191 | + {...register("subject")} | |
| 192 | + {...field("subject")} | |
| 193 | + > | |
| 194 | + <option value="" disabled> | |
| 195 | + Choisir un sujet… | |
| 196 | + </option> | |
| 197 | + {subjects.map((s) => ( | |
| 198 | + <option key={s} value={s}> | |
| 199 | + {s} | |
| 200 | + </option> | |
| 201 | + ))} | |
| 202 | + </select> | |
| 203 | + {err("subject")} | |
| 204 | + </div> | |
| 205 | + </div> | |
| 206 | + <div> | |
| 207 | + <label htmlFor={`${id}-message`} className={label}> | |
| 208 | + Message <span aria-hidden="true">*</span> | |
| 209 | + </label> | |
| 210 | + <textarea | |
| 211 | + id={`${id}-message`} | |
| 212 | + rows={6} | |
| 213 | + required | |
| 214 | + minLength={20} | |
| 215 | + maxLength={4000} | |
| 216 | + className={input} | |
| 217 | + {...register("message")} | |
| 218 | + {...field("message")} | |
| 219 | + /> | |
| 220 | + {err("message")} | |
| 221 | + </div> | |
| 222 | + | |
| 223 | + {/* Honeypot : invisible et hors tabulation ; doit rester vide. */} | |
| 224 | + <div className="absolute -left-[9999px] h-0 w-0 overflow-hidden" aria-hidden="true"> | |
| 225 | + <label htmlFor={`${id}-website`}>Site web</label> | |
| 226 | + <input | |
| 227 | + id={`${id}-website`} | |
| 228 | + type="text" | |
| 229 | + name="website" | |
| 230 | + tabIndex={-1} | |
| 231 | + autoComplete="off" | |
| 232 | + defaultValue="" | |
| 233 | + /> | |
| 234 | + </div> | |
| 235 | + | |
| 236 | + <div className="flex items-start gap-3"> | |
| 237 | + <input | |
| 238 | + id={`${id}-consent`} | |
| 239 | + type="checkbox" | |
| 240 | + className="mt-1 h-4 w-4 accent-slate-500" | |
| 241 | + {...register("consent")} | |
| 242 | + {...field("consent")} | |
| 243 | + /> | |
| 244 | + <label htmlFor={`${id}-consent`} className="text-ink-700 text-sm"> | |
| 245 | + {consentLabel} <span aria-hidden="true">*</span> | |
| 246 | + </label> | |
| 247 | + </div> | |
| 248 | + {err("consent")} | |
| 249 | + | |
| 250 | + {status.kind === "error" ? ( | |
| 251 | + <p | |
| 252 | + id={`${id}-status`} | |
| 253 | + role="alert" | |
| 254 | + className="border-danger/40 bg-danger/5 text-danger rounded-md border px-4 py-3 text-sm" | |
| 255 | + > | |
| 256 | + {status.message} | |
| 257 | + </p> | |
| 258 | + ) : null} | |
| 259 | + | |
| 260 | + <div> | |
| 261 | + <Button | |
| 262 | + type="submit" | |
| 263 | + size="lg" | |
| 264 | + disabled={isSubmitting || status.kind === "submitting"} | |
| 265 | + aria-busy={status.kind === "submitting"} | |
| 266 | + > | |
| 267 | + {status.kind === "submitting" ? "Envoi en cours…" : "Envoyer le message"} | |
| 268 | + </Button> | |
| 269 | + <p className="text-ink-500 mt-3 text-xs"> | |
| 270 | + <span aria-hidden="true">* </span>Champs obligatoires. | |
| 271 | + </p> | |
| 272 | + </div> | |
| 273 | + </form> | |
| 274 | + ); | |
| 275 | +} | |
added
src/components/graphics/BotanicalMotif.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +/** Feuille stylisée au trait, pour le bandeau final. */ | |
| 2 | +export function BotanicalMotif({ className = "" }: { className?: string }) { | |
| 3 | + const leaf = (rot: number, len: number, w: number, y: number) => ( | |
| 4 | + <ellipse | |
| 5 | + key={`${rot}-${y}`} | |
| 6 | + cx={0} | |
| 7 | + cy={-len / 2} | |
| 8 | + rx={w} | |
| 9 | + ry={len / 2} | |
| 10 | + transform={`translate(0 ${y}) rotate(${rot})`} | |
| 11 | + /> | |
| 12 | + ); | |
| 13 | + return ( | |
| 14 | + <svg | |
| 15 | + className={className} | |
| 16 | + viewBox="-80 -10 160 220" | |
| 17 | + fill="var(--color-ivory-200)" | |
| 18 | + fillOpacity="0.55" | |
| 19 | + stroke="var(--color-ivory-200)" | |
| 20 | + strokeWidth="1.5" | |
| 21 | + aria-hidden="true" | |
| 22 | + focusable="false" | |
| 23 | + > | |
| 24 | + <path d="M0 200 C -8 150, -4 90, 0 10" fill="none" /> | |
| 25 | + {leaf(-40, 60, 14, 160)} | |
| 26 | + {leaf(42, 56, 13, 140)} | |
| 27 | + {leaf(-44, 54, 12, 115)} | |
| 28 | + {leaf(40, 50, 11, 92)} | |
| 29 | + {leaf(-46, 46, 10, 70)} | |
| 30 | + {leaf(38, 40, 9, 50)} | |
| 31 | + {leaf(-10, 34, 8, 32)} | |
| 32 | + </svg> | |
| 33 | + ); | |
| 34 | +} | |
added
src/components/graphics/GoldenSpiral.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +import type { SVGProps } from "react"; | |
| 2 | +import { buildSpiral } from "@/lib/spiral"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Spirale de Fibonacci construite par programme : carrés de côtés | |
| 6 | + * 1, 1, 2, 3, 5, 8, 13, 21 (…) disposés en rectangle, et quart de cercle | |
| 7 | + * inscrit dans chaque carré. L'approximation du nombre d'or est celle de la | |
| 8 | + * suite de Fibonacci (21/13 ≈ 1,615) — ce n'est pas une spirale | |
| 9 | + * logarithmique exacte. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +type Props = SVGProps<SVGSVGElement> & { | |
| 13 | + levels?: number; | |
| 14 | + showSquares?: boolean; | |
| 15 | + strokeWidth?: number; | |
| 16 | + /** miroir horizontal / vertical pour orienter le motif */ | |
| 17 | + flipX?: boolean; | |
| 18 | + flipY?: boolean; | |
| 19 | + title?: string; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export function GoldenSpiral({ | |
| 23 | + levels = 8, | |
| 24 | + showSquares = true, | |
| 25 | + strokeWidth = 0.12, | |
| 26 | + flipX = false, | |
| 27 | + flipY = false, | |
| 28 | + title, | |
| 29 | + ...rest | |
| 30 | +}: Props) { | |
| 31 | + const { squares, path, bbox } = buildSpiral(levels); | |
| 32 | + const pad = strokeWidth; | |
| 33 | + const transform = `translate(${flipX ? bbox.x * 2 + bbox.w : 0} ${ | |
| 34 | + flipY ? bbox.y * 2 + bbox.h : 0 | |
| 35 | + }) scale(${flipX ? -1 : 1} ${flipY ? -1 : 1})`; | |
| 36 | + return ( | |
| 37 | + <svg | |
| 38 | + viewBox={`${bbox.x - pad} ${bbox.y - pad} ${bbox.w + pad * 2} ${bbox.h + pad * 2}`} | |
| 39 | + fill="none" | |
| 40 | + stroke="currentColor" | |
| 41 | + strokeWidth={strokeWidth} | |
| 42 | + strokeLinecap="round" | |
| 43 | + aria-hidden={title ? undefined : true} | |
| 44 | + role={title ? "img" : undefined} | |
| 45 | + {...rest} | |
| 46 | + > | |
| 47 | + {title ? <title>{title}</title> : null} | |
| 48 | + <g transform={transform}> | |
| 49 | + {showSquares | |
| 50 | + ? squares.map((sq, i) => ( | |
| 51 | + <rect | |
| 52 | + key={i} | |
| 53 | + x={sq.x} | |
| 54 | + y={sq.y} | |
| 55 | + width={sq.s} | |
| 56 | + height={sq.s} | |
| 57 | + strokeWidth={strokeWidth * 0.75} | |
| 58 | + opacity={0.8} | |
| 59 | + /> | |
| 60 | + )) | |
| 61 | + : null} | |
| 62 | + <path d={path} /> | |
| 63 | + </g> | |
| 64 | + </svg> | |
| 65 | + ); | |
| 66 | +} | |
added
src/components/graphics/Logo.tsx
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import { GoldenSpiral } from "./GoldenSpiral"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Logo recréé en SVG d'après la capture (rectangle d'or + spirale dorée). | |
| 5 | + * À remplacer par le fichier vectoriel officiel du client s'il existe ; | |
| 6 | + * validation du client requise avant usage définitif. | |
| 7 | + */ | |
| 8 | +export function LogoMark({ className }: { className?: string }) { | |
| 9 | + return ( | |
| 10 | + <GoldenSpiral className={className} levels={8} strokeWidth={0.16} title="Logo PHI Conseil" /> | |
| 11 | + ); | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function LogoLockup({ | |
| 15 | + compact = false, | |
| 16 | + tagline, | |
| 17 | + name, | |
| 18 | +}: { | |
| 19 | + compact?: boolean; | |
| 20 | + name: string; | |
| 21 | + tagline: string; | |
| 22 | +}) { | |
| 23 | + return ( | |
| 24 | + <span className="flex min-w-0 items-center gap-2.5 md:gap-3"> | |
| 25 | + <LogoMark | |
| 26 | + className={ | |
| 27 | + compact ? "text-gold-500 h-8 w-auto" : "text-gold-500 h-9 w-auto shrink-0 sm:h-11 md:h-14" | |
| 28 | + } | |
| 29 | + /> | |
| 30 | + <span className="flex flex-col leading-none"> | |
| 31 | + <span | |
| 32 | + className={`text-navy-900 font-serif ${ | |
| 33 | + compact ? "text-[1.35rem]" : "text-[1.35rem] sm:text-[1.6rem] md:text-[2rem]" | |
| 34 | + }`} | |
| 35 | + > | |
| 36 | + {name} | |
| 37 | + </span> | |
| 38 | + {!compact ? ( | |
| 39 | + <span className="text-ink-700 mt-1.5 hidden text-[0.55rem] font-semibold tracking-[0.16em] uppercase sm:block md:text-[0.62rem]"> | |
| 40 | + {tagline} | |
| 41 | + </span> | |
| 42 | + ) : null} | |
| 43 | + </span> | |
| 44 | + </span> | |
| 45 | + ); | |
| 46 | +} | |
added
src/components/graphics/Placeholders.tsx
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +/** | |
| 2 | + * Illustrations vectorielles de remplacement, dans la palette du site. | |
| 3 | + * Elles occupent exactement la place des photographies de la capture | |
| 4 | + * (bureau avec plante, tasse et ordinateur ; vase et livres) en attendant les | |
| 5 | + * photos sous licence du client. Voir docs/ASSET_REGISTER.md. | |
| 6 | + */ | |
| 7 | + | |
| 8 | +export function DeskScene({ className = "" }: { className?: string }) { | |
| 9 | + return ( | |
| 10 | + <svg | |
| 11 | + className={className} | |
| 12 | + viewBox="0 0 640 440" | |
| 13 | + role="img" | |
| 14 | + aria-labelledby="desk-title" | |
| 15 | + preserveAspectRatio="xMidYMid slice" | |
| 16 | + > | |
| 17 | + <title id="desk-title"> | |
| 18 | + Illustration : bureau lumineux avec une plante, une tasse et un ordinateur portable | |
| 19 | + </title> | |
| 20 | + <defs> | |
| 21 | + <linearGradient id="desk-light" x1="0" y1="0" x2="1" y2="1"> | |
| 22 | + <stop offset="0" stopColor="#fbf6ed" /> | |
| 23 | + <stop offset="0.55" stopColor="#f3e9da" /> | |
| 24 | + <stop offset="1" stopColor="#e5dccb" /> | |
| 25 | + </linearGradient> | |
| 26 | + <linearGradient id="desk-wood" x1="0" y1="0" x2="1" y2="0"> | |
| 27 | + <stop offset="0" stopColor="#e4cfae" /> | |
| 28 | + <stop offset="1" stopColor="#cdb389" /> | |
| 29 | + </linearGradient> | |
| 30 | + <linearGradient id="desk-screen" x1="0" y1="0" x2="0" y2="1"> | |
| 31 | + <stop offset="0" stopColor="#f6f3ec" /> | |
| 32 | + <stop offset="1" stopColor="#dfe6ec" /> | |
| 33 | + </linearGradient> | |
| 34 | + <radialGradient id="desk-glow" cx="0.6" cy="0.3" r="0.6"> | |
| 35 | + <stop offset="0" stopColor="#ffffff" stopOpacity="0.9" /> | |
| 36 | + <stop offset="1" stopColor="#ffffff" stopOpacity="0" /> | |
| 37 | + </radialGradient> | |
| 38 | + </defs> | |
| 39 | + <rect width="640" height="440" fill="url(#desk-light)" /> | |
| 40 | + {/* fenêtre / lumière */} | |
| 41 | + <rect x="360" y="20" width="250" height="260" rx="6" fill="#f7f0e4" /> | |
| 42 | + <rect x="372" y="32" width="110" height="236" fill="#fbf8f1" /> | |
| 43 | + <rect x="494" y="32" width="104" height="236" fill="#f9f4ea" /> | |
| 44 | + <rect width="640" height="440" fill="url(#desk-glow)" /> | |
| 45 | + {/* plan de travail */} | |
| 46 | + <path d="M0 300 L640 282 L640 440 L0 440 Z" fill="url(#desk-wood)" /> | |
| 47 | + <path d="M0 300 L640 282 L640 292 L0 310 Z" fill="#f0e0c5" opacity="0.7" /> | |
| 48 | + {/* ordinateur portable */} | |
| 49 | + <g transform="translate(372 170)"> | |
| 50 | + <rect x="0" y="0" width="240" height="140" rx="8" fill="#9aa5ae" /> | |
| 51 | + <rect x="8" y="8" width="224" height="124" rx="4" fill="url(#desk-screen)" /> | |
| 52 | + <path | |
| 53 | + d="M-18 140 L258 140 L278 160 Q280 166 272 166 L-32 166 Q-40 166 -38 160 Z" | |
| 54 | + fill="#aab4bc" | |
| 55 | + /> | |
| 56 | + <rect x="90" y="150" width="60" height="5" rx="2" fill="#8c979f" /> | |
| 57 | + </g> | |
| 58 | + {/* tasse */} | |
| 59 | + <g transform="translate(260 240)"> | |
| 60 | + <ellipse cx="38" cy="86" rx="46" ry="8" fill="#b9a88a" opacity="0.35" /> | |
| 61 | + <path d="M0 20 Q0 86 38 86 Q76 86 76 20 Z" fill="#fbfaf6" /> | |
| 62 | + <ellipse cx="38" cy="20" rx="38" ry="9" fill="#efe9dd" /> | |
| 63 | + <ellipse cx="38" cy="20" rx="30" ry="6" fill="#d9c8ad" /> | |
| 64 | + <path | |
| 65 | + d="M76 36 Q104 36 102 58 Q100 78 74 76" | |
| 66 | + fill="none" | |
| 67 | + stroke="#fbfaf6" | |
| 68 | + strokeWidth="9" | |
| 69 | + /> | |
| 70 | + </g> | |
| 71 | + {/* plante */} | |
| 72 | + <g transform="translate(150 150)"> | |
| 73 | + <ellipse cx="40" cy="170" rx="60" ry="9" fill="#b9a88a" opacity="0.3" /> | |
| 74 | + <path d="M-10 110 L90 110 L80 170 L0 170 Z" fill="#f4eee3" /> | |
| 75 | + <path d="M-10 110 L90 110 L87 124 L-7 124 Z" fill="#e6dccb" /> | |
| 76 | + <g fill="#6f8f6a"> | |
| 77 | + <path d="M40 112 C 20 90, 10 60, 40 40 C 70 60, 60 90, 40 112 Z" /> | |
| 78 | + <path d="M42 110 C 70 100, 95 80, 110 45 C 80 50, 55 75, 42 110 Z" fill="#7d9c74" /> | |
| 79 | + <path d="M38 110 C 10 100, -20 85, -30 50 C 0 55, 25 78, 38 110 Z" fill="#86a37c" /> | |
| 80 | + <path d="M44 112 C 60 118, 95 120, 125 105 C 100 95, 65 100, 44 112 Z" fill="#6a8a66" /> | |
| 81 | + <path d="M36 112 C 20 118, -15 122, -45 110 C -20 98, 15 100, 36 112 Z" fill="#92ad88" /> | |
| 82 | + <path | |
| 83 | + d="M42 100 C 55 70, 75 45, 95 20 C 70 30, 50 60, 42 100 Z" | |
| 84 | + fill="#9db694" | |
| 85 | + opacity="0.9" | |
| 86 | + /> | |
| 87 | + </g> | |
| 88 | + </g> | |
| 89 | + </svg> | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export function VaseScene({ className = "" }: { className?: string }) { | |
| 94 | + return ( | |
| 95 | + <svg | |
| 96 | + className={className} | |
| 97 | + viewBox="0 0 440 560" | |
| 98 | + role="img" | |
| 99 | + aria-labelledby="vase-title" | |
| 100 | + preserveAspectRatio="xMidYMid slice" | |
| 101 | + > | |
| 102 | + <title id="vase-title"> | |
| 103 | + Illustration : vase transparent avec feuillage posé sur des livres, lumière douce | |
| 104 | + </title> | |
| 105 | + <defs> | |
| 106 | + <linearGradient id="vase-bg" x1="0" y1="0" x2="1" y2="1"> | |
| 107 | + <stop offset="0" stopColor="#f6efe4" /> | |
| 108 | + <stop offset="1" stopColor="#e9dfd0" /> | |
| 109 | + </linearGradient> | |
| 110 | + <linearGradient id="vase-glass" x1="0" y1="0" x2="1" y2="0"> | |
| 111 | + <stop offset="0" stopColor="#ffffff" stopOpacity="0.75" /> | |
| 112 | + <stop offset="0.5" stopColor="#dfe8ee" stopOpacity="0.55" /> | |
| 113 | + <stop offset="1" stopColor="#ffffff" stopOpacity="0.8" /> | |
| 114 | + </linearGradient> | |
| 115 | + </defs> | |
| 116 | + <rect width="440" height="560" fill="url(#vase-bg)" /> | |
| 117 | + <rect x="0" y="0" width="440" height="560" fill="#ffffff" opacity="0.18" /> | |
| 118 | + {/* table */} | |
| 119 | + <path d="M0 470 L440 450 L440 560 L0 560 Z" fill="#dcc6a4" /> | |
| 120 | + <path d="M0 470 L440 450 L440 460 L0 480 Z" fill="#efe2cc" opacity="0.7" /> | |
| 121 | + {/* livres */} | |
| 122 | + <g transform="translate(20 380)"> | |
| 123 | + <rect x="0" y="60" width="300" height="34" rx="3" fill="#5f7a94" /> | |
| 124 | + <rect x="0" y="60" width="300" height="6" rx="2" fill="#7b94ab" /> | |
| 125 | + <rect x="14" y="26" width="280" height="34" rx="3" fill="#a9b8c6" /> | |
| 126 | + <rect x="14" y="26" width="280" height="6" rx="2" fill="#c5d1dc" /> | |
| 127 | + <rect x="6" y="-8" width="290" height="34" rx="3" fill="#e8dfd0" /> | |
| 128 | + <rect x="6" y="-8" width="290" height="6" rx="2" fill="#f5efe4" /> | |
| 129 | + </g> | |
| 130 | + {/* vase */} | |
| 131 | + <g transform="translate(140 200)"> | |
| 132 | + <path | |
| 133 | + d="M30 0 L90 0 L100 60 Q110 150 60 172 Q10 150 20 60 Z" | |
| 134 | + fill="url(#vase-glass)" | |
| 135 | + stroke="#ffffff" | |
| 136 | + strokeWidth="2" | |
| 137 | + /> | |
| 138 | + <path d="M24 90 Q20 150 60 168 Q100 150 96 90 Z" fill="#dbe6ec" opacity="0.55" /> | |
| 139 | + </g> | |
| 140 | + {/* tiges et feuilles */} | |
| 141 | + <g fill="#7f9a76" stroke="#5e7858" strokeWidth="1"> | |
| 142 | + <path d="M200 200 C 190 150, 170 110, 150 60" fill="none" /> | |
| 143 | + <path d="M205 200 C 215 150, 240 110, 270 70" fill="none" /> | |
| 144 | + <path d="M198 200 C 180 160, 150 140, 110 120" fill="none" /> | |
| 145 | + {[ | |
| 146 | + [150, 60, -30], | |
| 147 | + [170, 110, -20], | |
| 148 | + [120, 95, -60], | |
| 149 | + [270, 70, 25], | |
| 150 | + [240, 110, 35], | |
| 151 | + [285, 115, 60], | |
| 152 | + [110, 120, -70], | |
| 153 | + [140, 150, -35], | |
| 154 | + [225, 150, 45], | |
| 155 | + ].map(([x, y, r], i) => ( | |
| 156 | + <ellipse | |
| 157 | + key={i} | |
| 158 | + cx={0} | |
| 159 | + cy={-22} | |
| 160 | + rx={11} | |
| 161 | + ry={24} | |
| 162 | + transform={`translate(${x} ${y}) rotate(${r})`} | |
| 163 | + /> | |
| 164 | + ))} | |
| 165 | + </g> | |
| 166 | + </svg> | |
| 167 | + ); | |
| 168 | +} | |
added
src/components/graphics/WaveDivider.tsx
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +/** | |
| 2 | + * Transition décorative en deux vagues superposées (ivoire puis bleu pâle), | |
| 3 | + * comme au bas du hero de la capture. | |
| 4 | + */ | |
| 5 | +export function WaveDivider({ className = "" }: { className?: string }) { | |
| 6 | + return ( | |
| 7 | + <svg | |
| 8 | + className={className} | |
| 9 | + viewBox="0 0 1440 160" | |
| 10 | + preserveAspectRatio="none" | |
| 11 | + aria-hidden="true" | |
| 12 | + focusable="false" | |
| 13 | + > | |
| 14 | + <path | |
| 15 | + d="M0 70 C 180 20, 300 20, 480 60 S 820 150, 1040 110 S 1340 60, 1440 40 L1440 160 L0 160 Z" | |
| 16 | + fill="var(--color-ivory-200)" | |
| 17 | + /> | |
| 18 | + <path | |
| 19 | + d="M0 110 C 160 70, 340 60, 520 90 S 880 160, 1100 125 S 1360 70, 1440 90 L1440 160 L0 160 Z" | |
| 20 | + fill="var(--color-pale-300)" | |
| 21 | + /> | |
| 22 | + <path | |
| 23 | + d="M0 150 C 240 120, 480 115, 720 135 S 1200 160, 1440 145 L1440 160 L0 160 Z" | |
| 24 | + fill="var(--color-ivory-50)" | |
| 25 | + /> | |
| 26 | + </svg> | |
| 27 | + ); | |
| 28 | +} | |
added
src/components/layout/Footer.tsx
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { site } from "@/lib/content"; | |
| 3 | +import { LogoMark } from "@/components/graphics/Logo"; | |
| 4 | +import { LinkedInIcon, MailIcon } from "@/components/ui/Icons"; | |
| 5 | +import { currentYear } from "@/lib/time"; | |
| 6 | + | |
| 7 | +export async function Footer() { | |
| 8 | + const year = await currentYear(); | |
| 9 | + const hasLinkedIn = site.social.linkedin !== ""; | |
| 10 | + const hasEmail = site.contact.email !== ""; | |
| 11 | + return ( | |
| 12 | + <footer className="bg-ivory-50 text-ink-700"> | |
| 13 | + <div className="container-site py-8 md:py-10"> | |
| 14 | + <div className="flex flex-col gap-8 md:flex-row md:items-center md:justify-between"> | |
| 15 | + <Link | |
| 16 | + href="/" | |
| 17 | + className="flex items-center gap-3 rounded-md" | |
| 18 | + aria-label={`${site.brand.name} — Accueil`} | |
| 19 | + > | |
| 20 | + <LogoMark className="text-gold-500 h-9 w-auto" /> | |
| 21 | + <span className="text-navy-900 font-serif text-2xl">{site.brand.name}</span> | |
| 22 | + </Link> | |
| 23 | + | |
| 24 | + <nav aria-label="Navigation de pied de page"> | |
| 25 | + <ul className="flex flex-wrap items-center gap-x-0 gap-y-2 text-sm"> | |
| 26 | + {site.navigation.map((item, i) => ( | |
| 27 | + <li key={item.href} className="flex items-center"> | |
| 28 | + {i > 0 ? ( | |
| 29 | + <span aria-hidden="true" className="bg-ink-300/70 mx-4 h-4 w-px" /> | |
| 30 | + ) : null} | |
| 31 | + <Link | |
| 32 | + href={item.href} | |
| 33 | + className="text-ink-700 hover:text-navy-900 rounded-sm underline-offset-4 hover:underline" | |
| 34 | + > | |
| 35 | + {item.label} | |
| 36 | + </Link> | |
| 37 | + </li> | |
| 38 | + ))} | |
| 39 | + </ul> | |
| 40 | + </nav> | |
| 41 | + | |
| 42 | + {hasLinkedIn || hasEmail ? ( | |
| 43 | + <ul className="flex items-center gap-4" aria-label="Réseaux et contact"> | |
| 44 | + {hasLinkedIn ? ( | |
| 45 | + <li> | |
| 46 | + <a | |
| 47 | + href={site.social.linkedin} | |
| 48 | + target="_blank" | |
| 49 | + rel="noopener noreferrer" | |
| 50 | + aria-label="LinkedIn (nouvelle fenêtre)" | |
| 51 | + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-10 w-10 items-center justify-center rounded-md" | |
| 52 | + > | |
| 53 | + <LinkedInIcon /> | |
| 54 | + </a> | |
| 55 | + </li> | |
| 56 | + ) : null} | |
| 57 | + {hasEmail ? ( | |
| 58 | + <li> | |
| 59 | + <a | |
| 60 | + href={`mailto:${site.contact.email}`} | |
| 61 | + aria-label="Nous écrire par courriel" | |
| 62 | + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-10 w-10 items-center justify-center rounded-md" | |
| 63 | + > | |
| 64 | + <MailIcon /> | |
| 65 | + </a> | |
| 66 | + </li> | |
| 67 | + ) : null} | |
| 68 | + </ul> | |
| 69 | + ) : ( | |
| 70 | + <Link | |
| 71 | + href="/nous-joindre" | |
| 72 | + aria-label="Nous joindre" | |
| 73 | + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-10 w-10 items-center justify-center rounded-md" | |
| 74 | + > | |
| 75 | + <MailIcon /> | |
| 76 | + </Link> | |
| 77 | + )} | |
| 78 | + </div> | |
| 79 | + | |
| 80 | + <div className="border-ink-300/30 text-ink-500 mt-8 flex flex-col gap-3 border-t pt-5 text-xs md:flex-row md:items-center md:justify-between"> | |
| 81 | + <p className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 82 | + <span> | |
| 83 | + © {year} {site.brand.legalName}. Tous droits réservés. | |
| 84 | + </span> | |
| 85 | + {site.legalLinks.map((l) => ( | |
| 86 | + <Link | |
| 87 | + key={l.href} | |
| 88 | + href={l.href} | |
| 89 | + className="hover:text-navy-900 rounded-sm underline-offset-4 hover:underline" | |
| 90 | + > | |
| 91 | + {l.label} | |
| 92 | + </Link> | |
| 93 | + ))} | |
| 94 | + </p> | |
| 95 | + <p className="text-ink-700 font-semibold tracking-[0.14em] uppercase"> | |
| 96 | + {site.brand.footerSignature} | |
| 97 | + </p> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + </footer> | |
| 101 | + ); | |
| 102 | +} | |
added
src/components/layout/Header.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { site } from "@/lib/content"; | |
| 3 | +import { LogoLockup } from "@/components/graphics/Logo"; | |
| 4 | +import { Button } from "@/components/ui/Button"; | |
| 5 | +import { MobileNav } from "./MobileNav"; | |
| 6 | +import { NavLinks } from "./NavLinks"; | |
| 7 | + | |
| 8 | +export function Header() { | |
| 9 | + return ( | |
| 10 | + <header className="bg-ivory-100/90 supports-[backdrop-filter]:bg-ivory-100/80 sticky top-0 z-40 backdrop-blur-sm"> | |
| 11 | + <div className="container-site flex items-center justify-between gap-5 py-3 md:py-4"> | |
| 12 | + <Link href="/" className="min-w-0 rounded-md" aria-label={`${site.brand.name} — Accueil`}> | |
| 13 | + <LogoLockup name={site.brand.name} tagline={site.brand.tagline} /> | |
| 14 | + </Link> | |
| 15 | + | |
| 16 | + <nav aria-label="Navigation principale" className="hidden lg:block"> | |
| 17 | + <NavLinks items={site.navigation} className="flex items-center gap-5 xl:gap-7" /> | |
| 18 | + </nav> | |
| 19 | + | |
| 20 | + <div className="hidden lg:block"> | |
| 21 | + <Button href={site.headerCta.href} size="md" className="whitespace-nowrap"> | |
| 22 | + {site.headerCta.label} | |
| 23 | + </Button> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <MobileNav items={site.navigation} cta={site.headerCta} brandName={site.brand.name} /> | |
| 27 | + </div> | |
| 28 | + </header> | |
| 29 | + ); | |
| 30 | +} | |
added
src/components/layout/MobileNav.tsx
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useId, useRef, useState } from "react"; | |
| 4 | +import { Button } from "@/components/ui/Button"; | |
| 5 | +import { NavLinks } from "./NavLinks"; | |
| 6 | + | |
| 7 | +type Item = { label: string; href: string }; | |
| 8 | + | |
| 9 | +export function MobileNav({ | |
| 10 | + items, | |
| 11 | + cta, | |
| 12 | + brandName, | |
| 13 | +}: { | |
| 14 | + items: Item[]; | |
| 15 | + cta: Item; | |
| 16 | + brandName: string; | |
| 17 | +}) { | |
| 18 | + const [open, setOpen] = useState(false); | |
| 19 | + const panelId = useId(); | |
| 20 | + const closeRef = useRef<HTMLButtonElement>(null); | |
| 21 | + const openerRef = useRef<HTMLButtonElement>(null); | |
| 22 | + | |
| 23 | + useEffect(() => { | |
| 24 | + if (!open) return; | |
| 25 | + const prev = document.body.style.overflow; | |
| 26 | + const opener = openerRef.current; | |
| 27 | + document.body.style.overflow = "hidden"; | |
| 28 | + closeRef.current?.focus(); | |
| 29 | + const onKey = (e: KeyboardEvent) => { | |
| 30 | + if (e.key === "Escape") setOpen(false); | |
| 31 | + }; | |
| 32 | + document.addEventListener("keydown", onKey); | |
| 33 | + return () => { | |
| 34 | + document.body.style.overflow = prev; | |
| 35 | + document.removeEventListener("keydown", onKey); | |
| 36 | + opener?.focus(); | |
| 37 | + }; | |
| 38 | + }, [open]); | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <div className="lg:hidden"> | |
| 42 | + <button | |
| 43 | + ref={openerRef} | |
| 44 | + type="button" | |
| 45 | + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-11 w-11 items-center justify-center rounded-md" | |
| 46 | + aria-expanded={open} | |
| 47 | + aria-controls={panelId} | |
| 48 | + aria-label="Ouvrir le menu" | |
| 49 | + onClick={() => setOpen(true)} | |
| 50 | + > | |
| 51 | + <svg | |
| 52 | + viewBox="0 0 24 24" | |
| 53 | + className="h-6 w-6" | |
| 54 | + fill="none" | |
| 55 | + stroke="currentColor" | |
| 56 | + strokeWidth="1.6" | |
| 57 | + strokeLinecap="round" | |
| 58 | + aria-hidden="true" | |
| 59 | + > | |
| 60 | + <path d="M4 7h16M4 12h16M4 17h16" /> | |
| 61 | + </svg> | |
| 62 | + </button> | |
| 63 | + | |
| 64 | + {open ? ( | |
| 65 | + <div | |
| 66 | + id={panelId} | |
| 67 | + role="dialog" | |
| 68 | + aria-modal="true" | |
| 69 | + aria-label={`Menu ${brandName}`} | |
| 70 | + className="bg-ivory-100 fixed inset-0 z-50 flex flex-col motion-safe:animate-[fade-in_var(--duration-base)_var(--ease-out)]" | |
| 71 | + > | |
| 72 | + <div className="container-site flex items-center justify-between py-4"> | |
| 73 | + <span className="text-navy-900 font-serif text-xl">{brandName}</span> | |
| 74 | + <button | |
| 75 | + ref={closeRef} | |
| 76 | + type="button" | |
| 77 | + className="text-navy-900 hover:bg-navy-900/5 inline-flex h-11 w-11 items-center justify-center rounded-md" | |
| 78 | + aria-label="Fermer le menu" | |
| 79 | + onClick={() => setOpen(false)} | |
| 80 | + > | |
| 81 | + <svg | |
| 82 | + viewBox="0 0 24 24" | |
| 83 | + className="h-6 w-6" | |
| 84 | + fill="none" | |
| 85 | + stroke="currentColor" | |
| 86 | + strokeWidth="1.6" | |
| 87 | + strokeLinecap="round" | |
| 88 | + aria-hidden="true" | |
| 89 | + > | |
| 90 | + <path d="m6 6 12 12M18 6 6 18" /> | |
| 91 | + </svg> | |
| 92 | + </button> | |
| 93 | + </div> | |
| 94 | + <nav | |
| 95 | + aria-label="Navigation mobile" | |
| 96 | + className="container-site flex-1 overflow-y-auto py-6" | |
| 97 | + > | |
| 98 | + <NavLinks | |
| 99 | + items={items} | |
| 100 | + vertical | |
| 101 | + className="flex flex-col gap-1" | |
| 102 | + onNavigate={() => setOpen(false)} | |
| 103 | + /> | |
| 104 | + </nav> | |
| 105 | + <div className="container-site pb-10"> | |
| 106 | + <Button href={cta.href} size="lg" className="w-full" onClick={() => setOpen(false)}> | |
| 107 | + {cta.label} | |
| 108 | + </Button> | |
| 109 | + </div> | |
| 110 | + </div> | |
| 111 | + ) : null} | |
| 112 | + </div> | |
| 113 | + ); | |
| 114 | +} | |
added
src/components/layout/NavLinks.tsx
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { usePathname } from "next/navigation"; | |
| 5 | + | |
| 6 | +type Item = { label: string; href: string }; | |
| 7 | + | |
| 8 | +export function NavLinks({ | |
| 9 | + items, | |
| 10 | + className = "", | |
| 11 | + onNavigate, | |
| 12 | + vertical = false, | |
| 13 | +}: { | |
| 14 | + items: Item[]; | |
| 15 | + className?: string; | |
| 16 | + onNavigate?: () => void; | |
| 17 | + vertical?: boolean; | |
| 18 | +}) { | |
| 19 | + const pathname = usePathname(); | |
| 20 | + return ( | |
| 21 | + <ul className={className}> | |
| 22 | + {items.map((item) => { | |
| 23 | + const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); | |
| 24 | + return ( | |
| 25 | + <li key={item.href}> | |
| 26 | + <Link | |
| 27 | + href={item.href} | |
| 28 | + onClick={onNavigate} | |
| 29 | + aria-current={active ? "page" : undefined} | |
| 30 | + className={ | |
| 31 | + vertical | |
| 32 | + ? `text-navy-900 hover:bg-navy-900/5 block rounded-md px-3 py-3 font-serif text-2xl ${ | |
| 33 | + active ? "decoration-gold-500 underline underline-offset-8" : "" | |
| 34 | + }` | |
| 35 | + : `text-ink-700 hover:text-navy-900 after:bg-navy-900 relative block py-1 text-[0.85rem] whitespace-nowrap transition-colors after:absolute after:inset-x-0 after:-bottom-1 after:h-px after:origin-left after:scale-x-0 after:transition-transform after:duration-[var(--duration-base)] hover:after:scale-x-100 ${ | |
| 36 | + active ? "text-navy-900 after:scale-x-100" : "" | |
| 37 | + }` | |
| 38 | + } | |
| 39 | + > | |
| 40 | + {item.label} | |
| 41 | + </Link> | |
| 42 | + </li> | |
| 43 | + ); | |
| 44 | + })} | |
| 45 | + </ul> | |
| 46 | + ); | |
| 47 | +} | |
added
src/components/sections/About.tsx
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +import Image from "next/image"; | |
| 2 | +import { home } from "@/lib/content"; | |
| 3 | +import { Button } from "@/components/ui/Button"; | |
| 4 | +import { CheckBadge } from "@/components/ui/Icons"; | |
| 5 | +import { VaseScene } from "@/components/graphics/Placeholders"; | |
| 6 | + | |
| 7 | +export function About() { | |
| 8 | + const { about } = home; | |
| 9 | + if (!about.visible) return null; | |
| 10 | + return ( | |
| 11 | + <section aria-labelledby="about-title" className="bg-ivory-50 relative"> | |
| 12 | + <div className="grid lg:grid-cols-[minmax(0,0.34fr)_minmax(0,0.66fr)]"> | |
| 13 | + {/* Photo + courbe ivoire */} | |
| 14 | + <div className="relative h-[18rem] overflow-hidden sm:h-[22rem] lg:h-auto lg:min-h-[26rem]"> | |
| 15 | + {about.image.src ? ( | |
| 16 | + <Image | |
| 17 | + src={about.image.src} | |
| 18 | + alt={about.image.alt} | |
| 19 | + fill | |
| 20 | + sizes="(min-width:1024px) 34vw, 100vw" | |
| 21 | + className="object-cover" | |
| 22 | + /> | |
| 23 | + ) : ( | |
| 24 | + <VaseScene className="absolute inset-0 h-full w-full" /> | |
| 25 | + )} | |
| 26 | + <svg | |
| 27 | + className="pointer-events-none absolute inset-y-0 right-0 hidden h-full w-[38%] lg:block" | |
| 28 | + viewBox="0 0 100 300" | |
| 29 | + preserveAspectRatio="none" | |
| 30 | + aria-hidden="true" | |
| 31 | + > | |
| 32 | + <path | |
| 33 | + d="M100 0 L100 300 L55 300 C 10 230, 100 160, 55 80 C 35 45, 60 15, 100 0 Z" | |
| 34 | + fill="var(--color-ivory-50)" | |
| 35 | + /> | |
| 36 | + <path | |
| 37 | + d="M58 300 C 14 232, 103 160, 58 80 C 38 46, 62 16, 100 2" | |
| 38 | + fill="none" | |
| 39 | + stroke="var(--color-ivory-200)" | |
| 40 | + strokeWidth="6" | |
| 41 | + opacity="0.9" | |
| 42 | + /> | |
| 43 | + </svg> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <div className="container-site grid gap-10 py-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.52fr)] lg:gap-6 lg:py-16 lg:pl-6 xl:pl-10"> | |
| 47 | + <div> | |
| 48 | + <p className="overtitle">{about.overtitle}</p> | |
| 49 | + <h2 id="about-title" className="text-h3 mt-3"> | |
| 50 | + {about.title} | |
| 51 | + </h2> | |
| 52 | + <p className="text-ink-700 mt-5 max-w-[32rem]">{about.paragraph}</p> | |
| 53 | + <div className="mt-7"> | |
| 54 | + <Button href={about.cta.href} variant="outline" size="lg"> | |
| 55 | + {about.cta.label} | |
| 56 | + </Button> | |
| 57 | + </div> | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + <ul | |
| 61 | + className="border-gold-500/60 flex flex-col gap-4 lg:border-l lg:py-2 lg:pl-7 xl:pl-10" | |
| 62 | + aria-label="Nos engagements" | |
| 63 | + > | |
| 64 | + {about.benefits.map((b) => ( | |
| 65 | + <li | |
| 66 | + key={b} | |
| 67 | + className="text-ink-900 flex items-start gap-3 text-[0.9rem] leading-snug" | |
| 68 | + > | |
| 69 | + <CheckBadge className="mt-0.5 h-5 w-5 shrink-0" /> | |
| 70 | + <span>{b}</span> | |
| 71 | + </li> | |
| 72 | + ))} | |
| 73 | + </ul> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + </section> | |
| 77 | + ); | |
| 78 | +} | |
added
src/components/sections/CallToAction.tsx
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import { home } from "@/lib/content"; | |
| 2 | +import { Button } from "@/components/ui/Button"; | |
| 3 | +import { GoldenSpiral } from "@/components/graphics/GoldenSpiral"; | |
| 4 | +import { BotanicalMotif } from "@/components/graphics/BotanicalMotif"; | |
| 5 | + | |
| 6 | +export function CallToAction() { | |
| 7 | + const { callToAction: cta } = home; | |
| 8 | + if (!cta.visible) return null; | |
| 9 | + return ( | |
| 10 | + <section | |
| 11 | + aria-labelledby="cta-title" | |
| 12 | + className="relative isolate overflow-hidden bg-slate-600 text-white" | |
| 13 | + > | |
| 14 | + <svg | |
| 15 | + className="pointer-events-none absolute inset-x-0 -top-px h-10 w-full md:h-16" | |
| 16 | + viewBox="0 0 1440 80" | |
| 17 | + preserveAspectRatio="none" | |
| 18 | + aria-hidden="true" | |
| 19 | + > | |
| 20 | + <path | |
| 21 | + d="M0 0 L1440 0 L1440 10 C 1200 70, 900 70, 660 30 C 420 -5, 200 10, 0 50 Z" | |
| 22 | + fill="var(--color-ivory-50)" | |
| 23 | + /> | |
| 24 | + </svg> | |
| 25 | + <GoldenSpiral | |
| 26 | + className="text-gold-400/80 pointer-events-none absolute top-[4%] -right-[6%] h-[120%] w-auto max-lg:opacity-40" | |
| 27 | + strokeWidth={0.1} | |
| 28 | + flipX | |
| 29 | + /> | |
| 30 | + <BotanicalMotif className="pointer-events-none absolute -right-2 bottom-0 h-[92%] w-auto max-lg:opacity-30" /> | |
| 31 | + <div className="container-site relative py-16 md:py-20 lg:py-24"> | |
| 32 | + <div className="max-w-[36rem]"> | |
| 33 | + <h2 id="cta-title" className="text-h2 text-white"> | |
| 34 | + {cta.title} | |
| 35 | + </h2> | |
| 36 | + <p className="mt-3 text-lg text-white">{cta.subtitle}</p> | |
| 37 | + <div className="mt-7"> | |
| 38 | + <Button href={cta.cta.href} variant="ivory" size="lg"> | |
| 39 | + {cta.cta.label} | |
| 40 | + </Button> | |
| 41 | + </div> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + </section> | |
| 45 | + ); | |
| 46 | +} | |
added
src/components/sections/Hero.tsx
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +import Image from "next/image"; | |
| 2 | +import { home } from "@/lib/content"; | |
| 3 | +import { Button } from "@/components/ui/Button"; | |
| 4 | +import { GoldenSpiral } from "@/components/graphics/GoldenSpiral"; | |
| 5 | +import { WaveDivider } from "@/components/graphics/WaveDivider"; | |
| 6 | +import { DeskScene } from "@/components/graphics/Placeholders"; | |
| 7 | + | |
| 8 | +export function Hero() { | |
| 9 | + const { hero } = home; | |
| 10 | + return ( | |
| 11 | + <section aria-labelledby="hero-title" className="bg-ivory-100 relative isolate overflow-hidden"> | |
| 12 | + <div className="container-site relative grid items-center gap-10 pt-8 pb-24 md:pt-10 md:pb-32 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-4 lg:pt-4 lg:pb-32"> | |
| 13 | + <div className="relative z-10 max-w-[36rem] lg:pt-6"> | |
| 14 | + <h1 id="hero-title" className="text-display"> | |
| 15 | + {hero.titleLines.map((line, i) => ( | |
| 16 | + <span key={i} className="block"> | |
| 17 | + {line} | |
| 18 | + </span> | |
| 19 | + ))} | |
| 20 | + </h1> | |
| 21 | + <p className="text-ink-700 mt-6 max-w-[30rem] text-[1.05rem]">{hero.subtitle}</p> | |
| 22 | + <div className="mt-8"> | |
| 23 | + <Button href={hero.cta.href} size="lg"> | |
| 24 | + {hero.cta.label} | |
| 25 | + </Button> | |
| 26 | + </div> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <div className="relative -mx-[var(--container-pad)] lg:mx-0 lg:-mr-[var(--container-pad)]"> | |
| 30 | + <div className="relative aspect-[16/10] w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_12%,black)] lg:aspect-[1.18]"> | |
| 31 | + {hero.image.src ? ( | |
| 32 | + <Image | |
| 33 | + src={hero.image.src} | |
| 34 | + alt={hero.image.alt} | |
| 35 | + fill | |
| 36 | + priority | |
| 37 | + sizes="(min-width:1024px) 50vw, 100vw" | |
| 38 | + className="object-cover" | |
| 39 | + /> | |
| 40 | + ) : ( | |
| 41 | + <DeskScene className="absolute inset-0 h-full w-full" /> | |
| 42 | + )} | |
| 43 | + <GoldenSpiral | |
| 44 | + className="text-gold-500 pointer-events-none absolute -top-[6%] right-[2%] h-[112%] w-auto opacity-90 drop-shadow-[0_0_1px_rgba(255,255,255,0.6)]" | |
| 45 | + strokeWidth={0.1} | |
| 46 | + flipY | |
| 47 | + /> | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + | |
| 52 | + <WaveDivider className="absolute inset-x-0 bottom-0 h-20 w-full md:h-28 lg:h-36" /> | |
| 53 | + </section> | |
| 54 | + ); | |
| 55 | +} | |
added
src/components/sections/Services.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { home } from "@/lib/content"; | |
| 3 | +import { SectionTitle } from "@/components/ui/SectionTitle"; | |
| 4 | +import { ServiceIcon } from "@/components/ui/ServiceIcon"; | |
| 5 | + | |
| 6 | +export function Services() { | |
| 7 | + const { services } = home; | |
| 8 | + if (!services.visible) return null; | |
| 9 | + return ( | |
| 10 | + <section aria-labelledby="services-title" className="section-y bg-ivory-50"> | |
| 11 | + <div className="container-site"> | |
| 12 | + <SectionTitle id="services-title">{services.title}</SectionTitle> | |
| 13 | + <ul className="mt-10 grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 md:mt-14 lg:grid-cols-4"> | |
| 14 | + {services.items.map((s) => ( | |
| 15 | + <li key={s.slug} className="flex flex-col items-center text-center"> | |
| 16 | + <span className="bg-pale-200 text-navy-800 flex h-[4.5rem] w-[4.5rem] items-center justify-center rounded-full"> | |
| 17 | + <ServiceIcon name={s.icon} className="h-8 w-8" /> | |
| 18 | + </span> | |
| 19 | + <h3 className="text-h4 mt-6 max-w-[14rem]"> | |
| 20 | + <Link | |
| 21 | + href={`/services#${s.slug}`} | |
| 22 | + className="decoration-gold-500 rounded-sm underline-offset-4 hover:underline" | |
| 23 | + > | |
| 24 | + {s.title} | |
| 25 | + </Link> | |
| 26 | + </h3> | |
| 27 | + <p className="text-ink-700 mt-3 max-w-[15rem] text-[0.95rem]">{s.summary}</p> | |
| 28 | + </li> | |
| 29 | + ))} | |
| 30 | + </ul> | |
| 31 | + </div> | |
| 32 | + </section> | |
| 33 | + ); | |
| 34 | +} | |
added
src/components/sections/TestimonialSlider.tsx
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useId, useState, useSyncExternalStore } from "react"; | |
| 4 | +import { QuoteMark } from "@/components/ui/Icons"; | |
| 5 | + | |
| 6 | +type Item = { quote: string; author: string; role: string }; | |
| 7 | + | |
| 8 | +const INTERVAL_MS = 8000; | |
| 9 | +const RM_QUERY = "(prefers-reduced-motion: reduce)"; | |
| 10 | +function subscribeReducedMotion(cb: () => void) { | |
| 11 | + const mq = window.matchMedia(RM_QUERY); | |
| 12 | + mq.addEventListener("change", cb); | |
| 13 | + return () => mq.removeEventListener("change", cb); | |
| 14 | +} | |
| 15 | +function getReducedMotion() { | |
| 16 | + return window.matchMedia(RM_QUERY).matches; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function TestimonialSlider({ items }: { items: Item[] }) { | |
| 20 | + const [index, setIndex] = useState(0); | |
| 21 | + const [paused, setPaused] = useState(false); | |
| 22 | + const reduced = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, () => false); | |
| 23 | + const regionId = useId(); | |
| 24 | + | |
| 25 | + const go = useCallback( | |
| 26 | + (n: number) => setIndex((i) => (i + n + items.length) % items.length), | |
| 27 | + [items.length], | |
| 28 | + ); | |
| 29 | + | |
| 30 | + useEffect(() => { | |
| 31 | + if (items.length < 2 || paused || reduced) return; | |
| 32 | + const t = setInterval(() => go(1), INTERVAL_MS); | |
| 33 | + return () => clearInterval(t); | |
| 34 | + }, [go, items.length, paused, reduced]); | |
| 35 | + | |
| 36 | + const current = items[index]; | |
| 37 | + const multiple = items.length > 1; | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <section | |
| 41 | + aria-roledescription="carrousel" | |
| 42 | + aria-label="Témoignages de clients" | |
| 43 | + className="bg-pale-200 relative rounded-[var(--radius-xl)] px-6 py-8 sm:px-10 md:px-14 md:py-10" | |
| 44 | + onMouseEnter={() => setPaused(true)} | |
| 45 | + onMouseLeave={() => setPaused(false)} | |
| 46 | + onFocusCapture={() => setPaused(true)} | |
| 47 | + onBlurCapture={(e) => { | |
| 48 | + if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false); | |
| 49 | + }} | |
| 50 | + onKeyDown={(e) => { | |
| 51 | + if (!multiple) return; | |
| 52 | + if (e.key === "ArrowRight") go(1); | |
| 53 | + if (e.key === "ArrowLeft") go(-1); | |
| 54 | + }} | |
| 55 | + > | |
| 56 | + <div className="flex flex-col gap-6 md:flex-row md:items-start md:gap-8"> | |
| 57 | + <QuoteMark className="text-gold-400 h-9 w-9 shrink-0 md:h-11 md:w-11" /> | |
| 58 | + <div id={regionId} aria-live="polite" aria-atomic="true" className="flex-1"> | |
| 59 | + <blockquote> | |
| 60 | + <p className="text-navy-900 font-serif text-xl leading-relaxed italic md:text-[1.45rem]"> | |
| 61 | + {current.quote} | |
| 62 | + </p> | |
| 63 | + <footer className="text-ink-700 mt-5 text-sm"> | |
| 64 | + <span aria-hidden="true">— </span> | |
| 65 | + <cite className="text-ink-900 font-semibold not-italic">{current.author}</cite> | |
| 66 | + {current.role ? <span>, {current.role}</span> : null} | |
| 67 | + </footer> | |
| 68 | + </blockquote> | |
| 69 | + </div> | |
| 70 | + | |
| 71 | + {multiple ? ( | |
| 72 | + <div | |
| 73 | + className="flex items-center gap-3 self-end md:self-center" | |
| 74 | + role="group" | |
| 75 | + aria-label="Pagination des témoignages" | |
| 76 | + > | |
| 77 | + <button | |
| 78 | + type="button" | |
| 79 | + onClick={() => setPaused((p) => !p)} | |
| 80 | + aria-pressed={paused} | |
| 81 | + className="text-ink-700 rounded-md px-2 py-1 text-xs hover:bg-white/50" | |
| 82 | + > | |
| 83 | + {paused ? "Reprendre" : "Pause"} | |
| 84 | + </button> | |
| 85 | + <ul className="flex items-center gap-2.5"> | |
| 86 | + {items.map((_, i) => ( | |
| 87 | + <li key={i}> | |
| 88 | + <button | |
| 89 | + type="button" | |
| 90 | + aria-label={`Témoignage ${i + 1} sur ${items.length}`} | |
| 91 | + aria-current={i === index ? "true" : undefined} | |
| 92 | + aria-controls={regionId} | |
| 93 | + onClick={() => setIndex(i)} | |
| 94 | + className={`block h-2.5 w-2.5 rounded-full transition-colors ${ | |
| 95 | + i === index ? "bg-navy-900" : "bg-pale-400 hover:bg-slate-500" | |
| 96 | + }`} | |
| 97 | + /> | |
| 98 | + </li> | |
| 99 | + ))} | |
| 100 | + </ul> | |
| 101 | + </div> | |
| 102 | + ) : null} | |
| 103 | + </div> | |
| 104 | + </section> | |
| 105 | + ); | |
| 106 | +} | |
added
src/components/sections/Testimonials.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import { displayableTestimonials } from "@/lib/content"; | |
| 2 | +import { TestimonialSlider } from "./TestimonialSlider"; | |
| 3 | + | |
| 4 | +export function Testimonials() { | |
| 5 | + const preview = process.env.NEXT_PUBLIC_CONTENT_PREVIEW === "1"; | |
| 6 | + const { items, isPreview } = displayableTestimonials(preview); | |
| 7 | + if (items.length === 0) return null; | |
| 8 | + return ( | |
| 9 | + <section aria-labelledby="testimonials-title" className="bg-ivory-50 pt-2 pb-6 md:pb-10"> | |
| 10 | + <div className="container-site"> | |
| 11 | + <h2 id="testimonials-title" className="sr-only"> | |
| 12 | + Témoignages | |
| 13 | + </h2> | |
| 14 | + {isPreview ? ( | |
| 15 | + <p | |
| 16 | + role="status" | |
| 17 | + className="border-gold-500/60 bg-gold-300/30 text-ink-900 mb-3 rounded-md border px-4 py-2 text-sm" | |
| 18 | + > | |
| 19 | + Exemple de maquette (témoignage non vérifié) — sera remplacé par un avis client | |
| 20 | + autorisé. | |
| 21 | + </p> | |
| 22 | + ) : null} | |
| 23 | + <TestimonialSlider items={items} /> | |
| 24 | + </div> | |
| 25 | + </section> | |
| 26 | + ); | |
| 27 | +} | |
added
src/components/ui/Button.tsx
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import type { ComponentPropsWithoutRef, ReactNode } from "react"; | |
| 3 | + | |
| 4 | +type Variant = "primary" | "ivory" | "outline" | "ghost"; | |
| 5 | +type Size = "md" | "lg"; | |
| 6 | + | |
| 7 | +const base = | |
| 8 | + "inline-flex items-center justify-center gap-2 rounded-pill font-medium transition-[background-color,color,box-shadow,transform] duration-[var(--duration-base)] ease-[var(--ease-out)] focus-visible:outline-3 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-60 motion-safe:hover:-translate-y-px"; | |
| 9 | + | |
| 10 | +const variants: Record<Variant, string> = { | |
| 11 | + primary: "bg-slate-500 text-white shadow-sm hover:bg-slate-700 focus-visible:outline-navy-900", | |
| 12 | + ivory: "bg-ivory-200 text-navy-900 hover:bg-ivory-100 focus-visible:outline-white", | |
| 13 | + outline: | |
| 14 | + "border border-navy-800/50 bg-transparent text-navy-900 hover:border-navy-900 hover:bg-white/60", | |
| 15 | + ghost: "text-navy-900 hover:bg-navy-900/5", | |
| 16 | +}; | |
| 17 | + | |
| 18 | +const sizes: Record<Size, string> = { | |
| 19 | + md: "px-5 py-2 text-sm", | |
| 20 | + lg: "px-6 py-3 text-[0.95rem]", | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export function Arrow({ className = "h-4 w-4" }: { className?: string }) { | |
| 24 | + return ( | |
| 25 | + <svg | |
| 26 | + className={className} | |
| 27 | + viewBox="0 0 16 16" | |
| 28 | + fill="none" | |
| 29 | + stroke="currentColor" | |
| 30 | + strokeWidth="1.6" | |
| 31 | + strokeLinecap="round" | |
| 32 | + strokeLinejoin="round" | |
| 33 | + aria-hidden="true" | |
| 34 | + > | |
| 35 | + <path d="M2.5 8h11M9 3.5 13.5 8 9 12.5" /> | |
| 36 | + </svg> | |
| 37 | + ); | |
| 38 | +} | |
| 39 | + | |
| 40 | +type CommonProps = { | |
| 41 | + variant?: Variant; | |
| 42 | + size?: Size; | |
| 43 | + arrow?: boolean; | |
| 44 | + className?: string; | |
| 45 | + children: ReactNode; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +type LinkProps = CommonProps & { href: string } & Omit< | |
| 49 | + ComponentPropsWithoutRef<typeof Link>, | |
| 50 | + "href" | "children" | "className" | |
| 51 | + >; | |
| 52 | +type ButtonProps = CommonProps & { href?: undefined } & Omit< | |
| 53 | + ComponentPropsWithoutRef<"button">, | |
| 54 | + "children" | "className" | |
| 55 | + >; | |
| 56 | + | |
| 57 | +export function Button(props: LinkProps | ButtonProps) { | |
| 58 | + const { | |
| 59 | + variant = "primary", | |
| 60 | + size = "md", | |
| 61 | + arrow = true, | |
| 62 | + className = "", | |
| 63 | + children, | |
| 64 | + ...rest | |
| 65 | + } = props; | |
| 66 | + const cls = `${base} ${variants[variant]} ${sizes[size]} ${className}`; | |
| 67 | + const content = ( | |
| 68 | + <> | |
| 69 | + {children} | |
| 70 | + {arrow ? <Arrow /> : null} | |
| 71 | + </> | |
| 72 | + ); | |
| 73 | + if (rest.href !== undefined) { | |
| 74 | + const { href, ...linkRest } = rest as Omit<LinkProps, keyof CommonProps>; | |
| 75 | + return ( | |
| 76 | + <Link href={href} className={cls} {...linkRest}> | |
| 77 | + {content} | |
| 78 | + </Link> | |
| 79 | + ); | |
| 80 | + } | |
| 81 | + const { href: _unused, ...buttonRest } = rest as Omit<ButtonProps, keyof CommonProps>; | |
| 82 | + void _unused; | |
| 83 | + return ( | |
| 84 | + <button type="button" className={cls} {...buttonRest}> | |
| 85 | + {content} | |
| 86 | + </button> | |
| 87 | + ); | |
| 88 | +} | |
added
src/components/ui/Icons.tsx
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +export function LinkedInIcon({ className = "h-5 w-5" }: { className?: string }) { | |
| 2 | + return ( | |
| 3 | + <svg | |
| 4 | + className={className} | |
| 5 | + viewBox="0 0 24 24" | |
| 6 | + fill="none" | |
| 7 | + stroke="currentColor" | |
| 8 | + strokeWidth="1.5" | |
| 9 | + aria-hidden="true" | |
| 10 | + focusable="false" | |
| 11 | + > | |
| 12 | + <rect x="3" y="3" width="18" height="18" rx="3" /> | |
| 13 | + <path d="M8 10v7M8 7v.5M12 17v-4a2 2 0 0 1 4 0v4M12 10v1" strokeLinecap="round" /> | |
| 14 | + </svg> | |
| 15 | + ); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function MailIcon({ className = "h-5 w-5" }: { className?: string }) { | |
| 19 | + return ( | |
| 20 | + <svg | |
| 21 | + className={className} | |
| 22 | + viewBox="0 0 24 24" | |
| 23 | + fill="none" | |
| 24 | + stroke="currentColor" | |
| 25 | + strokeWidth="1.5" | |
| 26 | + aria-hidden="true" | |
| 27 | + focusable="false" | |
| 28 | + > | |
| 29 | + <rect x="3" y="5" width="18" height="14" rx="2.5" /> | |
| 30 | + <path d="m4 7 8 6 8-6" strokeLinecap="round" strokeLinejoin="round" /> | |
| 31 | + </svg> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function CheckBadge({ className = "h-5 w-5" }: { className?: string }) { | |
| 36 | + return ( | |
| 37 | + <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false"> | |
| 38 | + <circle cx="12" cy="12" r="11" fill="var(--color-gold-400)" /> | |
| 39 | + <path | |
| 40 | + d="m7.5 12.3 3 3 6-6.5" | |
| 41 | + fill="none" | |
| 42 | + stroke="#fff" | |
| 43 | + strokeWidth="2" | |
| 44 | + strokeLinecap="round" | |
| 45 | + strokeLinejoin="round" | |
| 46 | + /> | |
| 47 | + </svg> | |
| 48 | + ); | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function QuoteMark({ className = "h-10 w-10" }: { className?: string }) { | |
| 52 | + return ( | |
| 53 | + <svg | |
| 54 | + className={className} | |
| 55 | + viewBox="0 0 48 40" | |
| 56 | + fill="currentColor" | |
| 57 | + aria-hidden="true" | |
| 58 | + focusable="false" | |
| 59 | + > | |
| 60 | + <path d="M10.5 40C4.7 40 0 35.3 0 29.5c0-2 .5-4 1.4-5.8L12.6 0h10.2L15 17.6c4.3 1.6 7.2 5.7 7.2 10.4C22.2 34.7 17 40 10.5 40Zm25.8 0C30.5 40 25.8 35.3 25.8 29.5c0-2 .5-4 1.4-5.8L38.4 0h10.2L40.8 17.6c4.3 1.6 7.2 5.7 7.2 10.4C48 34.7 42.8 40 36.3 40Z" /> | |
| 61 | + </svg> | |
| 62 | + ); | |
| 63 | +} | |
added
src/components/ui/SectionTitle.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import type { ReactNode } from "react"; | |
| 2 | + | |
| 3 | +/** Titre centré entre deux fins filets dorés (section « Nos services »). */ | |
| 4 | +export function SectionTitle({ | |
| 5 | + children, | |
| 6 | + as: Tag = "h2", | |
| 7 | + id, | |
| 8 | +}: { | |
| 9 | + children: ReactNode; | |
| 10 | + as?: "h1" | "h2"; | |
| 11 | + id?: string; | |
| 12 | +}) { | |
| 13 | + return ( | |
| 14 | + <div className="flex items-center gap-5 md:gap-8"> | |
| 15 | + <span aria-hidden="true" className="bg-gold-500/70 h-px flex-1" /> | |
| 16 | + <Tag id={id} className="text-h2 shrink-0 text-center"> | |
| 17 | + {children} | |
| 18 | + </Tag> | |
| 19 | + <span aria-hidden="true" className="bg-gold-500/70 h-px flex-1" /> | |
| 20 | + </div> | |
| 21 | + ); | |
| 22 | +} | |
added
src/components/ui/ServiceIcon.tsx
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import type { ServiceIcon as IconName } from "@/lib/content"; | |
| 2 | + | |
| 3 | +const paths: Record<IconName, React.ReactNode> = { | |
| 4 | + search: ( | |
| 5 | + <> | |
| 6 | + <circle cx="11" cy="11" r="6.5" /> | |
| 7 | + <path d="m16 16 5 5" /> | |
| 8 | + <path d="M8.5 11a2.5 2.5 0 0 1 2.5-2.5" /> | |
| 9 | + </> | |
| 10 | + ), | |
| 11 | + "document-edit": ( | |
| 12 | + <> | |
| 13 | + <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-9" /> | |
| 14 | + <path d="M14 3v5h5" /> | |
| 15 | + <path d="M8.5 13h4M8.5 16.5h3" /> | |
| 16 | + <path d="m19.5 9.5-6 6-1.5 3 3-1.5 6-6a1.06 1.06 0 0 0-1.5-1.5Z" /> | |
| 17 | + </> | |
| 18 | + ), | |
| 19 | + chart: ( | |
| 20 | + <> | |
| 21 | + <path d="M4 20h16" /> | |
| 22 | + <path d="M6 17v-5M11 17V9M16 17v-3" /> | |
| 23 | + <path d="m5 10 5-4 4 3 6-5" /> | |
| 24 | + <path d="M17 4h3v3" /> | |
| 25 | + </> | |
| 26 | + ), | |
| 27 | + "check-circle": ( | |
| 28 | + <> | |
| 29 | + <circle cx="12" cy="12" r="8.5" /> | |
| 30 | + <path d="m8.5 12.2 2.4 2.4 4.8-5" /> | |
| 31 | + </> | |
| 32 | + ), | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export function ServiceIcon({ | |
| 36 | + name, | |
| 37 | + className = "h-7 w-7", | |
| 38 | +}: { | |
| 39 | + name: IconName; | |
| 40 | + className?: string; | |
| 41 | +}) { | |
| 42 | + return ( | |
| 43 | + <svg | |
| 44 | + className={className} | |
| 45 | + viewBox="0 0 24 24" | |
| 46 | + fill="none" | |
| 47 | + stroke="currentColor" | |
| 48 | + strokeWidth="1.5" | |
| 49 | + strokeLinecap="round" | |
| 50 | + strokeLinejoin="round" | |
| 51 | + aria-hidden="true" | |
| 52 | + focusable="false" | |
| 53 | + > | |
| 54 | + {paths[name]} | |
| 55 | + </svg> | |
| 56 | + ); | |
| 57 | +} | |
added
src/lib/content.ts
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +import { z } from "zod"; | |
| 2 | +import siteJson from "../../content/site.json"; | |
| 3 | +import homeJson from "../../content/home.json"; | |
| 4 | +import pagesJson from "../../content/pages.json"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Unique point d'accès au contenu administrable. | |
| 8 | + * Les fichiers JSON de `content/` sont validés au chargement : une erreur de | |
| 9 | + * saisie (champ manquant, type incorrect) fait échouer le build plutôt que de | |
| 10 | + * publier une page cassée. | |
| 11 | + * | |
| 12 | + * Pour brancher un CMS (Payload, Sanity…), remplacer les imports JSON par des | |
| 13 | + * appels à l'API du CMS en conservant les schémas ci-dessous. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +const link = z.object({ | |
| 17 | + label: z.string().min(1), | |
| 18 | + href: z.string().min(1), | |
| 19 | +}); | |
| 20 | + | |
| 21 | +const image = z.object({ | |
| 22 | + src: z.string(), // vide = illustration vectorielle de remplacement | |
| 23 | + alt: z.string().min(1), | |
| 24 | +}); | |
| 25 | + | |
| 26 | +export const serviceIconSchema = z.enum(["search", "document-edit", "chart", "check-circle"]); | |
| 27 | +export type ServiceIcon = z.infer<typeof serviceIconSchema>; | |
| 28 | + | |
| 29 | +export const siteSchema = z.object({ | |
| 30 | + brand: z.object({ | |
| 31 | + name: z.string().min(1), | |
| 32 | + tagline: z.string().min(1), | |
| 33 | + footerSignature: z.string().min(1), | |
| 34 | + legalName: z.string().min(1), | |
| 35 | + }), | |
| 36 | + navigation: z.array(link).min(1), | |
| 37 | + headerCta: link, | |
| 38 | + contact: z.object({ | |
| 39 | + email: z.union([z.literal(""), z.email()]), | |
| 40 | + phone: z.string(), | |
| 41 | + address: z.string(), | |
| 42 | + region: z.string(), | |
| 43 | + }), | |
| 44 | + social: z.object({ | |
| 45 | + linkedin: z.union([z.literal(""), z.url()]), | |
| 46 | + }), | |
| 47 | + legalLinks: z.array(link), | |
| 48 | + seo: z.object({ | |
| 49 | + siteUrl: z.url(), | |
| 50 | + defaultTitle: z.string().min(1), | |
| 51 | + titleTemplate: z.string().includes("%s"), | |
| 52 | + defaultDescription: z.string().min(1), | |
| 53 | + locale: z.string().min(2), | |
| 54 | + }), | |
| 55 | +}); | |
| 56 | + | |
| 57 | +export const homeSchema = z.object({ | |
| 58 | + hero: z.object({ | |
| 59 | + titleLines: z.array(z.string().min(1)).min(1).max(4), | |
| 60 | + subtitle: z.string().min(1), | |
| 61 | + cta: link, | |
| 62 | + image, | |
| 63 | + }), | |
| 64 | + services: z.object({ | |
| 65 | + visible: z.boolean(), | |
| 66 | + title: z.string().min(1), | |
| 67 | + items: z | |
| 68 | + .array( | |
| 69 | + z.object({ | |
| 70 | + slug: z.string().regex(/^[a-z0-9-]+$/), | |
| 71 | + icon: serviceIconSchema, | |
| 72 | + title: z.string().min(1), | |
| 73 | + summary: z.string().min(1), | |
| 74 | + description: z.string(), | |
| 75 | + }), | |
| 76 | + ) | |
| 77 | + .min(1), | |
| 78 | + }), | |
| 79 | + about: z.object({ | |
| 80 | + visible: z.boolean(), | |
| 81 | + overtitle: z.string().min(1), | |
| 82 | + title: z.string().min(1), | |
| 83 | + paragraph: z.string().min(1), | |
| 84 | + cta: link, | |
| 85 | + benefits: z.array(z.string().min(1)).min(1), | |
| 86 | + image, | |
| 87 | + }), | |
| 88 | + testimonials: z.object({ | |
| 89 | + visible: z.boolean(), | |
| 90 | + previewOnly: z.boolean(), | |
| 91 | + items: z.array( | |
| 92 | + z.object({ | |
| 93 | + quote: z.string().min(1), | |
| 94 | + author: z.string().min(1), | |
| 95 | + role: z.string(), | |
| 96 | + approved: z.boolean(), | |
| 97 | + }), | |
| 98 | + ), | |
| 99 | + }), | |
| 100 | + callToAction: z.object({ | |
| 101 | + visible: z.boolean(), | |
| 102 | + title: z.string().min(1), | |
| 103 | + subtitle: z.string().min(1), | |
| 104 | + cta: link, | |
| 105 | + }), | |
| 106 | +}); | |
| 107 | + | |
| 108 | +export const pagesSchema = z.object({ | |
| 109 | + services: z.object({ | |
| 110 | + title: z.string().min(1), | |
| 111 | + intro: z.string().min(1), | |
| 112 | + seoDescription: z.string().min(1), | |
| 113 | + }), | |
| 114 | + about: z.object({ | |
| 115 | + title: z.string().min(1), | |
| 116 | + intro: z.string().min(1), | |
| 117 | + seoDescription: z.string().min(1), | |
| 118 | + body: z.array(z.string().min(1)), | |
| 119 | + }), | |
| 120 | + testimonials: z.object({ | |
| 121 | + title: z.string().min(1), | |
| 122 | + intro: z.string().min(1), | |
| 123 | + emptyMessage: z.string().min(1), | |
| 124 | + seoDescription: z.string().min(1), | |
| 125 | + }), | |
| 126 | + contact: z.object({ | |
| 127 | + title: z.string().min(1), | |
| 128 | + intro: z.string().min(1), | |
| 129 | + seoDescription: z.string().min(1), | |
| 130 | + subjects: z.array(z.string().min(1)).min(1), | |
| 131 | + consentLabel: z.string().min(1), | |
| 132 | + successTitle: z.string().min(1), | |
| 133 | + successMessage: z.string().min(1), | |
| 134 | + }), | |
| 135 | + privacy: z.object({ | |
| 136 | + title: z.string().min(1), | |
| 137 | + seoDescription: z.string().min(1), | |
| 138 | + lastUpdated: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), | |
| 139 | + reviewed: z.boolean(), | |
| 140 | + }), | |
| 141 | +}); | |
| 142 | + | |
| 143 | +export type SiteContent = z.infer<typeof siteSchema>; | |
| 144 | +export type HomeContent = z.infer<typeof homeSchema>; | |
| 145 | +export type PagesContent = z.infer<typeof pagesSchema>; | |
| 146 | + | |
| 147 | +function parse<T>(schema: z.ZodType<T>, data: unknown, file: string): T { | |
| 148 | + const result = schema.safeParse(data); | |
| 149 | + if (!result.success) { | |
| 150 | + const issues = result.error.issues | |
| 151 | + .map((i) => ` - ${i.path.join(".") || "(racine)"}: ${i.message}`) | |
| 152 | + .join("\n"); | |
| 153 | + throw new Error(`Contenu invalide dans content/${file}:\n${issues}`); | |
| 154 | + } | |
| 155 | + return result.data; | |
| 156 | +} | |
| 157 | + | |
| 158 | +export const site: SiteContent = parse(siteSchema, siteJson, "site.json"); | |
| 159 | +export const home: HomeContent = parse(homeSchema, homeJson, "home.json"); | |
| 160 | +export const pages: PagesContent = parse(pagesSchema, pagesJson, "pages.json"); | |
| 161 | + | |
| 162 | +/** Témoignages publiables : section visible ET avis approuvés uniquement. */ | |
| 163 | +export function publishedTestimonials(): HomeContent["testimonials"]["items"] { | |
| 164 | + if (!home.testimonials.visible) return []; | |
| 165 | + return home.testimonials.items.filter((t) => t.approved); | |
| 166 | +} | |
| 167 | + | |
| 168 | +/** | |
| 169 | + * Témoignages à afficher : en production, uniquement les avis approuvés ; | |
| 170 | + * en prévisualisation (NEXT_PUBLIC_CONTENT_PREVIEW=1), les exemples non | |
| 171 | + * vérifiés sont montrés avec un bandeau explicite. | |
| 172 | + */ | |
| 173 | +export function displayableTestimonials(preview: boolean) { | |
| 174 | + const approved = publishedTestimonials(); | |
| 175 | + if (approved.length > 0) return { items: approved, isPreview: false }; | |
| 176 | + if (preview && home.testimonials.previewOnly) { | |
| 177 | + return { items: home.testimonials.items, isPreview: true }; | |
| 178 | + } | |
| 179 | + return { items: [], isPreview: false }; | |
| 180 | +} | |
| 181 | + | |
| 182 | +export function serviceBySlug(slug: string) { | |
| 183 | + return home.services.items.find((s) => s.slug === slug); | |
| 184 | +} | |
added
src/lib/env.ts
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +import { z } from "zod"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Variables d'environnement serveur, validées au premier accès. | |
| 5 | + * Aucune n'est exposée au navigateur (pas de préfixe NEXT_PUBLIC_ sauf | |
| 6 | + * NEXT_PUBLIC_SITE_URL et NEXT_PUBLIC_CONTENT_PREVIEW, non sensibles). | |
| 7 | + */ | |
| 8 | +const schema = z.object({ | |
| 9 | + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), | |
| 10 | + NEXT_PUBLIC_SITE_URL: z.url().optional(), | |
| 11 | + NEXT_PUBLIC_CONTENT_PREVIEW: z.enum(["0", "1"]).default("0"), | |
| 12 | + | |
| 13 | + /** `smtp` = envoi réel ; `log` = journalisation locale uniquement (dev/CI). */ | |
| 14 | + CONTACT_TRANSPORT: z.enum(["smtp", "log"]).default("log"), | |
| 15 | + CONTACT_TO: z.email().optional(), | |
| 16 | + CONTACT_FROM: z.email().optional(), | |
| 17 | + SMTP_HOST: z.string().optional(), | |
| 18 | + SMTP_PORT: z.coerce.number().int().positive().optional(), | |
| 19 | + SMTP_SECURE: z | |
| 20 | + .enum(["true", "false"]) | |
| 21 | + .default("false") | |
| 22 | + .transform((v) => v === "true"), | |
| 23 | + SMTP_USER: z.string().optional(), | |
| 24 | + SMTP_PASS: z.string().optional(), | |
| 25 | + | |
| 26 | + /** Limitation de débit du formulaire : N requêtes par fenêtre (secondes). */ | |
| 27 | + CONTACT_RATE_LIMIT: z.coerce.number().int().positive().default(5), | |
| 28 | + CONTACT_RATE_WINDOW_S: z.coerce.number().int().positive().default(600), | |
| 29 | +}); | |
| 30 | + | |
| 31 | +export type Env = z.infer<typeof schema>; | |
| 32 | + | |
| 33 | +let cached: Env | null = null; | |
| 34 | + | |
| 35 | +export function getEnv(): Env { | |
| 36 | + if (cached) return cached; | |
| 37 | + const parsed = schema.safeParse(process.env); | |
| 38 | + if (!parsed.success) { | |
| 39 | + const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); | |
| 40 | + throw new Error(`Variables d'environnement invalides : ${issues}`); | |
| 41 | + } | |
| 42 | + const env = parsed.data; | |
| 43 | + if (env.CONTACT_TRANSPORT === "smtp") { | |
| 44 | + const missing = (["CONTACT_TO", "CONTACT_FROM", "SMTP_HOST", "SMTP_PORT"] as const).filter( | |
| 45 | + (k) => !env[k], | |
| 46 | + ); | |
| 47 | + if (missing.length) { | |
| 48 | + throw new Error(`CONTACT_TRANSPORT=smtp requiert : ${missing.join(", ")}`); | |
| 49 | + } | |
| 50 | + } | |
| 51 | + cached = env; | |
| 52 | + return env; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Pour les tests : vider le cache après modification de process.env. */ | |
| 56 | +export function resetEnvCache() { | |
| 57 | + cached = null; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function siteUrl(fallback: string): string { | |
| 61 | + return process.env.NEXT_PUBLIC_SITE_URL ?? fallback; | |
| 62 | +} | |
added
src/lib/mail.ts
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +import nodemailer from "nodemailer"; | |
| 2 | +import type { ContactData } from "./validation"; | |
| 3 | +import { getEnv } from "./env"; | |
| 4 | + | |
| 5 | +export interface Mailer { | |
| 6 | + send(data: ContactData, meta: { ipHash: string }): Promise<{ delivered: boolean; id?: string }>; | |
| 7 | +} | |
| 8 | + | |
| 9 | +function escapeHtml(s: string) { | |
| 10 | + return s | |
| 11 | + .replace(/&/g, "&") | |
| 12 | + .replace(/</g, "<") | |
| 13 | + .replace(/>/g, ">") | |
| 14 | + .replace(/"/g, """); | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function renderContactEmail(data: ContactData) { | |
| 18 | + const text = [ | |
| 19 | + `Nouveau message depuis le site PHI Conseil`, | |
| 20 | + ``, | |
| 21 | + `Nom : ${data.name}`, | |
| 22 | + `Courriel : ${data.email}`, | |
| 23 | + `Organisation : ${data.organization || "—"}`, | |
| 24 | + `Sujet : ${data.subject}`, | |
| 25 | + ``, | |
| 26 | + data.message, | |
| 27 | + ].join("\n"); | |
| 28 | + const html = `<!doctype html><html lang="fr"><body style="font-family:system-ui,sans-serif;color:#1f2a33"> | |
| 29 | +<h2 style="font-weight:600">Nouveau message depuis le site PHI Conseil</h2> | |
| 30 | +<table cellpadding="4"> | |
| 31 | +<tr><td><strong>Nom</strong></td><td>${escapeHtml(data.name)}</td></tr> | |
| 32 | +<tr><td><strong>Courriel</strong></td><td>${escapeHtml(data.email)}</td></tr> | |
| 33 | +<tr><td><strong>Organisation</strong></td><td>${escapeHtml(data.organization || "—")}</td></tr> | |
| 34 | +<tr><td><strong>Sujet</strong></td><td>${escapeHtml(data.subject)}</td></tr> | |
| 35 | +</table> | |
| 36 | +<p style="white-space:pre-wrap">${escapeHtml(data.message)}</p> | |
| 37 | +</body></html>`; | |
| 38 | + return { subject: `[Site PHI Conseil] ${data.subject}`, text, html }; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Transport « log » : n'envoie rien, journalise une empreinte. Jamais présenté comme une livraison. */ | |
| 42 | +export const logMailer: Mailer = { | |
| 43 | + async send(data, meta) { | |
| 44 | + console.info( | |
| 45 | + `[contact] CONTACT_TRANSPORT=log — courriel NON envoyé (sujet="${data.subject}", ip=${meta.ipHash})`, | |
| 46 | + ); | |
| 47 | + return { delivered: false }; | |
| 48 | + }, | |
| 49 | +}; | |
| 50 | + | |
| 51 | +export function createSmtpMailer(): Mailer { | |
| 52 | + const env = getEnv(); | |
| 53 | + const transporter = nodemailer.createTransport({ | |
| 54 | + host: env.SMTP_HOST, | |
| 55 | + port: env.SMTP_PORT, | |
| 56 | + secure: env.SMTP_SECURE, | |
| 57 | + auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined, | |
| 58 | + }); | |
| 59 | + return { | |
| 60 | + async send(data) { | |
| 61 | + const { subject, text, html } = renderContactEmail(data); | |
| 62 | + const info = await transporter.sendMail({ | |
| 63 | + from: env.CONTACT_FROM, | |
| 64 | + to: env.CONTACT_TO, | |
| 65 | + replyTo: data.email, | |
| 66 | + subject, | |
| 67 | + text, | |
| 68 | + html, | |
| 69 | + }); | |
| 70 | + return { delivered: true, id: info.messageId }; | |
| 71 | + }, | |
| 72 | + }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +let instance: Mailer | null = null; | |
| 76 | +export function getMailer(): Mailer { | |
| 77 | + if (instance) return instance; | |
| 78 | + instance = getEnv().CONTACT_TRANSPORT === "smtp" ? createSmtpMailer() : logMailer; | |
| 79 | + return instance; | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** Pour les tests. */ | |
| 83 | +export function setMailerForTests(m: Mailer | null) { | |
| 84 | + instance = m; | |
| 85 | +} | |
added
src/lib/security.ts
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Limitation de débit en mémoire (fenêtre glissante) — adaptée à une instance | |
| 5 | + * unique (PM2 fork / Node). Pour plusieurs instances, remplacer par un store | |
| 6 | + * partagé (Redis) en conservant l'interface `RateLimiter`. | |
| 7 | + */ | |
| 8 | +export interface RateLimiter { | |
| 9 | + check(key: string, now?: number): { allowed: boolean; retryAfterS: number }; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function createMemoryRateLimiter(limit: number, windowS: number): RateLimiter { | |
| 13 | + const hits = new Map<string, number[]>(); | |
| 14 | + const windowMs = windowS * 1000; | |
| 15 | + let lastSweep = 0; | |
| 16 | + return { | |
| 17 | + check(key, now = Date.now()) { | |
| 18 | + if (now - lastSweep > windowMs) { | |
| 19 | + for (const [k, arr] of hits) { | |
| 20 | + const kept = arr.filter((t) => now - t < windowMs); | |
| 21 | + if (kept.length) hits.set(k, kept); | |
| 22 | + else hits.delete(k); | |
| 23 | + } | |
| 24 | + lastSweep = now; | |
| 25 | + } | |
| 26 | + const arr = (hits.get(key) ?? []).filter((t) => now - t < windowMs); | |
| 27 | + if (arr.length >= limit) { | |
| 28 | + const retryAfterS = Math.ceil((arr[0] + windowMs - now) / 1000); | |
| 29 | + hits.set(key, arr); | |
| 30 | + return { allowed: false, retryAfterS: Math.max(1, retryAfterS) }; | |
| 31 | + } | |
| 32 | + arr.push(now); | |
| 33 | + hits.set(key, arr); | |
| 34 | + return { allowed: true, retryAfterS: 0 }; | |
| 35 | + }, | |
| 36 | + }; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** IP cliente derrière un reverse proxy de confiance (Caddy / nginx). */ | |
| 40 | +export function clientIp(headers: Headers): string { | |
| 41 | + const fwd = headers.get("x-forwarded-for"); | |
| 42 | + if (fwd) return fwd.split(",")[0].trim(); | |
| 43 | + return headers.get("x-real-ip") ?? "unknown"; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** Empreinte non réversible pour la journalisation (pas de donnée personnelle en clair). */ | |
| 47 | +export function fingerprint(value: string): string { | |
| 48 | + return createHash("sha256").update(value).digest("hex").slice(0, 12); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Dédoublonnage court : même expéditeur + même message dans la fenêtre. */ | |
| 52 | +export function createDedupe(windowS: number) { | |
| 53 | + const seen = new Map<string, number>(); | |
| 54 | + return { | |
| 55 | + isDuplicate(key: string, now = Date.now()): boolean { | |
| 56 | + for (const [k, t] of seen) if (now - t > windowS * 1000) seen.delete(k); | |
| 57 | + if (seen.has(key)) return true; | |
| 58 | + seen.set(key, now); | |
| 59 | + return false; | |
| 60 | + }, | |
| 61 | + }; | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Politique de sécurité de contenu (CSP) du site. */ | |
| 65 | +export function contentSecurityPolicy(): string { | |
| 66 | + return [ | |
| 67 | + "default-src 'self'", | |
| 68 | + "script-src 'self' 'unsafe-inline'", // inline requis par l'hydratation Next (scripts d'amorçage) | |
| 69 | + "style-src 'self' 'unsafe-inline'", | |
| 70 | + "img-src 'self' data: blob:", | |
| 71 | + "font-src 'self' data:", | |
| 72 | + "connect-src 'self'", | |
| 73 | + "frame-ancestors 'none'", | |
| 74 | + "base-uri 'self'", | |
| 75 | + "form-action 'self'", | |
| 76 | + "object-src 'none'", | |
| 77 | + ].join("; "); | |
| 78 | +} | |
| 79 | + | |
| 80 | +export const securityHeaders = [ | |
| 81 | + { key: "Content-Security-Policy", value: contentSecurityPolicy() }, | |
| 82 | + { key: "X-Content-Type-Options", value: "nosniff" }, | |
| 83 | + { key: "X-Frame-Options", value: "DENY" }, | |
| 84 | + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, | |
| 85 | + { | |
| 86 | + key: "Permissions-Policy", | |
| 87 | + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", | |
| 88 | + }, | |
| 89 | + { | |
| 90 | + key: "Strict-Transport-Security", | |
| 91 | + value: "max-age=31536000; includeSubDomains", | |
| 92 | + }, | |
| 93 | +]; | |
added
src/lib/seo.ts
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { site } from "./content"; | |
| 3 | +import { siteUrl } from "./env"; | |
| 4 | + | |
| 5 | +export function baseUrl() { | |
| 6 | + return siteUrl(site.seo.siteUrl).replace(/\/$/, ""); | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function pageMetadata(opts: { | |
| 10 | + title: string; | |
| 11 | + description: string; | |
| 12 | + path: string; | |
| 13 | + noIndex?: boolean; | |
| 14 | +}): Metadata { | |
| 15 | + const url = `${baseUrl()}${opts.path}`; | |
| 16 | + return { | |
| 17 | + title: opts.title, | |
| 18 | + description: opts.description, | |
| 19 | + alternates: { canonical: url }, | |
| 20 | + openGraph: { title: opts.title, description: opts.description, url, type: "website" }, | |
| 21 | + robots: opts.noIndex ? { index: false, follow: false } : undefined, | |
| 22 | + }; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Données structurées minimales : aucune adresse/avis inventé (champs vides omis). */ | |
| 26 | +export function organizationJsonLd() { | |
| 27 | + const data: Record<string, unknown> = { | |
| 28 | + "@context": "https://schema.org", | |
| 29 | + "@type": "ProfessionalService", | |
| 30 | + name: site.brand.name, | |
| 31 | + url: baseUrl(), | |
| 32 | + description: site.seo.defaultDescription, | |
| 33 | + areaServed: site.contact.region || undefined, | |
| 34 | + email: site.contact.email || undefined, | |
| 35 | + telephone: site.contact.phone || undefined, | |
| 36 | + sameAs: site.social.linkedin ? [site.social.linkedin] : undefined, | |
| 37 | + }; | |
| 38 | + return Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)); | |
| 39 | +} | |
added
src/lib/spiral.ts
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +/** | |
| 2 | + * Géométrie de la spirale de Fibonacci (pure, sans React) — partagée par le | |
| 3 | + * composant <GoldenSpiral /> et le script de génération des icônes. | |
| 4 | + */ | |
| 5 | +type Square = { x: number; y: number; s: number }; | |
| 6 | +type Dir = "right" | "down" | "left" | "up"; | |
| 7 | +const DIRS: Dir[] = ["right", "down", "left", "up"]; | |
| 8 | + | |
| 9 | +export function buildSpiral(levels = 8) { | |
| 10 | + const fib = [1, 1]; | |
| 11 | + while (fib.length < levels) fib.push(fib[fib.length - 1] + fib[fib.length - 2]); | |
| 12 | + | |
| 13 | + const squares: Square[] = [{ x: 0, y: 0, s: 1 }]; | |
| 14 | + const dirs: Dir[] = ["right"]; // direction "virtuelle" du premier carré | |
| 15 | + let bbox = { x: 0, y: 0, w: 1, h: 1 }; | |
| 16 | + | |
| 17 | + for (let i = 1; i < fib.length; i++) { | |
| 18 | + const s = fib[i]; | |
| 19 | + const d = DIRS[(i - 1) % 4]; | |
| 20 | + let sq: Square; | |
| 21 | + if (d === "right") sq = { x: bbox.x + bbox.w, y: bbox.y, s }; | |
| 22 | + else if (d === "down") sq = { x: bbox.x, y: bbox.y + bbox.h, s }; | |
| 23 | + else if (d === "left") sq = { x: bbox.x - s, y: bbox.y, s }; | |
| 24 | + else sq = { x: bbox.x, y: bbox.y - s, s }; | |
| 25 | + squares.push(sq); | |
| 26 | + dirs.push(d); | |
| 27 | + const x0 = Math.min(bbox.x, sq.x); | |
| 28 | + const y0 = Math.min(bbox.y, sq.y); | |
| 29 | + const x1 = Math.max(bbox.x + bbox.w, sq.x + sq.s); | |
| 30 | + const y1 = Math.max(bbox.y + bbox.h, sq.y + sq.s); | |
| 31 | + bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; | |
| 32 | + } | |
| 33 | + | |
| 34 | + // Centre de l'arc d'un carré = coin situé du côté du carré précédent | |
| 35 | + // (opposé à sa direction) et du côté de la direction suivante. | |
| 36 | + const opposite: Record<Dir, Dir> = { | |
| 37 | + right: "left", | |
| 38 | + left: "right", | |
| 39 | + up: "down", | |
| 40 | + down: "up", | |
| 41 | + }; | |
| 42 | + const corner = (sq: Square, a: Dir, b: Dir) => { | |
| 43 | + const x = a === "left" || b === "left" ? sq.x : sq.x + sq.s; | |
| 44 | + const y = a === "up" || b === "up" ? sq.y : sq.y + sq.s; | |
| 45 | + return { x, y }; | |
| 46 | + }; | |
| 47 | + | |
| 48 | + const centers = squares.map((sq, i) => { | |
| 49 | + if (i === 0) return { x: sq.x, y: sq.y }; // coin haut-gauche du 1er carré | |
| 50 | + const next = i + 1 < dirs.length ? dirs[i + 1] : DIRS[i % 4]; | |
| 51 | + return corner(sq, opposite[dirs[i]], next); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + let d = ""; | |
| 55 | + let prev: { x: number; y: number } | null = null; | |
| 56 | + squares.forEach((sq, i) => { | |
| 57 | + const c = centers[i]; | |
| 58 | + // Les deux extrémités possibles de l'arc : à distance s sur chaque axe. | |
| 59 | + const dx = c.x === sq.x ? sq.x + sq.s : sq.x - 0; // extrémité horizontale | |
| 60 | + const dy = c.y === sq.y ? sq.y + sq.s : sq.y - 0; | |
| 61 | + const pH = { x: c.x === sq.x ? dx : sq.x, y: c.y }; | |
| 62 | + const pV = { x: c.x, y: c.y === sq.y ? dy : sq.y }; | |
| 63 | + let from = pV; | |
| 64 | + let to = pH; | |
| 65 | + if (prev) { | |
| 66 | + const same = (p: { x: number; y: number }) => | |
| 67 | + Math.abs(p.x - prev!.x) < 1e-9 && Math.abs(p.y - prev!.y) < 1e-9; | |
| 68 | + if (same(pH)) { | |
| 69 | + from = pH; | |
| 70 | + to = pV; | |
| 71 | + } | |
| 72 | + } | |
| 73 | + const cross = (from.x - c.x) * (to.y - c.y) - (from.y - c.y) * (to.x - c.x); | |
| 74 | + const sweep = cross > 0 ? 1 : 0; | |
| 75 | + if (!prev) d += `M ${from.x} ${from.y} `; | |
| 76 | + d += `A ${sq.s} ${sq.s} 0 0 ${sweep} ${to.x} ${to.y} `; | |
| 77 | + prev = to; | |
| 78 | + }); | |
| 79 | + | |
| 80 | + return { squares, path: d.trim(), bbox }; | |
| 81 | +} | |
added
src/lib/time.ts
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +import { cacheLife } from "next/cache"; | |
| 2 | + | |
| 3 | +/** Année courante, prérendue et revalidée quotidiennement (copyright dynamique). */ | |
| 4 | +export async function currentYear(): Promise<number> { | |
| 5 | + "use cache"; | |
| 6 | + cacheLife("days"); | |
| 7 | + return new Date().getFullYear(); | |
| 8 | +} | |
added
src/lib/validation.ts
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +import { z } from "zod"; | |
| 2 | + | |
| 3 | +/** Supprime les caractères de contrôle et normalise les espaces. */ | |
| 4 | +export function cleanText(value: string): string { | |
| 5 | + return value | |
| 6 | + .normalize("NFC") | |
| 7 | + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") | |
| 8 | + .replace(/[ \t]+/g, " ") | |
| 9 | + .trim(); | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** Une seule ligne : empêche l'injection d'en-têtes dans les courriels. */ | |
| 13 | +const singleLine = (max: number) => | |
| 14 | + z | |
| 15 | + .string() | |
| 16 | + .transform(cleanText) | |
| 17 | + .pipe( | |
| 18 | + z | |
| 19 | + .string() | |
| 20 | + .min(1, "Ce champ est requis.") | |
| 21 | + .max(max, `Maximum ${max} caractères.`) | |
| 22 | + .refine((v) => !/[\r\n]/.test(v), "Les retours à la ligne ne sont pas permis."), | |
| 23 | + ); | |
| 24 | + | |
| 25 | +export const contactSchema = z.object({ | |
| 26 | + name: singleLine(120), | |
| 27 | + email: z | |
| 28 | + .string() | |
| 29 | + .transform((v) => cleanText(v).toLowerCase()) | |
| 30 | + .pipe(z.email("Adresse courriel invalide.").max(254)), | |
| 31 | + organization: z | |
| 32 | + .string() | |
| 33 | + .optional() | |
| 34 | + .default("") | |
| 35 | + .transform(cleanText) | |
| 36 | + .pipe(z.string().max(160, "Maximum 160 caractères.")), | |
| 37 | + subject: singleLine(160), | |
| 38 | + message: z | |
| 39 | + .string() | |
| 40 | + .transform((v) => v.normalize("NFC").replace(/\r\n?/g, "\n").trim()) | |
| 41 | + .pipe( | |
| 42 | + z | |
| 43 | + .string() | |
| 44 | + .min(20, "Décrivez votre demande en au moins 20 caractères.") | |
| 45 | + .max(4000, "Maximum 4 000 caractères."), | |
| 46 | + ), | |
| 47 | + consent: z.literal(true, { | |
| 48 | + error: "Votre consentement est requis pour traiter la demande.", | |
| 49 | + }), | |
| 50 | + /** Honeypot : doit rester vide (champ invisible pour les humains). */ | |
| 51 | + website: z.string().max(0).optional().default(""), | |
| 52 | + /** Horodatage du rendu du formulaire : rejette les soumissions instantanées. */ | |
| 53 | + startedAt: z.coerce.number().int().nonnegative().optional(), | |
| 54 | +}); | |
| 55 | + | |
| 56 | +export type ContactInput = z.input<typeof contactSchema>; | |
| 57 | +export type ContactData = z.output<typeof contactSchema>; | |
| 58 | + | |
| 59 | +/** Schéma côté client (sans honeypot/horodatage, consentement booléen). */ | |
| 60 | +export const contactFormSchema = contactSchema.omit({ | |
| 61 | + website: true, | |
| 62 | + startedAt: true, | |
| 63 | +}); | |
| 64 | +export type ContactFormValues = z.input<typeof contactFormSchema>; | |
added
src/styles/tokens.css
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +/* | |
| 2 | + * Design tokens — PHI Conseil | |
| 3 | + * Couleurs échantillonnées sur references/phi-conseil-homepage.jpeg (2026-11-08). | |
| 4 | + * Point d'entrée unique : ne pas coder de valeur brute dans les composants. | |
| 5 | + */ | |
| 6 | +:root { | |
| 7 | + /* Couleurs */ | |
| 8 | + --color-ivory-50: #faf7f1; /* fond des sections (services, à propos, footer) */ | |
| 9 | + --color-ivory-100: #f5eee4; /* fond du hero */ | |
| 10 | + --color-ivory-200: #f0e4d2; /* vague ivoire, bouton bandeau */ | |
| 11 | + --color-ivory-300: #eddcc8; | |
| 12 | + | |
| 13 | + --color-pale-100: #e6edf3; | |
| 14 | + --color-pale-200: #d7e0e7; /* encadré témoignage, disques services */ | |
| 15 | + --color-pale-300: #c4cdd6; /* vague bleu pâle */ | |
| 16 | + --color-pale-400: #b7c1ca; /* points de pagination inactifs */ | |
| 17 | + | |
| 18 | + --color-slate-500: #597992; /* boutons bleus */ | |
| 19 | + --color-slate-600: #55738f; /* bandeau final */ | |
| 20 | + --color-slate-700: #4a657f; | |
| 21 | + | |
| 22 | + --color-navy-800: #233a52; | |
| 23 | + --color-navy-900: #1d3047; /* titres */ | |
| 24 | + | |
| 25 | + --color-gold-300: #e3cfae; | |
| 26 | + --color-gold-400: #d2b991; /* coches, guillemets */ | |
| 27 | + --color-gold-500: #c7a075; /* spirale, filets */ | |
| 28 | + --color-gold-600: #bea177; /* filets, motifs */ | |
| 29 | + --color-gold-700: #7c5e34; /* texte doré lisible (AA sur ivoire) */ | |
| 30 | + | |
| 31 | + --color-ink-900: #1f2a33; /* texte courant fort */ | |
| 32 | + --color-ink-700: #3a4652; /* texte courant */ | |
| 33 | + --color-ink-500: #5b6771; /* texte secondaire */ | |
| 34 | + --color-ink-300: #9aa4ad; | |
| 35 | + | |
| 36 | + --color-white: #ffffff; | |
| 37 | + --color-danger: #a23b3b; | |
| 38 | + --color-success: #2f6b4f; | |
| 39 | + | |
| 40 | + /* Typographie */ | |
| 41 | + --font-serif: var(--font-eb-garamond), "Iowan Old Style", Georgia, serif; | |
| 42 | + --font-sans: var(--font-figtree), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; | |
| 43 | + | |
| 44 | + --text-xs: 0.75rem; | |
| 45 | + --text-sm: 0.875rem; | |
| 46 | + --text-base: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem); | |
| 47 | + --text-lg: clamp(1.0625rem, 1rem + 0.35vw, 1.25rem); | |
| 48 | + --text-xl: clamp(1.15rem, 1.05rem + 0.4vw, 1.35rem); | |
| 49 | + --text-2xl: clamp(1.5rem, 1.3rem + 0.9vw, 1.875rem); | |
| 50 | + --text-3xl: clamp(1.75rem, 1.3rem + 1.1vw, 2.25rem); | |
| 51 | + --text-4xl: clamp(2rem, 1.55rem + 1.7vw, 2.75rem); | |
| 52 | + --text-display: clamp(2.5rem, 1.55rem + 3.3vw, 4.25rem); | |
| 53 | + | |
| 54 | + --leading-tight: 1.05; | |
| 55 | + --leading-snug: 1.2; | |
| 56 | + --leading-normal: 1.55; | |
| 57 | + --tracking-caps: 0.14em; | |
| 58 | + | |
| 59 | + /* Espacements */ | |
| 60 | + --space-1: 0.25rem; | |
| 61 | + --space-2: 0.5rem; | |
| 62 | + --space-3: 0.75rem; | |
| 63 | + --space-4: 1rem; | |
| 64 | + --space-5: 1.25rem; | |
| 65 | + --space-6: 1.5rem; | |
| 66 | + --space-8: 2rem; | |
| 67 | + --space-10: 2.5rem; | |
| 68 | + --space-12: 3rem; | |
| 69 | + --space-16: 4rem; | |
| 70 | + --space-20: 5rem; | |
| 71 | + --space-24: 6rem; | |
| 72 | + --section-y: clamp(3rem, 2rem + 4vw, 5.5rem); | |
| 73 | + --container-max: 1180px; | |
| 74 | + --container-pad: clamp(1.25rem, 4vw, 3rem); | |
| 75 | + | |
| 76 | + /* Rayons */ | |
| 77 | + --radius-sm: 0.375rem; | |
| 78 | + --radius-md: 0.75rem; | |
| 79 | + --radius-lg: 1.25rem; | |
| 80 | + --radius-xl: 1.75rem; | |
| 81 | + --radius-pill: 999px; | |
| 82 | + | |
| 83 | + /* Ombres */ | |
| 84 | + --shadow-sm: 0 1px 2px rgba(29, 48, 71, 0.06); | |
| 85 | + --shadow-md: 0 6px 18px rgba(29, 48, 71, 0.1); | |
| 86 | + --shadow-lg: 0 18px 40px rgba(29, 48, 71, 0.14); | |
| 87 | + | |
| 88 | + /* Mouvement */ | |
| 89 | + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); | |
| 90 | + --duration-fast: 150ms; | |
| 91 | + --duration-base: 250ms; | |
| 92 | + --duration-slow: 450ms; | |
| 93 | + | |
| 94 | + /* Points de rupture (référence, Tailwind utilise les siens) */ | |
| 95 | + --bp-sm: 640px; | |
| 96 | + --bp-md: 768px; | |
| 97 | + --bp-lg: 1024px; | |
| 98 | + --bp-xl: 1280px; | |
| 99 | + --bp-2xl: 1536px; | |
| 100 | +} | |
added
tests/e2e/a11y.spec.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { test, expect } from "@playwright/test"; | |
| 2 | +import AxeBuilder from "@axe-core/playwright"; | |
| 3 | + | |
| 4 | +for (const r of [ | |
| 5 | + "/", | |
| 6 | + "/services", | |
| 7 | + "/a-propos", | |
| 8 | + "/temoignages", | |
| 9 | + "/nous-joindre", | |
| 10 | + "/confidentialite", | |
| 11 | +]) { | |
| 12 | + test(`axe : aucune violation sérieuse ou critique sur ${r}`, async ({ page }) => { | |
| 13 | + await page.goto(r); | |
| 14 | + const results = await new AxeBuilder({ page }) | |
| 15 | + .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]) | |
| 16 | + .analyze(); | |
| 17 | + const serious = results.violations.filter( | |
| 18 | + (v) => v.impact === "serious" || v.impact === "critical", | |
| 19 | + ); | |
| 20 | + expect( | |
| 21 | + serious.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`), | |
| 22 | + ).toEqual([]); | |
| 23 | + expect(results.violations.map((v) => v.id)).toEqual([]); | |
| 24 | + }); | |
| 25 | +} | |
added
tests/e2e/contact.spec.ts
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +import { test, expect } from "@playwright/test"; | |
| 2 | + | |
| 3 | +test.describe("formulaire de contact", () => { | |
| 4 | + test("affiche les erreurs associées aux champs", async ({ page }) => { | |
| 5 | + await page.goto("/nous-joindre"); | |
| 6 | + await page.getByRole("button", { name: /Envoyer le message/ }).click(); | |
| 7 | + const alerts = page.getByRole("alert"); | |
| 8 | + await expect(alerts.first()).toBeVisible(); | |
| 9 | + const name = page.getByLabel(/^Nom/); | |
| 10 | + await expect(name).toHaveAttribute("aria-invalid", "true"); | |
| 11 | + const described = await name.getAttribute("aria-describedby"); | |
| 12 | + expect(described).toBeTruthy(); | |
| 13 | + await expect(page.locator(`#${described}`)).toBeVisible(); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + test("soumission valide → état de confirmation (transport log, aucun courriel réel)", async ({ | |
| 17 | + page, | |
| 18 | + }) => { | |
| 19 | + await page.goto("/nous-joindre"); | |
| 20 | + await page.getByLabel(/^Nom/).fill("Test Playwright"); | |
| 21 | + await page.getByLabel(/^Courriel/).fill("test@exemple.com"); | |
| 22 | + await page.getByLabel(/^Sujet/).selectOption({ index: 1 }); | |
| 23 | + await page | |
| 24 | + .getByLabel(/^Message/) | |
| 25 | + .fill("Ceci est un message de test automatisé d’au moins vingt caractères."); | |
| 26 | + await page.getByLabel(/J’accepte|J'accepte/).check(); | |
| 27 | + await page.waitForTimeout(2600); // délai anti-robot minimal | |
| 28 | + await page.getByRole("button", { name: /Envoyer le message/ }).click(); | |
| 29 | + const status = page.getByRole("status"); | |
| 30 | + await expect(status).toContainText("Message envoyé"); | |
| 31 | + await expect(status).toContainText("pas encore configuré"); // honnêteté : transport log | |
| 32 | + }); | |
| 33 | + | |
| 34 | + test("l'API refuse une requête malformée", async ({ request }) => { | |
| 35 | + const res = await request.post("/api/contact", { data: { name: "" } }); | |
| 36 | + expect(res.status()).toBe(400); | |
| 37 | + const body = await res.json(); | |
| 38 | + expect(body.ok).toBe(false); | |
| 39 | + }); | |
| 40 | +}); | |
added
tests/e2e/mobile-menu.spec.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +import { test, expect } from "@playwright/test"; | |
| 2 | + | |
| 3 | +test.describe("menu mobile", () => { | |
| 4 | + test("ouverture, navigation clavier, fermeture Échap", async ({ page, isMobile }) => { | |
| 5 | + test.skip(!isMobile, "mobile uniquement"); | |
| 6 | + await page.goto("/"); | |
| 7 | + const opener = page.getByRole("button", { name: "Ouvrir le menu" }); | |
| 8 | + await expect(opener).toBeVisible(); | |
| 9 | + await opener.click(); | |
| 10 | + const dialog = page.getByRole("dialog"); | |
| 11 | + await expect(dialog).toBeVisible(); | |
| 12 | + await expect(page.getByRole("button", { name: "Fermer le menu" })).toBeFocused(); | |
| 13 | + await page.keyboard.press("Escape"); | |
| 14 | + await expect(dialog).toBeHidden(); | |
| 15 | + await expect(opener).toBeFocused(); | |
| 16 | + await opener.click(); | |
| 17 | + await dialog.getByRole("link", { name: "À propos" }).click(); | |
| 18 | + await expect(page).toHaveURL(/\/a-propos$/); | |
| 19 | + await expect(page.getByRole("dialog")).toBeHidden(); | |
| 20 | + }); | |
| 21 | +}); | |
| 22 | + | |
| 23 | +test.describe("responsive", () => { | |
| 24 | + for (const width of [360, 390, 768, 1024, 1440, 1920]) { | |
| 25 | + test(`aucun débordement horizontal à ${width} px`, async ({ page, isMobile }) => { | |
| 26 | + test.skip(isMobile, "largeurs forcées sur le projet desktop"); | |
| 27 | + await page.setViewportSize({ width, height: 900 }); | |
| 28 | + for (const r of ["/", "/nous-joindre", "/services"]) { | |
| 29 | + await page.goto(r); | |
| 30 | + const overflow = await page.evaluate( | |
| 31 | + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, | |
| 32 | + ); | |
| 33 | + expect(overflow, `${r} @ ${width}`).toBeLessThanOrEqual(0); | |
| 34 | + } | |
| 35 | + }); | |
| 36 | + } | |
| 37 | +}); | |
added
tests/e2e/navigation.spec.ts
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +import { test, expect } from "@playwright/test"; | |
| 2 | + | |
| 3 | +const routes = ["/", "/services", "/a-propos", "/temoignages", "/nous-joindre", "/confidentialite"]; | |
| 4 | + | |
| 5 | +test.describe("navigation et liens", () => { | |
| 6 | + test("toutes les pages répondent 200 avec un H1 unique", async ({ page }) => { | |
| 7 | + for (const r of routes) { | |
| 8 | + const res = await page.goto(r); | |
| 9 | + expect(res?.status(), r).toBe(200); | |
| 10 | + await expect(page.locator("h1")).toHaveCount(1); | |
| 11 | + } | |
| 12 | + }); | |
| 13 | + | |
| 14 | + test("aucun lien interne mort sur l'accueil", async ({ page, request }) => { | |
| 15 | + await page.goto("/"); | |
| 16 | + const hrefs = await page | |
| 17 | + .locator("a[href^='/']") | |
| 18 | + .evaluateAll((as) => | |
| 19 | + Array.from( | |
| 20 | + new Set( | |
| 21 | + as | |
| 22 | + .map((a) => (a as HTMLAnchorElement).getAttribute("href")!.split("#")[0]) | |
| 23 | + .filter(Boolean), | |
| 24 | + ), | |
| 25 | + ), | |
| 26 | + ); | |
| 27 | + expect(hrefs.length).toBeGreaterThan(5); | |
| 28 | + for (const h of hrefs) { | |
| 29 | + const res = await request.get(h); | |
| 30 | + expect(res.status(), h).toBeLessThan(400); | |
| 31 | + } | |
| 32 | + }); | |
| 33 | + | |
| 34 | + test("le lien actif est signalé par aria-current", async ({ page, isMobile }) => { | |
| 35 | + test.skip(isMobile, "navigation desktop"); | |
| 36 | + await page.goto("/services"); | |
| 37 | + await expect( | |
| 38 | + page | |
| 39 | + .getByRole("navigation", { name: "Navigation principale" }) | |
| 40 | + .getByRole("link", { name: "Nos services" }), | |
| 41 | + ).toHaveAttribute("aria-current", "page"); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + test("les CTA mènent aux bonnes pages", async ({ page }) => { | |
| 45 | + await page.goto("/"); | |
| 46 | + await page.getByRole("link", { name: /Découvrir nos services/ }).click(); | |
| 47 | + await expect(page).toHaveURL(/\/services$/); | |
| 48 | + await page.goto("/"); | |
| 49 | + await page | |
| 50 | + .getByRole("link", { name: /^Nous joindre/ }) | |
| 51 | + .last() | |
| 52 | + .click(); | |
| 53 | + await expect(page).toHaveURL(/\/nous-joindre$/); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + test("sitemap, robots, manifest et icônes sont servis", async ({ request }) => { | |
| 57 | + for (const p of [ | |
| 58 | + "/sitemap.xml", | |
| 59 | + "/robots.txt", | |
| 60 | + "/manifest.webmanifest", | |
| 61 | + "/icon.svg", | |
| 62 | + "/apple-icon.png", | |
| 63 | + "/opengraph-image", | |
| 64 | + ]) { | |
| 65 | + const res = await request.get(p); | |
| 66 | + expect(res.status(), p).toBe(200); | |
| 67 | + } | |
| 68 | + const icon = await request.get("/icon.svg"); | |
| 69 | + expect(await icon.text()).toContain('<path d="M '); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + test("en-têtes de sécurité présents", async ({ request }) => { | |
| 73 | + const res = await request.get("/"); | |
| 74 | + const h = res.headers(); | |
| 75 | + expect(h["content-security-policy"]).toContain("frame-ancestors 'none'"); | |
| 76 | + expect(h["x-content-type-options"]).toBe("nosniff"); | |
| 77 | + expect(h["x-powered-by"]).toBeUndefined(); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + test("page 404 personnalisée", async ({ page }) => { | |
| 81 | + const res = await page.goto("/page-inexistante"); | |
| 82 | + expect(res?.status()).toBe(404); | |
| 83 | + await expect(page.getByRole("heading", { level: 1 })).toContainText("n’existe pas"); | |
| 84 | + }); | |
| 85 | +}); | |
added
tests/e2e/screenshots.spec.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { test } from "@playwright/test"; | |
| 2 | + | |
| 3 | +/** Captures de référence pour docs/QA_REPORT.md (desktop + mobile). */ | |
| 4 | +test("captures de l'accueil et des pages", async ({ page, isMobile }) => { | |
| 5 | + test.setTimeout(120_000); | |
| 6 | + const widths = isMobile ? [390] : [1024, 1440]; | |
| 7 | + for (const w of widths) { | |
| 8 | + if (!isMobile) await page.setViewportSize({ width: w, height: 900 }); | |
| 9 | + for (const [name, r] of [ | |
| 10 | + ["home", "/"], | |
| 11 | + ["services", "/services"], | |
| 12 | + ["a-propos", "/a-propos"], | |
| 13 | + ["nous-joindre", "/nous-joindre"], | |
| 14 | + ]) { | |
| 15 | + await page.goto(r, { waitUntil: "networkidle" }); | |
| 16 | + await page.screenshot({ | |
| 17 | + path: `docs/screenshots/${name}-${isMobile ? "mobile-390" : w}.png`, | |
| 18 | + fullPage: true, | |
| 19 | + }); | |
| 20 | + } | |
| 21 | + } | |
| 22 | +}); | |
added
tests/unit/components.test.tsx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { render, screen } from "@testing-library/react"; | |
| 3 | +import { Button } from "@/components/ui/Button"; | |
| 4 | +import { TestimonialSlider } from "@/components/sections/TestimonialSlider"; | |
| 5 | +import { buildSpiral } from "@/lib/spiral"; | |
| 6 | + | |
| 7 | +describe("Button", () => { | |
| 8 | + it("rend un lien quand href est fourni", () => { | |
| 9 | + render(<Button href="/services">Voir</Button>); | |
| 10 | + expect(screen.getByRole("link", { name: /voir/i })).toHaveAttribute("href", "/services"); | |
| 11 | + }); | |
| 12 | + it("rend un bouton sinon", () => { | |
| 13 | + render(<Button arrow={false}>OK</Button>); | |
| 14 | + expect(screen.getByRole("button", { name: "OK" })).toHaveAttribute("type", "button"); | |
| 15 | + }); | |
| 16 | +}); | |
| 17 | + | |
| 18 | +describe("TestimonialSlider", () => { | |
| 19 | + const items = [ | |
| 20 | + { quote: "Un", author: "A", role: "r1" }, | |
| 21 | + { quote: "Deux", author: "B", role: "r2" }, | |
| 22 | + ]; | |
| 23 | + it("affiche le premier témoignage et la pagination accessible", () => { | |
| 24 | + render(<TestimonialSlider items={items} />); | |
| 25 | + expect(screen.getByText("Un")).toBeInTheDocument(); | |
| 26 | + expect(screen.getByRole("button", { name: "Témoignage 2 sur 2" })).toBeInTheDocument(); | |
| 27 | + expect(screen.getByRole("button", { name: /pause/i })).toBeInTheDocument(); | |
| 28 | + }); | |
| 29 | + it("n'affiche pas de pagination avec un seul avis", () => { | |
| 30 | + render(<TestimonialSlider items={[items[0]]} />); | |
| 31 | + expect(screen.queryByRole("group")).toBeNull(); | |
| 32 | + }); | |
| 33 | +}); | |
| 34 | + | |
| 35 | +describe("spirale de Fibonacci", () => { | |
| 36 | + it("produit des carrés de côtés de Fibonacci et un rectangle 34×21 à 8 niveaux", () => { | |
| 37 | + const { squares, bbox, path } = buildSpiral(8); | |
| 38 | + expect(squares.map((s) => s.s)).toEqual([1, 1, 2, 3, 5, 8, 13, 21]); | |
| 39 | + expect([bbox.w, bbox.h].sort((a, b) => a - b)).toEqual([21, 34]); | |
| 40 | + expect(path.startsWith("M ")).toBe(true); | |
| 41 | + expect((path.match(/A /g) ?? []).length).toBe(8); | |
| 42 | + }); | |
| 43 | +}); | |
added
tests/unit/contact-route.test.ts
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | +import { POST } from "@/app/api/contact/route"; | |
| 3 | +import { setMailerForTests, type Mailer } from "@/lib/mail"; | |
| 4 | +import { resetEnvCache } from "@/lib/env"; | |
| 5 | + | |
| 6 | +const sent: unknown[] = []; | |
| 7 | +const fakeMailer: Mailer = { | |
| 8 | + async send(data) { | |
| 9 | + sent.push(data); | |
| 10 | + return { delivered: true, id: "fake-id" }; | |
| 11 | + }, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +function req(body: unknown, headers: Record<string, string> = {}) { | |
| 15 | + return new Request("http://localhost/api/contact", { | |
| 16 | + method: "POST", | |
| 17 | + headers: { | |
| 18 | + "content-type": "application/json", | |
| 19 | + "x-forwarded-for": headers.ip ?? "198.51.100.1", | |
| 20 | + ...headers, | |
| 21 | + }, | |
| 22 | + body: typeof body === "string" ? body : JSON.stringify(body), | |
| 23 | + }); | |
| 24 | +} | |
| 25 | + | |
| 26 | +const valid = { | |
| 27 | + name: "Jean Roy", | |
| 28 | + email: "jean@exemple.com", | |
| 29 | + organization: "OBNL", | |
| 30 | + subject: "Reddition de comptes", | |
| 31 | + message: "Nous devons produire un rapport final pour une subvention reçue cette année.", | |
| 32 | + consent: true, | |
| 33 | + website: "", | |
| 34 | + startedAt: Date.now() - 10_000, | |
| 35 | +}; | |
| 36 | + | |
| 37 | +describe("POST /api/contact", () => { | |
| 38 | + beforeEach(() => { | |
| 39 | + sent.length = 0; | |
| 40 | + process.env.CONTACT_TRANSPORT = "log"; | |
| 41 | + process.env.CONTACT_RATE_LIMIT = "100"; | |
| 42 | + resetEnvCache(); | |
| 43 | + setMailerForTests(fakeMailer); | |
| 44 | + vi.spyOn(console, "info").mockImplementation(() => {}); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it("accepte une soumission valide et appelle le mailer", async () => { | |
| 48 | + const res = await POST(req({ ...valid, message: valid.message + " A" })); | |
| 49 | + expect(res.status).toBe(200); | |
| 50 | + expect(await res.json()).toMatchObject({ ok: true, delivered: true }); | |
| 51 | + expect(sent).toHaveLength(1); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it("renvoie 400 avec les champs fautifs", async () => { | |
| 55 | + const res = await POST(req({ ...valid, email: "pas-un-courriel", message: "B" })); | |
| 56 | + expect(res.status).toBe(400); | |
| 57 | + const body = await res.json(); | |
| 58 | + expect(body.ok).toBe(false); | |
| 59 | + expect(Object.keys(body.fields)).toEqual(expect.arrayContaining(["email", "message"])); | |
| 60 | + expect(sent).toHaveLength(0); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("répond succès silencieux au honeypot sans envoyer", async () => { | |
| 64 | + const res = await POST(req({ ...valid, website: "http://bot", message: valid.message + " C" })); | |
| 65 | + expect(res.status).toBe(200); | |
| 66 | + expect(await res.json()).toMatchObject({ ok: true, delivered: false }); | |
| 67 | + expect(sent).toHaveLength(0); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it("ignore une soumission instantanée (robot)", async () => { | |
| 71 | + const res = await POST(req({ ...valid, startedAt: Date.now(), message: valid.message + " D" })); | |
| 72 | + expect(await res.json()).toMatchObject({ ok: true, delivered: false }); | |
| 73 | + expect(sent).toHaveLength(0); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("refuse un JSON invalide et un mauvais content-type", async () => { | |
| 77 | + expect((await POST(req("{oops"))).status).toBe(400); | |
| 78 | + const r = await POST( | |
| 79 | + new Request("http://localhost/api/contact", { | |
| 80 | + method: "POST", | |
| 81 | + headers: { "content-type": "text/plain" }, | |
| 82 | + body: "x", | |
| 83 | + }), | |
| 84 | + ); | |
| 85 | + expect(r.status).toBe(415); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("dédoublonne un même message", async () => { | |
| 89 | + const body = { ...valid, message: valid.message + " E" }; | |
| 90 | + await POST(req(body)); | |
| 91 | + const res = await POST(req(body)); | |
| 92 | + expect(await res.json()).toMatchObject({ ok: true, duplicate: true }); | |
| 93 | + expect(sent).toHaveLength(1); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + it("renvoie 502 si le mailer échoue, sans fuite de détail", async () => { | |
| 97 | + vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 98 | + setMailerForTests({ | |
| 99 | + async send() { | |
| 100 | + throw new Error("SMTP down: password=secret"); | |
| 101 | + }, | |
| 102 | + }); | |
| 103 | + const res = await POST(req({ ...valid, message: valid.message + " F" })); | |
| 104 | + expect(res.status).toBe(502); | |
| 105 | + const txt = JSON.stringify(await res.json()); | |
| 106 | + expect(txt).not.toContain("secret"); | |
| 107 | + }); | |
| 108 | +}); | |
added
tests/unit/content.test.ts
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { | |
| 3 | + site, | |
| 4 | + home, | |
| 5 | + pages, | |
| 6 | + homeSchema, | |
| 7 | + displayableTestimonials, | |
| 8 | + serviceBySlug, | |
| 9 | +} from "@/lib/content"; | |
| 10 | + | |
| 11 | +describe("contenu administrable", () => { | |
| 12 | + it("charge et valide les trois fichiers JSON", () => { | |
| 13 | + expect(site.brand.name).toBe("PHI Conseil"); | |
| 14 | + expect(home.services.items).toHaveLength(4); | |
| 15 | + expect(pages.contact.subjects.length).toBeGreaterThan(0); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it("rejette un contenu invalide avec un message lisible", () => { | |
| 19 | + const broken = structuredClone(home) as Record<string, unknown>; | |
| 20 | + (broken.hero as { titleLines: string[] }).titleLines = []; | |
| 21 | + const r = homeSchema.safeParse(broken); | |
| 22 | + expect(r.success).toBe(false); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it("ne publie jamais un témoignage non approuvé hors prévisualisation", () => { | |
| 26 | + expect(displayableTestimonials(false).items).toHaveLength(0); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("montre les exemples en prévisualisation avec le drapeau isPreview", () => { | |
| 30 | + const r = displayableTestimonials(true); | |
| 31 | + expect(r.isPreview).toBe(true); | |
| 32 | + expect(r.items.length).toBeGreaterThan(0); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("retrouve un service par slug", () => { | |
| 36 | + expect(serviceBySlug("reddition-de-comptes")?.title).toBe("Reddition de comptes"); | |
| 37 | + expect(serviceBySlug("inexistant")).toBeUndefined(); | |
| 38 | + }); | |
| 39 | +}); | |
added
tests/unit/security.test.ts
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { | |
| 3 | + createMemoryRateLimiter, | |
| 4 | + createDedupe, | |
| 5 | + clientIp, | |
| 6 | + fingerprint, | |
| 7 | + contentSecurityPolicy, | |
| 8 | +} from "@/lib/security"; | |
| 9 | + | |
| 10 | +describe("limitation de débit", () => { | |
| 11 | + it("bloque après N requêtes dans la fenêtre puis libère", () => { | |
| 12 | + const rl = createMemoryRateLimiter(3, 60); | |
| 13 | + const t0 = 1_000_000; | |
| 14 | + expect(rl.check("a", t0).allowed).toBe(true); | |
| 15 | + expect(rl.check("a", t0 + 1).allowed).toBe(true); | |
| 16 | + expect(rl.check("a", t0 + 2).allowed).toBe(true); | |
| 17 | + const blocked = rl.check("a", t0 + 3); | |
| 18 | + expect(blocked.allowed).toBe(false); | |
| 19 | + expect(blocked.retryAfterS).toBeGreaterThan(0); | |
| 20 | + expect(rl.check("b", t0 + 3).allowed).toBe(true); | |
| 21 | + expect(rl.check("a", t0 + 61_000).allowed).toBe(true); | |
| 22 | + }); | |
| 23 | +}); | |
| 24 | + | |
| 25 | +describe("dédoublonnage", () => { | |
| 26 | + it("signale un doublon dans la fenêtre seulement", () => { | |
| 27 | + const d = createDedupe(10); | |
| 28 | + expect(d.isDuplicate("k", 0)).toBe(false); | |
| 29 | + expect(d.isDuplicate("k", 5_000)).toBe(true); | |
| 30 | + expect(d.isDuplicate("k", 20_000)).toBe(false); | |
| 31 | + }); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +describe("utilitaires", () => { | |
| 35 | + it("lit la première IP de X-Forwarded-For", () => { | |
| 36 | + expect(clientIp(new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }))).toBe( | |
| 37 | + "203.0.113.5", | |
| 38 | + ); | |
| 39 | + expect(clientIp(new Headers())).toBe("unknown"); | |
| 40 | + }); | |
| 41 | + it("produit une empreinte stable non réversible", () => { | |
| 42 | + expect(fingerprint("a")).toBe(fingerprint("a")); | |
| 43 | + expect(fingerprint("a")).not.toBe(fingerprint("b")); | |
| 44 | + expect(fingerprint("a")).toHaveLength(12); | |
| 45 | + }); | |
| 46 | + it("la CSP interdit l'intégration dans une iframe et les objets", () => { | |
| 47 | + const csp = contentSecurityPolicy(); | |
| 48 | + expect(csp).toContain("frame-ancestors 'none'"); | |
| 49 | + expect(csp).toContain("object-src 'none'"); | |
| 50 | + }); | |
| 51 | +}); | |
added
tests/unit/setup.ts
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +import "@testing-library/jest-dom/vitest"; | |
| 2 | + | |
| 3 | +// jsdom n'implémente pas matchMedia (utilisé pour prefers-reduced-motion). | |
| 4 | +if (typeof window !== "undefined" && !window.matchMedia) { | |
| 5 | + window.matchMedia = (query: string) => | |
| 6 | + ({ | |
| 7 | + matches: false, | |
| 8 | + media: query, | |
| 9 | + onchange: null, | |
| 10 | + addEventListener: () => {}, | |
| 11 | + removeEventListener: () => {}, | |
| 12 | + addListener: () => {}, | |
| 13 | + removeListener: () => {}, | |
| 14 | + dispatchEvent: () => false, | |
| 15 | + }) as MediaQueryList; | |
| 16 | +} | |
| 17 | + | |
| 18 | +import { afterEach } from "vitest"; | |
| 19 | +import { cleanup } from "@testing-library/react"; | |
| 20 | +afterEach(() => cleanup()); | |
added
tests/unit/validation.test.ts
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { contactSchema, cleanText } from "@/lib/validation"; | |
| 3 | + | |
| 4 | +const valid = { | |
| 5 | + name: " Marie Tremblay ", | |
| 6 | + email: "Marie@Exemple.COM", | |
| 7 | + organization: "", | |
| 8 | + subject: "Recherche de subventions", | |
| 9 | + message: "Bonjour, nous aimerions discuter d'un projet de financement pour notre organisme.", | |
| 10 | + consent: true, | |
| 11 | + website: "", | |
| 12 | +}; | |
| 13 | + | |
| 14 | +describe("validation du formulaire de contact", () => { | |
| 15 | + it("normalise et accepte une soumission valide", () => { | |
| 16 | + const r = contactSchema.safeParse(valid); | |
| 17 | + expect(r.success).toBe(true); | |
| 18 | + if (r.success) { | |
| 19 | + expect(r.data.name).toBe("Marie Tremblay"); | |
| 20 | + expect(r.data.email).toBe("marie@exemple.com"); | |
| 21 | + } | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("refuse l'injection d'en-têtes dans le sujet", () => { | |
| 25 | + const r = contactSchema.safeParse({ ...valid, subject: "Sujet\r\nBcc: spam@exemple.com" }); | |
| 26 | + expect(r.success).toBe(false); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("exige le consentement", () => { | |
| 30 | + expect(contactSchema.safeParse({ ...valid, consent: false }).success).toBe(false); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("refuse un message trop court ou trop long", () => { | |
| 34 | + expect(contactSchema.safeParse({ ...valid, message: "court" }).success).toBe(false); | |
| 35 | + expect(contactSchema.safeParse({ ...valid, message: "x".repeat(4001) }).success).toBe(false); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("détecte le honeypot rempli", () => { | |
| 39 | + const r = contactSchema.safeParse({ ...valid, website: "http://spam" }); | |
| 40 | + expect(r.success).toBe(false); | |
| 41 | + if (!r.success) expect(r.error.issues.some((i) => i.path[0] === "website")).toBe(true); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("retire les caractères de contrôle", () => { | |
| 45 | + expect(cleanText("abc\u0000\u0007def ghi")).toBe("abcdef ghi"); | |
| 46 | + }); | |
| 47 | +}); | |
modified
tsconfig.json
+3 −2
@@ -20,7 +20,8 @@ | ||
| 20 | 20 | ], |
| 21 | 21 | "paths": { |
| 22 | 22 | "@/*": ["./src/*"] |
| 23 | − } | |
| 23 | + }, | |
| 24 | + "allowImportingTsExtensions": true | |
| 24 | 25 | }, |
| 25 | 26 | "include": [ |
| 26 | 27 | "next-env.d.ts", |
@@ -30,5 +31,5 @@ | ||
| 30 | 31 | ".next/dev/types/**/*.ts", |
| 31 | 32 | "**/*.mts" |
| 32 | 33 | ], |
| 33 | − "exclude": ["node_modules"] | |
| 34 | + "exclude": ["node_modules", "tests/e2e", "playwright.config.ts"] | |
| 34 | 35 | } |
added
vitest.config.mts
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | +import react from "@vitejs/plugin-react"; | |
| 3 | +import { fileURLToPath } from "node:url"; | |
| 4 | + | |
| 5 | +export default defineConfig({ | |
| 6 | + plugins: [react()], | |
| 7 | + resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, | |
| 8 | + test: { | |
| 9 | + environment: "jsdom", | |
| 10 | + include: ["tests/unit/**/*.test.{ts,tsx}"], | |
| 11 | + setupFiles: ["tests/unit/setup.ts"], | |
| 12 | + globals: false, | |
| 13 | + }, | |
| 14 | +}); | |
| 15 | ||