Refonte du front (D-005), référentiel véhicules RDW (D-004) et Phase 0 du pilier maison
Trois chantiers arrivent ensemble faute d'avoir été committés au fil de l'eau : - Suppression du mur e-mail post-quiz et refonte du parcours autour de la révélation graduée (D-005) : nouveau quiz-client, hub foyer, landing. - Le champ K remplace la plaque comme clé d'identification véhicule ; le référentiel RDW Open Data et l'entretien constructeur passent en base (D-004). - Phase 0 du pilier maison (docs/pilier-maison.md) : adapter DPE réparé (le dataset interrogé renvoyait 404 en silence) avec jointure exacte sur banId, confirmation DPE explicite avant toute échéance (déclaré > confirmé > dérivé), correctif de revision-loyer-irl (gel des loyers F/G), et 6 nouvelles fiches calendaires. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
22035f4a40
commit
d89ff518e8
85 changed files with 7626 additions and 1515 deletions
15
.env.example
15
.env.example
|
|
@ -1,12 +1,19 @@
|
|||
# Base de données (docker-compose fournit fokan-db sur ai-net)
|
||||
DATABASE_URL=postgres://fokan:fokan@fokan-db:5432/fokan
|
||||
|
||||
# Adapter plaque — sans clé, le mock déterministe est utilisé (dev)
|
||||
# PLAQUE_API_URL=
|
||||
# PLAQUE_API_KEY=
|
||||
# Référentiel véhicules — RDW Open Data, gratuit. Le jeton n'est pas requis mais relève
|
||||
# le quota Socrata de 100 à 1 000 requêtes/h : recommandé avant le premier cron complet.
|
||||
# RDW_APP_TOKEN=
|
||||
|
||||
# Adapter adresse — open data, pas de clé requise
|
||||
# ADEME_DPE_DATASET_URL=https://data.ademe.fr/data-fair/api/v1/datasets/dpe-v2-logements-existants/lines
|
||||
# ADEME_DPE_DATASET_URL=https://data.ademe.fr/data-fair/api/v1/datasets/dpe03existant/lines
|
||||
|
||||
# Référentiel véhicules (RDW Open Data, domaine public) — jeton gratuit et FACULTATIF.
|
||||
# Sans jeton, le quota est partagé par IP et les refus (429) sont plus fréquents ; avec, on
|
||||
# passe à 1 000 requêtes/heure glissante (un passage complet en consomme ~560, étalées sur ~2 h).
|
||||
# Le rejeu automatique absorbe les refus dans les deux cas ; le jeton ne fait que les raréfier.
|
||||
# S'obtenir sur https://opendata.rdw.nl (profil développeur → App Tokens).
|
||||
# RDW_APP_TOKEN=
|
||||
|
||||
# Comptes (better-auth, § 25) — secret long et aléatoire, JAMAIS commité.
|
||||
# Générer avec : openssl rand -hex 32
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ Chaque mois, le prélèvement arrive **avec sa preuve** : le récap mensuel («
|
|||
- **Les guides, en accès individuel uniquement, jamais en catalogue navigable.** Un guide se trouve (recherche Google, lien direct, mention dans un autre guide) ; il ne se parcourt pas en liste. Le catalogue complet est réservé aux abonnés — **c'est lui, la valeur du produit** : la charge mentale, c'est ne pas savoir qu'il faut chercher, pas seulement oublier une date connue. Indexation Google assurée par **sitemap XML** (découple crawl et navigation humaine) ; maillage interne en liens contextuels dans la prose des articles. Frange résiduelle acceptée : `site:fokan.fr/guide` reste possible sur Google — cohérent avec la règle « accessible si on sait quoi chercher », pas un problème à corriger.
|
||||
- **Le quiz complet**, 100 % déclaratif, **zéro appel API payant** : logement (avec pré-suggestion par adresse, open data gratuit), véhicules (type + année, déclaratifs), papiers, animaux.
|
||||
- **Le pilier Véhicules en gratuit reste générique** : le moteur applique les mêmes fenêtres de confiance que les autres piliers (§ 4.3) à partir du type + de l'année déclarés (« prochain contrôle technique estimé courant 2027 »). Aucun appel API plaque au stade gratuit — décision motivée par le coût (§ 10.6).
|
||||
- **La preview post-quiz**, email requis avant affichage (capture du lead + friction anti-scraping) : total et répartition par pilier (« 34 échéances sur 4 domaines »), 2-3 échéances urgentes entièrement détaillées, le reste en cartes verrouillées (titre + pilier visibles, date/enjeu masqués).
|
||||
- **La preview post-quiz**, ~~email requis avant affichage (capture du lead + friction anti-scraping)~~ **sans aucune contrepartie demandée (révisé par D-005, 25/07/2026)** : total et répartition par pilier (« 34 échéances sur 4 domaines »), **le rythme de l'année (timeline 12 mois, pastilles anonymes par mois et par pilier)**, 2-3 échéances urgentes entièrement détaillées, le reste en cartes verrouillées (titre + pilier visibles, date/enjeu masqués). L'adresse mail se demande **après** le service rendu, comme un service supplémentaire et non comme une porte. Ligne directrice consolidée : **révéler l'inventaire, jamais le mode d'emploi**.
|
||||
- Pas d'export .ics statique gratuit (retiré : supprimait le risque qu'un fichier recopiable suffise à lui seul).
|
||||
|
||||
**PAYANT — La Veille, 19,99 €/an**
|
||||
|
|
|
|||
|
|
@ -2,6 +2,114 @@
|
|||
|
||||
Le [cadrage](cadrage-projet-fokan.md) reste le document de référence ; ce journal trace les décisions qui le révisent ou le précisent.
|
||||
|
||||
## D-005 — 25 juillet 2026 · Le mur e-mail post-quiz est retiré ; refonte du front autour de la révélation graduée
|
||||
|
||||
**Révise le § 10.2 du cadrage** (« La preview post-quiz, email requis avant affichage »).
|
||||
|
||||
**Contexte** : le parcours accumulait trois portes avant la moindre contrepartie — 8 questions, puis un mur e-mail, puis le paywall. Un visiteur donnait deux minutes et se voyait redemander quelque chose avant d'avoir rien vu. Le quiz devenait rétrospectivement un péage, exactement le contraire de la règle d'or (§ 4.1 : chaque interaction supprime plus de travail qu'elle n'en coûte).
|
||||
|
||||
### 1. Rien n'est demandé avant d'avoir donné
|
||||
|
||||
L'aperçu du foyer s'affiche immédiatement après le quiz, sans adresse mail. `EmailGate` et `hasUnlockedPreview()` sont supprimés ; `/api/checkout` n'exige plus d'adresse (Stripe Checkout la collecte lui-même — un champ de moins entre l'intention et le paiement).
|
||||
|
||||
**Sur la friction anti-scraping**, qui était l'autre justification du mur : elle était faible. Chaque aperçu suppose un quiz complet et un UUID non devinable ; le coût d'un scraping reste dominé par le quiz, pas par le mur. Un rate-limit sur `/api/quiz` couvre le besoin réel — à mettre en place, non fait à ce stade.
|
||||
|
||||
### 2. La contrepartie remplace la barrière pour la capture du lead
|
||||
|
||||
L'adresse se demande **après** le service rendu, formulée comme un service supplémentaire (« je te renvoie ce lien par mail ») et non comme une condition. Elle n'apparaît qu'une fois le calendrier parcouru (sentinelle d'intersection), est refusable, et le refus est mémorisé. Complément indispensable : le foyer visité est mémorisé en `localStorage`, et la nav propose « Reprendre mon foyer » — sans quoi une URL en UUID rendrait le résultat irretrouvable.
|
||||
|
||||
**Arbitrage assumé** : moins de leads en volume, de bien meilleure qualité, et surtout plus aucune perception de piège. Le CTA de sortie du parcours devient le paiement, pas la collecte d'adresse.
|
||||
|
||||
### 3. La frontière gratuit/payant est reformulée : révéler l'inventaire, jamais le mode d'emploi
|
||||
|
||||
Le § 10.2 restait juste sur le fond mais était appliqué de façon binaire (3 échéances en clair, le reste en titres grisés) : trop de substance sur les trois, aucune sensation de volume sur le reste. Nouvelle ligne, appliquée partout :
|
||||
|
||||
| Gratuit — la sensation | Payant — le service |
|
||||
| --- | --- |
|
||||
| Le volume (« 34 échéances ») | La date et la fenêtre |
|
||||
| La répartition par pilier | Le pourquoi, l'enjeu |
|
||||
| Le rythme de l'année (densité mois par mois) | La règle et sa source |
|
||||
| Le compte par pilier (jamais le titre) | Le titre, l'action, le premier pas |
|
||||
| | La mise à jour continue |
|
||||
|
||||
**Exception volontaire et bornée** : 2-3 échéances réelles du foyer restent entièrement révélées (titre, date, enjeu) même sans abonnement — seule brèche assumée dans la ligne ci-dessus. Sans elle, tout ce qu'un visiteur sans abonnement voit reste abstrait (compteur, timeline, agrégats) ; rien ne prouve concrètement qu'on a trouvé quelque chose pour *son* foyer. Un échantillon borné à 2-3 items est de nature différente d'exposer l'inventaire entier ou les catégories de rappel à tout le monde — c'est le patron freemium standard, et le § 10.2 le prévoyait dès l'origine. (Une itération intermédiaire de cette révision l'avait supprimé par excès de zèle en généralisant le raisonnement du mur d'échéances ; remis en place le jour même après revue.)
|
||||
|
||||
Pièce centrale : une **timeline 12 mois** où chaque échéance est une pastille anonyme positionnée sur son mois, couleur = pilier. Elle fait ressentir la charge de l'année sans jamais associer un titre à une date — la corrélation reste impossible, et c'est ce qui la rend publiable. C'est aussi l'objet partageable attendu au § 11.3.
|
||||
|
||||
**Conséquence sur la landing** : les trois cartes de démonstration mail sont retirées. Elles donnaient gratuitement le savoir vendu (délais de rendez-vous en mairie, règle des 6 mois de validité, tension saisonnière sur le contrôle technique). Le mur d'échéances qui les a remplacées est passé par deux versions : une première listait les vrais intitulés des 45 fiches en défilement (encore trop — cela publiait le catalogue complet, réservé aux abonnés) ; la version retenue est un champ de pastilles colorées par pilier, sans aucun mot — le volume et la répartition se ressentent, aucun sujet n'est nommé ni cherchable.
|
||||
|
||||
**Rappels constructeur** (`rappels.tsx`) suivent la même ligne : le teaser sans abonnement révèle l'existence et le compte des campagnes, jamais leurs catégories (« freinage », « direction »…) — connaître la catégorie concernée suffisait à motiver un passage au garage sans jamais payer.
|
||||
|
||||
### 4. Refonte du front
|
||||
|
||||
Tailwind v4 (thème en CSS via `@theme inline`, aucun `tailwind.config.js`), socle de tokens en trois couches (palette → sémantique → exposition Tailwind) avec mode sombre traité **uniquement** dans `globals.css` : aucun composant ne connaît son existence. Typographie Fraunces (display) + Inter (texte), auto-hébergées par `next/font/google` — aucun appel navigateur vers Google (§ 16). Primitives partagées dans `src/components/ui/` en remplacement des 50 `style={{}}` inline.
|
||||
|
||||
Le paywall devient une section pleine confrontant le prix à ce qu'il achète, placée **après** les listes verrouillées. Le quiz rend de la valeur pendant la saisie (piliers qui s'allument, décompte factuel d'éléments — jamais un nombre d'échéances promis), offre « Passer » sur tous les écrans, conserve un brouillon en `sessionStorage` et remplace l'`alert()` de fin de parcours par un vrai état d'erreur.
|
||||
|
||||
**Piège de bundling rencontré, à ne pas réintroduire** : un module JSX sans directive importé par un composant client rendu dans le layout racine casse le manifeste RSC au prerender (Next 15.5). D'où `src/components/ui/button-styles.ts`, module purement TS, seul consommé par les composants client.
|
||||
|
||||
### 5. Reste à faire
|
||||
|
||||
Rate-limit sur `/api/quiz` ; vignettes OG par guide (seule la vignette racine existe) ; validation juridique de la case de renonciation (§ 10.7, inchangé).
|
||||
|
||||
---
|
||||
|
||||
## D-004 — 25 juillet 2026 · Véhicules : le champ K remplace la plaque (annule D-002) ; l'entretien constructeur passe en base
|
||||
|
||||
**Contexte** : le référentiel RDW Open Data (domaine public, ~2,9 M configurations) publie les réceptions CE de toutes les autorités européennes, y compris françaises, indexées exactement comme les **champs K et D.2 de la carte grise**. Un foyer qui recopie son champ K identifie son véhicule aussi précisément qu'une API plaque — gratuitement, sans licence SIV, sans coût variable. C'est ce que D-002 jugeait impossible en juillet.
|
||||
|
||||
Un audit global de la couche connaissance a par ailleurs montré que les deux mécaniques cohabitaient mal, et que la plus faible avait la priorité.
|
||||
|
||||
### 1. La mécanique plaque est retirée du produit
|
||||
|
||||
**Fait constaté en production** : `PLAQUE_API_URL` n'ayant jamais été renseignée, l'adapter retombait sur son mock déterministe — **en prod**. Quatre véhicules fictifs avaient été écrits en base, dont un ayant **écrasé** une identification exacte, et le drapeau `plaque_verifiee` masquait ensuite le widget à jamais. La fonctionnalité payante détruisait la donnée juste, sans erreur ni trace.
|
||||
|
||||
**Décision** : suppression complète de l'adapter, des deux routes API, du composant UI et des variables d'environnement. Les 4 véhicules fictifs et leurs 17 échéances ont été purgés. Le champ K devient l'unique voie d'identification exacte, disponible **gratuitement pour tous** — ce qui annule aussi le raisonnement économique du § 10.6 (D-003, point 3) : il n'y a plus d'appel API à rationner.
|
||||
|
||||
**Conséquence sur le payant** : le débloquant du pilier Véhicules n'est plus « ta vraie plaque » mais les **rappels constructeur** (déjà livrés, teaser gratuit + détail abonné). Copy commerciale mise à jour en conséquence.
|
||||
|
||||
**Ce qu'on perd** : la date de 1re immatriculation, que la plaque fournissait et que le champ K ne donne pas (une réception date l'homologation du type, pas l'exemplaire). La fenêtre de contrôle technique repart donc de l'année déclarée — estimée au lieu de connue. `date_premiere_immat` reste dans le schéma pour une date apprise ultérieurement.
|
||||
|
||||
### 2. L'entretien constructeur quitte le YAML pour la base
|
||||
|
||||
`knowledge/vehicules/entretien-constructeur.yaml` était à cheval sur les deux couches et échouait aux critères de la Connaissance : aucun éditorial, aucun ID stable à vie, produit par un pipeline et non à la main, **~12 900 triplets (marque, modèle, énergie) cibles** — une relecture de diff git y aurait été une fiction. Il ne pouvait pas joindre le catalogue, inventait donc ses propres clés (`groupe_id`, `famille_moteur`) héritées d'ADEME et sans producteur depuis la bascule vers RDW, et imposait une lecture disque au cœur du moteur de fenêtres.
|
||||
|
||||
**Décision** : table `vehicules_entretien`, clé identique à celle des rappels. La relecture humaine devient des colonnes (`confiance`, `revu_par`, `revu_le`) donc requêtable, au lieu d'un historique git que personne n'aurait lu.
|
||||
|
||||
**Règle de tri retenue, applicable aux piliers suivants** : *si je supprime la table et rejoue le pipeline, est-ce que je récupère exactement la même chose ?* Oui → référentiel en base. Non, il y a du jugement humain irremplaçable dedans → Connaissance en git. Les 45 fiches d'échéance restent en YAML sans discussion : éditorial, revue par diff, atomicité avec le code (l'enum `strategy`, le DSL d'applicabilité), bicéphalie avec les pages SEO, et « une fiche invalide casse le build, jamais la prod » — un principe qui s'inverse mécaniquement en base.
|
||||
|
||||
### 3. Une seule résolution véhicule
|
||||
|
||||
Trois politiques de rapprochement `(marque, modèle)` coexistaient : `ILIKE` pour l'autocomplétion, égalité stricte pour les rappels, `includes()` bidirectionnel pour l'entretien. La troisième faisait matcher « C5 AIRCROSS » sur une entrée « C5 » — une préconisation attribuée au mauvais véhicule, en silence. Unifiées dans `src/knowledge/vehicules-resolution.ts`, sur clés normalisées stockées. Aucune correspondance approchée : sur des campagnes de sécurité, un faux positif coûte plus cher qu'un silence.
|
||||
|
||||
### 4. Le moteur de fenêtres redevient pur
|
||||
|
||||
`computeWindow` lisait un YAML sur disque, ce qui violait l'invariant n° 5 et forçait les tests à mocker un module. L'entretien lui est désormais **passé en argument**, chargé par la couche I/O (`reconcile-db.ts`).
|
||||
|
||||
### 5. Ingestion : les garanties d'upsert deviennent communes
|
||||
|
||||
`src/db/upsert.ts` — taille de lot et clause SET **dérivées du schéma**, appliquées aux cinq tables synchronisées. `rappels` écrivait encore sa clause à la main, donc exposée au défaut silencieux qu'on avait déjà corrigé ailleurs : une colonne ajoutée n'aurait jamais été rafraîchie aux passages suivants. 18 tests couvrent désormais chaque table.
|
||||
|
||||
### 6. `carburant` → `energie`
|
||||
|
||||
Un concept, un seul nom, de bout en bout : quiz, asset, référentiel, contrat d'API. Les attributs des véhicules existants ont été migrés en base ; `energieDe()` lit encore l'ancien nom en repli. **La fiche `courroie-distribution` conditionnait sur `carburant`** — sans mise à jour simultanée du YAML, elle aurait cessé de s'appliquer en silence. C'est l'illustration exacte de l'argument d'atomicité qui justifie de garder les fiches en git.
|
||||
|
||||
### 7. Qualité des libellés du référentiel — mesurée, et corrigée à la lecture
|
||||
|
||||
**Codes internes en guise de nom commercial** (« CITROEN 7066 ») : mesuré après coup, **négligeable**. Sur les 15 marques grand public (88 % du référentiel), les modèles à ≤ 5 configurations pèsent **0,072 %** du volume, et la quasi-totalité sont des noms de variantes parfaitement légitimes (`AUDI Q4 SB E-TRON PERFORMANCE`, `BMW 225XE ACTIVE TOURER`). Les vrais codes internes identifiés se comptent sur les doigts (`CITROEN 7066`, `CITROEN S1HDZE`, `AUDI 4L`, `8G7`, `S8Q`, `RENAULT 57`), une trentaine de configurations. Aucune table de correspondance ne se justifie.
|
||||
|
||||
*Piège évité* : le critère « modèle à configuration unique » (2 348 modèles, 1,2 % des réceptions) aurait été trompeur — il est dominé par des **camping-cars** de très petite série (Pilote, Carthago, Adria, Knaus), dont les noms sont réels.
|
||||
|
||||
**Accents perdus à la source** : le vrai défaut, celui-là corrigé. RDW publie certains libellés avec le caractère accentué remplacé par « ? » ou U+FFFD — vérifié directement sur l'API, ce n'est pas notre ingestion. 26 modèles, 718 configurations.
|
||||
|
||||
Le coût n'était pas cosmétique mais structurel : le catalogue **éclatait un même modèle en plusieurs entrées** (`A5 COUPE` 5 340 configurations, `A5 COUP<55>` 463, `A5 COUP?` 1 — MINI Cooper Coupé en comptait cinq orthographes). Trois entrées identiques à l'écran, et 463 configurations décrochées des rappels attachés à la quatrième.
|
||||
|
||||
**Corrigé à la lecture, pas à l'ingestion** : `reparerLibelle()` dans `vehicules-cle.ts`, appliqué avant le calcul de la clé (les orthographes convergent) et à l'affichage (l'autocomplétion fusionne les doublons). Rien à réimporter, effet immédiat sur les 2,9 M de lignes déjà en base. **701 des 718 configurations rejoignent leur modèle canonique.**
|
||||
|
||||
Table de réparation volontairement fermée — `COUPÉ`, `CITROËN`, `PÖSSL`, `N°` — parce que chacune est vérifiable : la source publie les deux orthographes pour les mêmes voitures, ou ce sont des noms propres attestés. Les 6 corruptions qu'on ne sait pas reconstituer (`?MISSIS`, `600 DB <20>CHARMING<4E>`) sont laissées telles quelles plutôt qu'inventées ; le caractère de remplacement résiduel est simplement retiré de l'affichage.
|
||||
|
||||
**Ce qui NE change pas** : les 3 couches, les 7 invariants, le format des fiches, le stack. La Connaissance reste git-only, sans back-office.
|
||||
|
||||
## D-003 — 23 juillet 2026 · Mise à jour du cadrage : modèle économique réécrit (§ 10)
|
||||
|
||||
**Contexte** : nouvelle version du document de cadrage reçue, avec le § 10 (Modèle économique) entièrement réécrit suite à un audit de scénarios. Remplace le modèle mensuel 4-5 €/mois construit en Phase 0/MVP jusqu'ici.
|
||||
|
|
@ -27,7 +135,7 @@ Le [cadrage](cadrage-projet-fokan.md) reste le document de référence ; ce jour
|
|||
|
||||
**Décision d'implémentation : reportée.** Vu l'ampleur (retrait d'une page qui vient d'être construite et mise en avant, changement de prix affiché partout, refonte du quiz véhicule, ajout d'un mur de paiement Stripe + case légale, ajout du statut d'abonnement en base), aucun code n'est modifié dans l'immédiat — cadrage mis à jour et écarts documentés d'abord, arbitrages à valider avec l'utilisateur avant de toucher au produit.
|
||||
|
||||
## D-002 — 22 juillet 2026 · Véhicules : API plaque payante self-service (annule D-001)
|
||||
## ~~D-002 — 22 juillet 2026 · Véhicules : API plaque payante self-service~~ (annulée le 25/07/2026 par D-004)
|
||||
|
||||
**Contexte** : l'intention initiale (D-001 puis « API gratuite ») s'est heurtée à un fait juridique vérifié : la réutilisation des données SIV exige une **licence du ministère de l'Intérieur avec redevance** — « gratuit + zéro saisie » est impossible en France (la seule voie gratuite, HistoVec, exige de recopier nom + numéro de formule de la carte grise : plus de saisie, pas moins).
|
||||
|
||||
|
|
|
|||
356
docs/pilier-maison.md
Normal file
356
docs/pilier-maison.md
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
# Pilier Maison — sources adressables et plan d'implémentation
|
||||
|
||||
Document de référence du pilier maison : ce qu'une adresse française permet de savoir gratuitement, ce qu'on en fait, et dans quel ordre. Le [cadrage](cadrage-projet-fokan.md) reste la source de vérité produit ; ce document le décline sur un pilier.
|
||||
|
||||
**Statut des tests** : toutes les API marquées ✅ ont été appelées réellement le **25 juillet 2026** et ont renvoyé une réponse exploitable, sans clé ni token. Les requêtes exactes sont reproduites pour ne pas avoir à les rechercher.
|
||||
|
||||
Adresse d'essai : `5 rue Lafayette, 31000 Toulouse` → `lat 43.604992`, `lon 1.444281`, `citycode 31555`, `banId 31555_4920_00005`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Les trois niveaux de vérité
|
||||
|
||||
Règle fondatrice du pilier, posée avant toute implémentation. Elle découle de `§ 4.2` du cadrage (inférence > saisie) **et** de sa limite : un propriétaire fait des travaux sans refaire son DPE, un diagnostic de 2019 décrit un logement qui n'existe plus.
|
||||
|
||||
| Niveau | Origine | Droits |
|
||||
|---|---|---|
|
||||
| **déclaré** | le quiz, l'utilisateur | crée des échéances |
|
||||
| **confirmé** | l'utilisateur a validé une proposition | crée des échéances |
|
||||
| **dérivé** | DPE, Géorisques, Hub'Eau… | **peut seulement poser une question** |
|
||||
|
||||
Résolution : `déclaré > confirmé > dérivé`.
|
||||
|
||||
**Invariant nouveau : une valeur dérivée ne produit jamais une échéance à elle seule.** Elle produit une question modulaire, ou rien.
|
||||
|
||||
Stockage, sans nouvelle table :
|
||||
|
||||
```
|
||||
assets.attributes = {
|
||||
...déclaré, // le quiz, inchangé
|
||||
contexte: { … }, // dérivé + horodaté, JAMAIS lu par applicabilite.when
|
||||
contexte_confirme: { … } // validé par l'utilisateur, lu par le moteur
|
||||
}
|
||||
assets.provenance = "detection_adresse" // valeur déjà prévue dans src/db/schema.ts
|
||||
```
|
||||
|
||||
Conséquence : `src/engine/applicability.ts` et `src/engine/reconcile.ts` restent inchangés — le DSL ne lit que du déclaré et du confirmé.
|
||||
|
||||
### Les questions modulaires
|
||||
|
||||
Une seule à la fois, jamais bloquante, « je ne sais pas » toujours de première classe.
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
| `type_batiment = maison` **et** 1 seul DPE **et** `statut_geocodage = "adresse géocodée ban à l'adresse"` | proposition pré-cochée : *« Le diagnostic de ta maison date du 30/07/2024 : chauffage électrique, VMC simple flux. Toujours d'actualité ? »* → `[oui] [j'ai changé le chauffage] [j'ai isolé] [je ne sais pas]` |
|
||||
| appartement, **ou** ≥ 2 DPE au même `identifiant_ban` | **on ne propose rien.** Au mieux : *« Plusieurs diagnostics à cette adresse — le tien, c'est lequel ? »* (surface + étage), avec un « passer » visible |
|
||||
| aucun résultat | silence, le quiz continue |
|
||||
| DPE de plus de 2 ans | une question de fraîcheur : *« Des travaux d'isolation ou de chauffage depuis ? »* |
|
||||
|
||||
**Preuve par l'exemple** : sur l'adresse d'essai, la jointure exacte renvoie **2 DPE** — un appartement de 24,5 m² étage 4 étiqueté **F**, et un de 42,3 m² étiqueté **D**. Proposer une valeur ici serait faux une fois sur deux.
|
||||
|
||||
### Le test du geste (risques)
|
||||
|
||||
Un risque a le droit d'être remonté — au même titre qu'un rappel constructeur : échéance dormante, personne ne surveille, forte valeur quand ça tombe. La règle n'est pas « pas de risques », c'est :
|
||||
|
||||
> **Un risque n'entre dans le hub que s'il produit un geste concret et réalisable.** S'il n'y a rien à faire, il n'y a rien à dire.
|
||||
|
||||
| Passe le test | Ne passe pas |
|
||||
|---|---|
|
||||
| Zone inondable → surélever le tableau électrique, batardeau, ne pas stocker en sous-sol, connaître le point de coupure | « Votre commune est en zone sismique 1 » |
|
||||
| Argile → photographier et dater les fissures (c'est la preuve qui manque à l'expertise CatNat) | Un score de risque global |
|
||||
| Radon zone 3 → kit de dépistage 30 €, à poser pendant la saison de chauffe | Une carte des aléas à consulter |
|
||||
| Nucléaire < 20 km → pastilles d'iode gratuites en pharmacie | Un compteur « 4 risques sur votre commune » |
|
||||
|
||||
Et les interdits du cadrage tiennent : pas de dashboard à tenir, pas de rouge, pas de score, pas de comptabilité morale. Le ton reste déculpabilisant — on informe une fois, on propose un geste, on se tait.
|
||||
|
||||
---
|
||||
|
||||
## 2. Catalogue des sources
|
||||
|
||||
### 2.1 Socle d'identité
|
||||
|
||||
| Source | Appel | État |
|
||||
|---|---|---|
|
||||
| **BAN** — géocodage, `citycode`, `banId`, lat/lon | `GET https://api-adresse.data.gouv.fr/search/?q=<adresse>&type=housenumber&limit=5` | ✅ testé — déjà utilisé par `src/adapters/adresse.ts` |
|
||||
|
||||
Le champ `properties.id` (ex. `31555_4920_00005`) est la clé de jointure exacte avec le DPE. **Il est aujourd'hui jeté** — voir Phase 0.b.
|
||||
|
||||
### 2.2 Bâtiment et performance énergétique
|
||||
|
||||
| Source | Appel | État |
|
||||
|---|---|---|
|
||||
| **Observatoire DPE ADEME** — logements existants (9,77 M lignes) | `GET https://data.ademe.fr/data-fair/api/v1/datasets/dpe03existant/lines?qs=identifiant_ban:"<banId>"&size=5&sort=-date_etablissement_dpe&select=<champs>` | ✅ testé — jointure exacte fonctionnelle |
|
||||
| **Observatoire DPE ADEME** — logements neufs | `.../datasets/dpe02neuf/lines` | ✅ testé (HTTP 200), non exploité |
|
||||
| **BDNB** (CSTB) — données au bâtiment | `GET https://api.bdnb.io/v1/bdnb/donnees/batiment_groupe_complet?code_commune_insee=eq.<insee>&limit=1` | ✅ testé — licence à vérifier avant usage |
|
||||
| **RNB** — référentiel national des bâtiments | `GET https://rnb-api.beta.gouv.fr/api/alpha/buildings/?insee_code=<insee>` | ✅ testé — exige `bbox`, `insee_code` ou `cle_interop_ban` |
|
||||
|
||||
⚠️ **Le dataset utilisé aujourd'hui dans `src/adapters/adresse.ts` est mort** : `dpe-v2-logements-existants` renvoie **HTTP 404 « Dataset not found »**. `suggestLogement()` retourne donc `null` à tous les coups, avalé par le `catch`. Identifiant correct : `dpe03existant`.
|
||||
|
||||
**Champs utiles retournés** (tous vérifiés présents) :
|
||||
|
||||
```
|
||||
etiquette_dpe · etiquette_ges · periode_construction · annee_construction (souvent null)
|
||||
type_batiment · surface_habitable_logement · hauteur_sous_plafond · nombre_niveau_logement
|
||||
type_energie_principale_chauffage · type_generateur_chauffage_principal · type_installation_chauffage
|
||||
type_ventilation · type_energie_principale_ecs · zone_climatique (H1a…H3) · classe_altitude
|
||||
cout_total_5_usages · date_etablissement_dpe · date_fin_validite_dpe
|
||||
identifiant_ban · statut_geocodage · score_ban · complement_adresse_logement · id_rnb
|
||||
```
|
||||
|
||||
⚠️ `annee_construction` est très souvent `null` ; c'est `periode_construction` (tranche : « avant 1948 ») qui est renseignée. **Ne pas la convertir en nombre** — ce serait inventer une donnée. `QuizAnswers.logement.annee_construction` reste un `number` déclaré ; la tranche va dans `contexte`.
|
||||
|
||||
### 2.3 Risques (Géorisques — API v1, sans token)
|
||||
|
||||
| Endpoint | Appel | État |
|
||||
|---|---|---|
|
||||
| **Rapport de risques à l'adresse** | `GET https://georisques.gouv.fr/api/v1/resultats_rapport_risque?latlon=<lon>,<lat>&rayon=1000` | ✅ testé — 13 risques naturels + 6 technologiques en un appel |
|
||||
| **Débroussaillement (OLD)** | `GET .../api/v1/old?latlon=<lon>,<lat>` | ✅ testé — 404 propre hors zone |
|
||||
| **Arrêtés CatNat** | `GET .../api/v1/gaspar/catnat?code_insee=<insee>&page_size=500` | ✅ testé — données fraîches (dernier arrêté vu : 23/01/2026) |
|
||||
| Argile (RGA) à l'adresse | `GET .../api/v1/rga?latlon=<lon>,<lat>` | ✅ testé — `{"codeExposition":"2","exposition":"Exposition moyenne"}` |
|
||||
| Radon | `GET .../api/v1/radon?code_insee=<insee>` | ✅ testé — `classe_potentiel` 1/2/3 |
|
||||
| Zonage sismique | `GET .../api/v1/zonage_sismique?code_insee=<insee>` | ✅ testé |
|
||||
| Risques de la commune (GASPAR) | `GET .../api/v1/gaspar/risques?code_insee=<insee>` | ✅ testé |
|
||||
| ICPE | `GET .../api/v1/installations_classees?code_insee=<insee>&rayon=1000` | ✅ testé |
|
||||
|
||||
**Attention à l'ordre des coordonnées** : `latlon=` attend en réalité `<longitude>,<latitude>`.
|
||||
|
||||
`resultats_rapport_risque` distingue, pour chaque risque, `libelleStatutCommune` et `libelleStatutAdresse` — c'est cette distinction qui rend l'info actionnable plutôt qu'anxiogène. Exemple réel sur l'adresse d'essai : inondation « Risque Existant » sur la commune mais « Risque non Connu » à l'adresse, alors que la remontée de nappe est « Risque Existant » **à l'adresse**.
|
||||
|
||||
Risques couverts : `inondation · remonteeNappe · risqueCotier · seisme · mouvementTerrain · reculTraitCote · retraitGonflementArgile · avalanche · feuForet · eruptionVolcanique · cyclone · radon` / `icpe · nucleaire · canalisationsMatieresDangereuses · pollutionSols · ruptureBarrage · risqueMinier`.
|
||||
|
||||
Réponse de `old` (testée sur Cassis et Bormes-les-Mimosas) : commune, coordonnées, **URL de l'arrêté préfectoral**, date d'approbation, et le flag `zoneUrbaine` qui détermine l'étendue de l'obligation.
|
||||
|
||||
⚠️ Les API v2 exigent un jeton (compte Cerbère ou FranceConnect, valable 1 an) ; la v1 reste libre mais l'État annonce une évolution. Quotas et SLA non documentés — à surveiller.
|
||||
|
||||
### 2.4 Eau
|
||||
|
||||
| Source | Appel | État |
|
||||
|---|---|---|
|
||||
| **VigiEau** — restrictions en vigueur | `GET https://api.vigieau.beta.gouv.fr/api/zones?lon=<lon>&lat=<lat>&commune=<insee>&profil=particulier` | ✅ testé — niveau, arrêté (dates + PDF), usages restreints nominatifs |
|
||||
| **Hub'Eau** — dureté de l'eau | `GET https://hubeau.eaufrance.fr/api/v1/qualite_eau_potable/resultats_dis?code_commune=<insee>&code_parametre_se=TH&size=1&sort=desc` | ✅ testé — `7.3 °f`, prélèvement du 26/05/2026 |
|
||||
| **Hub'Eau** — conformité, nitrates, pesticides | même endpoint sans filtre `code_parametre_se` | ✅ testé — `conclusion_conformite_prelevement`, `nom_distributeur` |
|
||||
|
||||
VigiEau renvoie `niveauGravite` (`vigilance` / `alerte` / `crise`), l'arrêté avec `dateDebutValidite` / `dateFinValidite` et son PDF, et la liste des usages avec `concerneParticulier`. Testé : zone Garonne aval, niveau `alerte`, arrêté valable jusqu'au 31/10/2026.
|
||||
|
||||
### 2.5 Urbanisme, énergie, services
|
||||
|
||||
| Source | Appel | État |
|
||||
|---|---|---|
|
||||
| **API Carto / GPU** — servitudes d'utilité publique | `GET https://apicarto.ign.fr/api/gpu/assiette-sup-s?geom={"type":"Point","coordinates":[<lon>,<lat>]}` | ✅ testé — 63 servitudes, dont `ac1` « Périmètre des abords » (rayon 500 m, monument nommé) |
|
||||
| **API Carto / GPU** — zonage PLU | `GET https://apicarto.ign.fr/api/gpu/zone-urba?geom=<point>` | ✅ testé |
|
||||
| **API Carto / GPU** — prescriptions | `GET https://apicarto.ign.fr/api/gpu/prescription-surf?geom=<point>` | ✅ testé (0 résultat sur l'adresse d'essai) |
|
||||
| **France Chaleur Urbaine** — éligibilité réseau de chaleur | `GET https://france-chaleur-urbaine.beta.gouv.fr/api/v1/eligibility?lat=<lat>&lon=<lon>` | ✅ testé — distance, gestionnaire, réseau futur, `inPDP` |
|
||||
| **Annuaire de l'administration** — mairie, ARS, DDT | `GET https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=code_insee_commune="<insee>" and pivot like "mairie"` | ✅ testé — horaires, site, SIREN |
|
||||
| **Annuaire RGE ADEME** | `GET https://data.ademe.fr/data-fair/api/v1/datasets/liste-des-entreprises-rge-2/lines?q=<cp>` | ✅ testé — entreprise, domaine, `lien_date_fin` de qualification, géopoint |
|
||||
|
||||
### 2.6 Jeux de données (pas d'API dédiée)
|
||||
|
||||
| Donnée | Où | État |
|
||||
|---|---|---|
|
||||
| Zonage ABC (tension du marché) | data.gouv — « Liste des communes selon le zonage ABC » | ✅ jeu national identifié, non intégré |
|
||||
| Communes « recul du trait de côte » | data.gouv — liste nationale | ✅ jeu national identifié |
|
||||
| Base nationale consolidée des ZFE | data.gouv | ✅ identifié — **à ne pas graver** (statut politique instable) |
|
||||
| Potentiel radon par commune (IRSN) | data.gouv | ✅ identifié — redondant avec l'API Géorisques |
|
||||
| Fermeture du cuivre par commune | fichier de trajectoire Orange + moteur de recherche economie.gouv.fr | ❌ non testé — pas d'API propre |
|
||||
| Plans d'exposition au bruit (PEB/PGS) | **départemental uniquement**, pas de jeu national | ❌ non testé |
|
||||
| Arrêtés termites | **départemental uniquement** | ❌ non testé |
|
||||
| `quefairedemesdechets` (ADEME) | `https://quefairedemesdechets.ademe.fr/api/lieux?latitude=&longitude=` | ⚠️ testé — réponse vide, endpoint à confirmer |
|
||||
|
||||
### 2.7 Ce qui n'existe pas au national
|
||||
|
||||
Jours de collecte des déchets · horaires de bricolage et bruit de voisinage (arrêtés préfectoraux) · communes à permis de louer · communes à ravalement obligatoire · barèmes d'encadrement des loyers. Se traitent par un lien vers la mairie via l'annuaire de l'administration — c'est du contenu, pas de la personnalisation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 0 — « zéro nouvelle source »
|
||||
|
||||
**Valeur maximale, coût minimal.** Rien à intégrer : on répare ce qui est cassé, on cesse de jeter ce qu'on possède, on ajoute des fiches purement calendaires.
|
||||
|
||||
### 0.a — Réparer et durcir l'adapter existant
|
||||
|
||||
`src/adapters/adresse.ts` :
|
||||
|
||||
- `dpe-v2-logements-existants` → **`dpe03existant`** (le premier est 404).
|
||||
- Passer de `?q=<texte libre>` à **`?qs=identifiant_ban:"<banId>"`**, tri `-date_etablissement_dpe`, `size=5` (pour détecter le cas multi-DPE).
|
||||
- Élargir le `select` aux champs listés en 2.2.
|
||||
- Calculer et renvoyer un **niveau de confiance** (règles du § 1).
|
||||
- Journaliser les échecs : le `catch {}` silencieux actuel a masqué une panne de source complète.
|
||||
|
||||
| Source | État du test |
|
||||
|---|---|
|
||||
| ADEME `dpe03existant` + jointure `identifiant_ban` | ✅ testé — 2 DPE retournés sur l'adresse d'essai |
|
||||
|
||||
### 0.b — Cesser de jeter les identifiants de l'adresse
|
||||
|
||||
`QuizAnswers.adresse` conserve `{ label, departement, loi_montagne }`. Le `citycode` est calculé puis perdu, la position n'est jamais lue. Ajouter **`citycode`, `banId`, `lat`, `lon`**.
|
||||
|
||||
> Même leçon que le champ K de la carte grise, déjà écrite dans `src/engine/quiz-to-assets.ts` : *« la perdre obligerait à la redemander pour tout enrichissement ultérieur »*. Ici c'est pire — on ne peut pas la redemander, il faudrait refaire géocoder.
|
||||
|
||||
**C'est la seule dette bloquante du pilier** : sans elle, aucune phase suivante n'est réalisable.
|
||||
|
||||
### 0.c — Correctif de justesse, coût nul
|
||||
|
||||
`revision-loyer-irl` pousse aujourd'hui un bailleur vers une révision **illégale** si le logement est classé F ou G : le gel est total depuis le 24 août 2022 (ni IRL, ni renouvellement, ni relocation), sauf sortie du statut de passoire prouvée par un nouveau DPE. Ajouter la condition d'exclusion + une ligne dans la page.
|
||||
|
||||
### 0.d — Fiches nouvelles, aucune source externe
|
||||
|
||||
| Fiche | Déclencheur | Enjeu | Source réglementaire |
|
||||
|---|---|---|---|
|
||||
| `dpe-fin-validite` | `date_fin_validite_dpe` (donnée exacte) | `administratif` | DPE valable 10 ans ; ceux d'avant juillet 2021 sont caducs |
|
||||
| `decence-energetique` | DPE **confirmé** F/G + statut propriétaire | `legal` | G interdit depuis le 01/01/2025, F au 01/01/2028, E au 01/01/2034 |
|
||||
| `cheque-energie-guichet` | foyer | `argent` | Guichet 1ᵉʳ avril → 31 décembre pour les non-identifiés automatiquement |
|
||||
| `spanc-controle-periodique` | `equipements contains fosse_septique` | `legal` | Arrêté du 27 avril 2012 — périodicité **≤ 10 ans** ; rapport < 3 ans exigé à la vente |
|
||||
| `declaration-fonciere-90j` | travaux / piscine / abri déclarés | `legal+argent` | Déclaration GMBI dans les **90 jours** après achèvement + taxe d'aménagement |
|
||||
| `carnet-information-logement` | travaux d'isolation, chauffage ou ENR | `legal` | Décret n° 2022-1674 — obligatoire depuis le 01/01/2023, remis à l'acquéreur |
|
||||
|
||||
**Effort** : ~3-4 jours.
|
||||
**Test prioritaire** : la résolution `déclaré > confirmé > dérivé`, pure et sans DB — c'est le nouvel invariant, il mérite le traitement de `reconcile.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 1 — Géorisques : un adapter, tout le rapport
|
||||
|
||||
**Valeur très forte, coût d'un adapter et deux endpoints, sans clé.**
|
||||
|
||||
Nouvel adapter `src/adapters/georisques.ts`, même contrat que `adresse.ts` : timeout court, dégradation douce, jamais bloquant. **Appelé après la création du foyer, en tâche pg-boss** — jamais dans le chemin du quiz, l'onboarding reste sous 2 minutes quoi qu'il arrive.
|
||||
|
||||
| Fiche | Condition | Enjeu | Source API | État du test |
|
||||
|---|---|---|---|---|
|
||||
| `debroussaillement-old` | `old` renvoie une zone | `legal` — jusqu'à **50 €/m²** non débroussaillé, contrôles satellite, saisonnier (avant l'été) | `/api/v1/old?latlon=` | ✅ testé (Cassis, Bormes-les-Mimosas) |
|
||||
| `radon-depistage` | `classe_potentiel = 3` | `securite` — kit ~30 €, mesure **pendant la saison de chauffe** (oct-avril), seuil 300 Bq/m³ | `/api/v1/radon` ou champ `radon` du rapport | ✅ testé |
|
||||
| `argile-fissures` | exposition moyenne ou forte | `argent` — photographier et **dater** les fissures : c'est la preuve qui manque à l'expertise CatNat | `/api/v1/rga?latlon=` | ✅ testé |
|
||||
| `iode-pastilles` | flag `nucleaire` présent | `securite` — **gratuit en pharmacie**, campagne 10-20 km annoncée pour 2026, 1 063 communes | champ `nucleaire` du rapport | ✅ testé (absent sur l'adresse d'essai, structure vérifiée) |
|
||||
| `inondation-gestes` | `inondation` ou `remonteeNappe` « Existant » **à l'adresse** | `securite` — surélever le tableau électrique, batardeau, ne rien stocker en sous-sol, connaître le point de coupure | champs du rapport | ✅ testé (remontée de nappe « Existant à l'adresse ») |
|
||||
| `pprt-travaux-prescrits` | risque technologique présent | `argent` — travaux **financés à 90 %**, plafonnés à 20 000 € ou 10 % de la valeur du bien | champs `icpe` / rapport | ✅ testé |
|
||||
|
||||
**Formulation obligatoire pour `pprt`** : le rapport donne le risque, **pas** le périmètre de prescription. La fiche dit « vérifie si ton logement est en zone de prescription » avec le lien préfecture — jamais une affirmation. Même réserve que celle déjà en place pour les rappels constructeur (`src/db/schema.ts` : *« on ne peut jamais affirmer que ta voiture est rappelée »*).
|
||||
|
||||
**Effort** : ~3-4 jours.
|
||||
**À trancher avant** : ouvrir ou non un compte Cerbère pour ne pas dépendre d'une v1 dont l'évolution est annoncée.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2 — La veille événementielle
|
||||
|
||||
**Le meilleur cas d'usage de l'audit, et la seule évolution structurelle du moteur.** Jusqu'ici le cron recalcule ; ici il **observe le monde extérieur**.
|
||||
|
||||
### 5.a — Alerte CatNat
|
||||
|
||||
Un sinistre n'est indemnisable qu'après un arrêté interministériel, et l'assuré a **30 jours à compter de la publication au JO** pour déclarer. Personne ne surveille le JO.
|
||||
|
||||
| Source | État |
|
||||
|---|---|
|
||||
| `GET https://georisques.gouv.fr/api/v1/gaspar/catnat?code_insee=<insee>&page_size=500` | ✅ testé — champs `code_national_catnat`, `libelle_risque_jo`, `date_publication_jo`, `date_debut_evt` ; données fraîches (23/01/2026) |
|
||||
|
||||
### 5.b — Restrictions d'eau
|
||||
|
||||
| Source | État |
|
||||
|---|---|
|
||||
| `GET https://api.vigieau.beta.gouv.fr/api/zones?lon=&lat=&commune=&profil=particulier` | ✅ testé — `niveauGravite`, arrêté daté + PDF, usages `concerneParticulier` |
|
||||
|
||||
Impact direct sur `piscine-remise-en-route` (remplissage interdit) et sur l'arrosage. La `dateFinValidite` de l'arrêté donne une fenêtre naturelle.
|
||||
|
||||
### 5.c — L'évolution du moteur
|
||||
|
||||
`src/engine/reconcile.ts` clé aujourd'hui sur `templateId::assetId`. Deux arrêtés CatNat successifs sur la même commune s'écraseraient. Il faut étendre :
|
||||
|
||||
```
|
||||
key(templateId, assetId, subject) // subject = "" par défaut
|
||||
```
|
||||
|
||||
Rétrocompatible par construction (`subject` vide = comportement actuel) ; pour CatNat, `subject = code_national_catnat`. **C'est la seule modification de `reconcile.ts` de tout le plan** — tests d'idempotence et d'inviolabilité de l'état utilisateur à rejouer intégralement.
|
||||
|
||||
### 5.d — Stockage et cron
|
||||
|
||||
Nouvelle table de référence `arretes_commune`, sur le modèle exact de `rappels` / `vehicules_reference` : donnée externe rafraîchie par cron, **pas** la couche Connaissance (aucun éditorial, aucun ID stable à vie), avec `syncedAt` et purge. Indexée par code INSEE → un appel sert tous les foyers d'une commune, et zéro donnée personnelle.
|
||||
|
||||
Nouvelle file pg-boss `veille-arretes-quotidienne`, planifiée **avant** la réconciliation de 4 h — même logique que le décalage du référentiel RDW au dimanche 0h30 (`src/instrumentation.ts`).
|
||||
|
||||
### 5.e — Garde-fou notifications
|
||||
|
||||
C'est la phase qui peut rendre le pilier bavard. Budget strict, regroupement, priorité légal > confort. Une reconnaissance CatNat justifie un envoi immédiat hors budget ; une restriction d'eau de niveau `vigilance`, non.
|
||||
|
||||
**Effort** : ~5-7 jours, dont la moitié sur le moteur et ses tests.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 3 — Personnalisation fine
|
||||
|
||||
**Bonne valeur, mais améliore l'existant plus qu'elle n'ajoute. Sources simples.**
|
||||
|
||||
| Item | Ce que ça change | Source API | État du test |
|
||||
|---|---|---|---|
|
||||
| **Dureté de l'eau** | `adoucisseur-entretien` et `chauffe-eau-detartrage` tournent sur une fréquence par défaut. À 7 °f l'adoucisseur est inutile et le détartrage s'espace ; au-delà de 25 °f il se resserre | Hub'Eau `code_parametre_se=TH` | ✅ testé — 7,3 °f |
|
||||
| **Zone climatique** | Cale les saisons du moteur : mise en chauffe, hivernage piscine, fenêtre ramonage. Amélioration silencieuse, zéro contenu | champ `zone_climatique` du DPE (déjà récupéré en Phase 0) | ✅ testé — `H2c` |
|
||||
| **Fermeture du cuivre** | ADSL et ligne fixe coupés par lots : lot 3 janvier 2027, lot 4 en 2028, lot 5 en 2029. Emporte téléalarme et alarme reliée | fichier de trajectoire Orange / moteur economie.gouv.fr | ❌ non testé — import périodique, pas d'API |
|
||||
| **Réseau de chaleur** | Se greffe sur `chaudiere-gaz-entretien` / `chaudiere-fioul-entretien` au moment du remplacement — pas une fiche autonome | France Chaleur Urbaine `/api/v1/eligibility` | ✅ testé |
|
||||
| **Annuaire RGE** | Alimente les `slot_affiliation` existants avec des entreprises réellement qualifiées à proximité | ADEME `liste-des-entreprises-rge-2` | ✅ testé |
|
||||
| **MaPrimeRénov'** | Rendez-vous France Rénov' désormais obligatoire avant dépôt pour la rénovation d'ampleur, réservée aux E/F/G | aucune (calendaire + DPE Phase 0) | — |
|
||||
|
||||
**Effort** : ~4-5 jours.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 4 — ROI dégressif
|
||||
|
||||
Valeur réelle mais effort disproportionné, ou donnée absente au national.
|
||||
|
||||
| Item | Blocage |
|
||||
|---|---|
|
||||
| **Aide à l'insonorisation (PGS, 11 aéroports)** | Très forte valeur individuelle, taux de recours dérisoire — mais les PEB/PGS ne sont publiés qu'au **niveau départemental**. Il faudrait agréger 11 périmètres à la main |
|
||||
| **Servitudes ABF / abords de monument historique** | Testé et disponible (`assiette-sup-s`, type `ac1`, rayon 500 m). Mais ce n'est pas une échéance : c'est un avertissement au moment d'un projet de travaux. À réserver au jour où la notion de « projet » existe |
|
||||
| **Moustique tigre, chenilles processionnaires, ambroisie** | Gestes simples et peu faits (83 des 96 départements colonisés au 01/01/2026), mais le département suffit — l'adresse n'apporte rien. Bon rapport contenu/effort, faible rapport personnalisation/effort |
|
||||
| **Copropriété (DPE collectif ≤ 50 lots au 01/01/2026, PPPT)** | Bloqué sur le nombre de lots, qu'on n'a pas et qu'on ne veut pas demander. Traitable seulement en fenêtre « libre » avec une question opt-in |
|
||||
| **Termites, permis de louer, ravalement, collecte des déchets, horaires de bricolage** | Aucune donnée nationale (cf. § 2.7) |
|
||||
| **ZFE** | Statut politique instable — et de toute façon pilier véhicules |
|
||||
|
||||
---
|
||||
|
||||
## 8. RGPD, transverse
|
||||
|
||||
- Stocker les **résultats dérivés** (classes, booléens, dates), jamais les payloads bruts, jamais l'historique des appels. Cohérent avec « on indexe, on n'archive pas ».
|
||||
- `banId` + `lat/lon` sont de la localisation précise : même régime que le reste du foyer, purgés avec lui.
|
||||
- Les tables de référence externes (`arretes_commune`) sont indexées par code INSEE — aucune donnée personnelle, purgeables librement, mutualisées entre foyers.
|
||||
|
||||
---
|
||||
|
||||
## 9. Références réglementaires
|
||||
|
||||
Textes à reporter dans le champ `source:` des fiches.
|
||||
|
||||
| Sujet | Texte / source officielle |
|
||||
|---|---|
|
||||
| Débroussaillement (OLD) | Code forestier art. L131-10 à L131-16-1 ; loi du 10 juillet 2023 ; information acquéreur/locataire obligatoire depuis le 01/01/2025 ; [Service-Public F33298](https://www.service-public.gouv.fr/particuliers/vosdroits/F33298) |
|
||||
| Décence énergétique | Loi Climat & Résilience ; décret n° 2023-796 ; G au 01/01/2025, F au 01/01/2028, E au 01/01/2034 ; [ecologie.gouv.fr](https://www.ecologie.gouv.fr/interdiction-location-et-gel-des-loyers-des-passoires-energetiques) |
|
||||
| Gel des loyers F/G | Depuis le 24/08/2022 — ni révision IRL, ni renouvellement, ni relocation ; [ecologie.gouv.fr](https://www.ecologie.gouv.fr/politiques-publiques/location-gel-loyers-passoires-energetiques) |
|
||||
| CatNat — délai de déclaration | 30 jours après publication de l'arrêté au JO ; [Service-Public F3076](https://www.service-public.gouv.fr/particuliers/vosdroits/F3076) ; [economie.gouv.fr](https://www.economie.gouv.fr/particuliers/catastrophe-naturelle-fonctionnent-indemnisations) |
|
||||
| Retrait-gonflement des argiles | Décret n° 2019-495 ; arrêté du 22 juillet 2020 ; **carte actualisée par arrêté du 9 janvier 2026** (55 % du territoire en zone moyenne ou forte) ; [ecologie.gouv.fr](https://www.ecologie.gouv.fr/politiques-publiques/retrait-gonflement-argiles-construction) |
|
||||
| Radon | Zone 3 = potentiel significatif, 12,2 M d'habitants, 72 départements ; niveau de référence 300 Bq/m³ ; obligation d'information (IAL) ; [ASNR/IRSN](https://recherche-expertise.asnr.fr/savoir-comprendre/environnement/connaitre-potentiel-radon-ma-commune) |
|
||||
| Pastilles d'iode | Extension du PPI de 10 à 20 km autour des 19 centrales, 1 063 communes, 2,2 M de personnes ; campagne 10-20 km prévue 2026 ; [ASNR](https://reglementation-controle.asnr.fr/autres-activites/situations-d-urgence/la-distribution-d-iode) |
|
||||
| PPRT — travaux prescrits | Loi du 16 juillet 2013 ; aide jusqu'à 90 % (crédit d'impôt + 25 % industriels + 25 % collectivités) ; plafond 20 000 € ou 10 % de la valeur ; [ecologie.gouv.fr](https://www.ecologie.gouv.fr/politiques-publiques/risques-technologiques-directive-seveso-loi-risques) |
|
||||
| Assainissement non collectif | Arrêté du 27 avril 2012 — contrôle périodique ≤ 10 ans ; rapport < 3 ans à la vente ; [portail interministériel ANC](https://www.assainissement-non-collectif.developpement-durable.gouv.fr/quelle-est-la-periodicite-des-controles-a37.html) |
|
||||
| Carnet d'information du logement | Décret n° 2022-1674 du 27 décembre 2022 ; CCH art. L126-35-2 et s. ; [Service-Public F36759](https://www.service-public.gouv.fr/particuliers/vosdroits/F36759) |
|
||||
| Déclaration foncière / taxe d'aménagement | 90 jours après achèvement, service GMBI ; [impots.gouv.fr](https://www.impots.gouv.fr/url-stable-gmbi-ecran-pivot) ; [economie.gouv.fr](https://www.economie.gouv.fr/particuliers/taxe-amenagement) |
|
||||
| Chèque énergie | Campagne 2026 : envois automatiques 1ᵉʳ-20 avril, **guichet de demande 1ᵉʳ avril → 31 décembre** ; [economie.gouv.fr](https://www.economie.gouv.fr/actualites/cheque-energie-2026-envoi-des-cheques-et-ouverture-du-guichet-de-demande-au-1er-avril) |
|
||||
| MaPrimeRénov' | Guichet rouvert le 23/02/2026 ; isolation des murs et chaudières biomasse exclues depuis le 01/01/2026 ; rénovation d'ampleur réservée E/F/G, RDV France Rénov' obligatoire ; [mode d'emploi Anah, mars 2026](https://www.anah.gouv.fr/sites/default/files/2026-03/202603-MPR-modeEmploi_WEB.pdf) |
|
||||
| DPE collectif / PPPT | DPE collectif ≤ 50 lots au 01/01/2026 ; PPPT ≤ 50 lots depuis le 01/01/2025 ; [Service-Public F37504](https://www.service-public.gouv.fr/particuliers/vosdroits/F37504) et [F36760](https://www.service-public.gouv.fr/particuliers/vosdroits/F36760) |
|
||||
| Zone tendue | Décret n° 2025-1267 du 22 décembre 2025 ; préavis 1 mois, TLV, encadrement ; [simulateur Service-Public](https://www.service-public.gouv.fr/simulateur/calcul/zones-tendues) |
|
||||
| Puits, forage, eau de pluie | CGCT art. L2224-9 ; arrêté du 17 décembre 2008 ; Cerfa 13837*03 ; télédéclaration DUPLOS depuis le 01/02/2024 ; [Service-Public F172](https://www.service-public.gouv.fr/particuliers/vosdroits/F172) |
|
||||
| Permis de louer | CCH art. L635-1 et s. ; amende jusqu'à 5 000 €, 15 000 € en récidive |
|
||||
| Ravalement | CCH art. L132-1 à L132-5 — au moins tous les 10 ans **sur injonction municipale**, dans les communes couvertes par arrêté ; [Service-Public F640](https://www.service-public.gouv.fr/particuliers/vosdroits/F640) |
|
||||
| Termites | Déclaration en mairie sous 1 mois ; zones délimitées par arrêté préfectoral ; [Service-Public F285](https://www.service-public.gouv.fr/particuliers/vosdroits/F285) |
|
||||
| Insonorisation aéroports | Dispositif PGS, 11 aéroports ; plafonds relevés par arrêté publié au JO du 05/01/2024 ; [ecologie.gouv.fr](https://www.ecologie.gouv.fr/politiques-publiques/aide-linsonorisation-logements-autour-aeroports) |
|
||||
| Fermeture du cuivre | Fermeture technique du lot 2 le 27/01/2026 ; lot 3 janvier 2027 (2 097 communes), lot 4 en 2028 (6 843), lot 5 en 2029 (10 488) ; [Arcep](https://www.arcep.fr/nos-sujets/la-fermeture-du-reseau-cuivre.html) |
|
||||
| Moustique tigre | 83 des 96 départements métropolitains colonisés au 01/01/2026 ; actif mai-novembre ; [sante.gouv.fr](https://sante.gouv.fr/sante-et-environnement/substances-chimiques/especes-nuisibles-et-parasites/article/cartes-de-presence-du-moustique-tigre-aedes-albopictus-en-france-metropolitaine) |
|
||||
| Chenilles processionnaires, ambroisies | Décret n° 2022-686 du 25 avril 2022 — espèces dont la prolifération menace la santé humaine |
|
||||
| Biodéchets | Tri à la source généralisé depuis le 01/01/2024 (obligation portée par la collectivité) ; [ecologie.gouv.fr](https://www.ecologie.gouv.fr/biodechets) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Décisions à trancher
|
||||
|
||||
1. **Périmètre de la Phase 0** — la persistance des identifiants d'adresse (0.b) et le correctif `revision-loyer-irl` (0.c) sont à faire même si rien d'autre n'est retenu : sans 0.b, aucune phase suivante n'est possible ; 0.c corrige une information fausse en production.
|
||||
2. **Forme de la confirmation modulaire** — écran dédié dans le quiz, ou question différée dans le hub (moins intrusif, moins de réponses).
|
||||
3. **Ordre Phase 2 / Phase 3** — CatNat est la plus forte valeur de l'audit mais la seule à toucher `reconcile.ts`. Interversion possible si on veut sécuriser d'abord.
|
||||
4. **Jeton Géorisques** — ouvrir ou non un compte Cerbère maintenant, pour ne pas dépendre d'une v1 dont l'évolution est annoncée.
|
||||
5. **Licence BDNB** — à vérifier avant tout usage (les autres sources sont en Licence Ouverte / Etalab).
|
||||
29
knowledge/pages/carnet-information-logement.md
Normal file
29
knowledge/pages/carnet-information-logement.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Carnet d'information du logement (CIL) : ce qu'il faut y mettre
|
||||
|
||||
Depuis le 1er janvier 2023, un propriétaire qui réalise certains travaux doit tenir un **carnet d'information du logement**. Le nom fait penser à une usine à gaz administrative ; dans les faits, c'est un simple recueil de documents que la plupart des propriétaires réunissent déjà, sans le savoir, dans un tiroir ou un dossier numérique.
|
||||
|
||||
## Qui est concerné, et depuis quand
|
||||
|
||||
L'obligation s'applique dès qu'un propriétaire dépose une demande de permis de construire ou d'autorisation de travaux, reçoit un devis, ou réalise des travaux à partir du 1er janvier 2023, pour :
|
||||
|
||||
- l'isolation thermique (toiture, murs extérieurs, parois vitrées, planchers bas) ;
|
||||
- l'installation ou le remplacement d'un système de chauffage ou de refroidissement ;
|
||||
- l'installation d'un équipement utilisant une source d'énergie renouvelable.
|
||||
|
||||
Les logements neufs dont le permis a été déposé après cette date sont également concernés dès la construction.
|
||||
|
||||
## Ce qu'il doit contenir
|
||||
|
||||
Pour chaque poste de travaux : la nature des travaux réalisés, les matériaux et équipements posés, leurs caractéristiques techniques, et les documents remis par l'entreprise (factures, notices, attestations). Aucun format n'est imposé — papier ou numérique conviennent, du moment que c'est **à jour et transmissible**.
|
||||
|
||||
## Qui le remplit
|
||||
|
||||
C'est au **propriétaire** de l'établir et de le tenir à jour. Pour des travaux neufs, le constructeur transmet les éléments au plus tard à la réception du chantier ; pour des travaux de rénovation, c'est l'entreprise qui doit fournir les informations, à charge pour le propriétaire de les conserver.
|
||||
|
||||
## La transmission en cas de vente
|
||||
|
||||
Le carnet doit être remis à l'acquéreur **au plus tard à la signature de l'acte authentique** — l'acquéreur en atteste d'ailleurs dans l'acte lui-même. Un carnet inexistant ou incomplet ne bloque pas la vente, mais prive l'acheteur d'un historique qui lui aurait évité de refaire des diagnostics ou de deviner l'âge d'une chaudière.
|
||||
|
||||
## En pratique
|
||||
|
||||
Le réflexe le plus simple : garder chaque facture de travaux d'isolation, de chauffage ou d'ENR dans un même dossier au fur et à mesure, plutôt que d'essayer de reconstituer l'historique le jour de la vente — c'est là que la plupart des propriétaires découvrent qu'ils ont tout perdu.
|
||||
29
knowledge/pages/cheque-energie.md
Normal file
29
knowledge/pages/cheque-energie.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Chèque énergie : attribution automatique et guichet de rattrapage
|
||||
|
||||
Le chèque énergie aide les ménages modestes à payer leurs factures d'énergie ou des travaux de rénovation. Depuis 2024, son attribution est en grande partie automatisée — ce qui règle la plupart des cas, mais en oublie régulièrement un certain nombre.
|
||||
|
||||
## Comment il est attribué
|
||||
|
||||
Chaque année, l'administration croise le **revenu fiscal de référence** (année N-1) et le **point de livraison électrique** (PDL) du logement pour établir la liste des bénéficiaires. Les chèques sont envoyés automatiquement, en une ou deux vagues, généralement en avril.
|
||||
|
||||
## Pourquoi il arrive parfois « perdu »
|
||||
|
||||
Le croisement automatique **rate un ménage** dans plusieurs cas fréquents :
|
||||
|
||||
- déménagement dans l'année de référence (le PDL a changé, le rapprochement échoue) ;
|
||||
- changement de titulaire du contrat d'électricité (mariage, séparation, colocation) ;
|
||||
- logement neuf ou point de livraison récemment créé.
|
||||
|
||||
C'est exactement le type de situation qu'on oublie de vérifier, faute de mécanisme de rattrapage visible.
|
||||
|
||||
## Le guichet de demande
|
||||
|
||||
Si vous êtes éligible selon le simulateur officiel mais n'avez rien reçu, une **demande manuelle** reste possible sur chequeenergie.gouv.fr, généralement ouverte du **1er avril au 31 décembre** de l'année en cours. Passé cette date, le droit à l'année concernée est perdu — il ne se reporte pas sur l'année suivante.
|
||||
|
||||
## Ce que couvre le chèque
|
||||
|
||||
Il finance les factures d'électricité, de gaz, de fioul, de bois ou de réseau de chaleur, mais aussi certains travaux de rénovation énergétique lorsqu'il est utilisé comme avance de MaPrimeRénov'. Le montant varie selon les revenus et la composition du foyer.
|
||||
|
||||
## En pratique
|
||||
|
||||
Une vérification annuelle de deux minutes sur le simulateur officiel suffit. Si vous n'êtes structurellement pas éligible, il suffit de le dire une fois : plus besoin d'y repenser chaque année.
|
||||
33
knowledge/pages/decence-energetique.md
Normal file
33
knowledge/pages/decence-energetique.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Passoire énergétique (DPE F/G) : location interdite, loyer gelé
|
||||
|
||||
Un logement classé F ou G n'est pas seulement mal isolé sur le papier : depuis la loi Climat & Résilience de 2021, ce classement déclenche une série d'interdictions et de blocages concrets, avec un calendrier qui se resserre chaque année.
|
||||
|
||||
## Le calendrier de l'interdiction de location
|
||||
|
||||
- **Depuis le 1er janvier 2025** : les logements classés **G** ne peuvent plus être mis en location (nouveau bail, renouvellement ou tacite reconduction).
|
||||
- **À partir du 1er janvier 2028** : la classe **F** rejoint l'interdiction.
|
||||
- **À partir du 1er janvier 2034** : la classe **E** sera à son tour concernée.
|
||||
|
||||
Ces seuils s'appliquent à la **décence énergétique** du logement (Code civil, obligations du bailleur) : un logement en dessous du seuil est juridiquement considéré comme indécent, indépendamment de son état général par ailleurs.
|
||||
|
||||
## Le gel des loyers, en vigueur depuis 2022
|
||||
|
||||
Depuis le **24 août 2022**, un logement classé F ou G déjà loué a son loyer **totalement gelé** :
|
||||
|
||||
- pas de révision annuelle à l'indice IRL,
|
||||
- pas de hausse au renouvellement du bail,
|
||||
- pas de hausse à la relocation.
|
||||
|
||||
Le seul moyen de sortir de ce gel : des travaux qui font remonter le logement à la classe E ou mieux, prouvés par un **nouveau DPE**. Un DPE d'avant travaux ne suffit pas — voir la fiche sur la fin de validité du DPE.
|
||||
|
||||
## L'impact sur la revente
|
||||
|
||||
L'étiquette DPE est obligatoire dans toute annonce immobilière depuis 2022, et les acheteurs — comme les banques pour le crédit — en tiennent compte. Un F ou un G se négocie systématiquement à la baisse, et certains acquéreurs excluent d'emblée les passoires énergétiques par anticipation des travaux à venir.
|
||||
|
||||
## Les aides pour sortir de la zone F/G
|
||||
|
||||
Les logements classés E, F ou G sont les seuls éligibles à **MaPrimeRénov' rénovation d'ampleur**, le dispositif le plus généreux (financement pouvant couvrir une large part de travaux globaux), sous condition de passer par un accompagnateur agréé et d'obtenir un gain d'au moins deux classes. Un rendez-vous avec un conseiller France Rénov' est désormais obligatoire avant tout dépôt de dossier.
|
||||
|
||||
## En pratique
|
||||
|
||||
Si votre logement est F ou G, l'urgence dépend de votre situation : si vous le louez déjà, le loyer est gelé dès maintenant, quoi qu'il arrive. Si vous l'occupez vous-même, l'enjeu est plutôt la revente et l'éligibilité aux aides — mais le calendrier légal ne va que dans un sens.
|
||||
22
knowledge/pages/declaration-fonciere.md
Normal file
22
knowledge/pages/declaration-fonciere.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Déclaration foncière après travaux : le délai de 90 jours
|
||||
|
||||
Piscine, abri de jardin, véranda, garage, agrandissement : toute construction nouvelle, même sans permis de construire, doit être déclarée à l'administration fiscale. C'est une obligation distincte du permis d'urbanisme lui-même, et c'est celle qu'on oublie le plus facilement une fois les travaux terminés.
|
||||
|
||||
## Ce qui doit être déclaré
|
||||
|
||||
Sont concernés : les piscines (y compris hors-sol si elles restent installées plus de trois mois), les abris de jardin même démontables, les vérandas, les garages, les carports, et tout agrandissement de la surface de plancher ou de l'emprise au sol. Les seuils de dispense de permis (souvent 5 ou 20 m²) ne dispensent **pas** de la déclaration fiscale — ce sont deux régimes différents.
|
||||
|
||||
## Le délai : 90 jours après l'achèvement
|
||||
|
||||
La déclaration se fait en ligne, dans l'espace **« Gérer mes biens immobiliers »** sur impots.gouv.fr, dans les **90 jours suivant la fin des travaux**. Elle sert à deux choses :
|
||||
|
||||
- calculer la **taxe d'aménagement**, une taxe unique due une fois, proportionnelle à la surface créée ;
|
||||
- mettre à jour la **valeur locative** du bien, base de calcul de la taxe foncière pour les années suivantes.
|
||||
|
||||
## Ce qui arrive en cas d'oubli
|
||||
|
||||
L'administration croise régulièrement les données (photos aériennes, permis déposés, signalements) et peut redresser plusieurs années en arrière. Le rattrapage s'accompagne d'**intérêts de retard**, et parfois d'une majoration si l'omission est jugée délibérée. Une piscine visible sur une photo satellite et absente du cadastre est un cas classique de contrôle.
|
||||
|
||||
## En pratique
|
||||
|
||||
Notez la date de fin de travaux dès qu'elle est connue — c'est elle qui fait courir le délai de 90 jours, pas la date de facturation ni celle du permis. La déclaration en ligne prend une dizaine de minutes une fois les caractéristiques de l'ouvrage en main (surface, nature, date d'achèvement).
|
||||
26
knowledge/pages/dpe-fin-validite.md
Normal file
26
knowledge/pages/dpe-fin-validite.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Fin de validité du DPE : ce qui se bloque, comment renouveler
|
||||
|
||||
Le diagnostic de performance énergétique (DPE) n'est pas éternel. Passé sa date de validité, il ne s'agit pas d'un simple document périmé dans un tiroir : c'est un blocage administratif pur et simple au moment où vous en avez le moins besoin — en pleine vente ou en pleine recherche de locataire.
|
||||
|
||||
## Combien de temps un DPE est-il valable ?
|
||||
|
||||
**10 ans**, depuis la réforme du 1er juillet 2021. Les DPE établis avant cette date suivent un régime transitoire :
|
||||
|
||||
- DPE réalisés entre 2013 et 2017 : caducs depuis le **31 décembre 2022**.
|
||||
- DPE réalisés entre 2018 et le 30 juin 2021 : caducs depuis le **31 décembre 2024**.
|
||||
|
||||
Concrètement, tout DPE antérieur à juillet 2021 est aujourd'hui déjà expiré, qu'il l'affiche ou non.
|
||||
|
||||
## Ce qu'un DPE expiré bloque
|
||||
|
||||
- **Vente** : le DPE fait partie du dossier de diagnostic technique (DDT) obligatoire annexé à la promesse de vente. Sans DPE valide, la vente ne peut pas se conclure dans les règles.
|
||||
- **Location** : même obligation pour toute nouvelle mise en location — le DPE doit figurer dans l'annonce elle-même.
|
||||
- **Rien d'autre** : tant que vous n'entreprenez ni vente ni location, un DPE expiré n'a aucune conséquence directe. Ce n'est pas une amende qui vous attend, mais un délai qui s'ajoute au mauvais moment.
|
||||
|
||||
## Comment le renouveler
|
||||
|
||||
Un diagnostiqueur certifié se déplace, mesure et applique la méthode 3CL en vigueur. Comptez **100 à 250 €** selon la surface et la région, et quelques jours de délai pour obtenir le rendez-vous puis le document. Autant l'anticiper avant d'avoir un compromis de vente à signer dans la semaine.
|
||||
|
||||
## En pratique
|
||||
|
||||
Si vous prévoyez de vendre ou de louer dans l'année, vérifiez la date d'expiration maintenant plutôt qu'au moment de signer. Un DPE encore valide mais avec une étiquette dégradée mérite aussi d'être regardé : depuis 2021, la méthode de calcul a changé et certains logements ont vu leur étiquette se dégrader mécaniquement, sans qu'aucun changement n'ait eu lieu sur place.
|
||||
|
|
@ -20,6 +20,10 @@ Concrètement : un propriétaire qui se réveille en 2026 pour appliquer la rév
|
|||
|
||||
Pour un propriétaire bailleur, l'oubli de révision est une perte sèche et définitive — sur un loyer de 800 €, une révision ratée deux ans de suite représente plusieurs centaines d'euros non récupérables. La date anniversaire du bail mérite une alarme, dans les deux camps.
|
||||
|
||||
## L'exception qui bloque tout : logement classé F ou G
|
||||
|
||||
Depuis le **24 août 2022** (loi Climat & Résilience), un logement classé **F ou G au DPE** a son loyer **entièrement gelé** : ni révision annuelle à l'IRL, ni hausse au renouvellement du bail, ni hausse à la relocation. Le calcul ci-dessus ne s'applique tout simplement pas — toute révision demandée par le bailleur sur un logement F ou G est sans effet, quel que soit le calcul. La seule sortie : des travaux qui font remonter le logement à la classe E ou mieux, prouvés par un nouveau DPE.
|
||||
|
||||
## En pratique
|
||||
|
||||
À chaque avis de révision : vérifier le trimestre de référence, refaire la multiplication avec les indices INSEE (publics et gratuits), vérifier la date de la demande. En cas d'erreur, un courrier au bailleur avec le calcul correct suffit généralement — la règle est limpide et ne se discute pas.
|
||||
|
|
|
|||
23
knowledge/pages/spanc-controle.md
Normal file
23
knowledge/pages/spanc-controle.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Contrôle SPANC : périodicité, obligations, vente du logement
|
||||
|
||||
Une fosse septique (ou tout autre dispositif d'assainissement non collectif) fait l'objet de **deux obligations distinctes**, souvent confondues : la vidange, qui évacue les boues, et le contrôle du SPANC, qui vérifie que l'installation fonctionne correctement. Ce sont deux visites différentes, par deux intervenants différents.
|
||||
|
||||
## Qui est le SPANC ?
|
||||
|
||||
Le Service Public d'Assainissement Non Collectif est géré par votre commune ou votre intercommunalité. Sa mission de contrôle est une obligation légale du service public, distincte de l'entretien qui reste à votre charge.
|
||||
|
||||
## À quelle fréquence ?
|
||||
|
||||
L'arrêté du 27 avril 2012 fixe une périodicité **au maximum tous les 10 ans**, mais chaque collectivité peut la resserrer selon le type d'installation, son état constaté au précédent contrôle, ou les risques identifiés (installations avec composants électromécaniques, par exemple). Il n'existe pas de règle nationale unique au-delà de ce plafond — seule votre collectivité peut indiquer la fréquence exacte appliquée chez vous.
|
||||
|
||||
## Ce que vérifie le contrôle
|
||||
|
||||
Le SPANC vérifie l'existence et le bon état de l'installation, l'absence de rejet direct au milieu naturel, l'accessibilité pour l'entretien, et la tenue à jour du carnet d'entretien si l'installation le prévoit. Il ne remplace pas la vidange, mais il peut la réclamer si les boues ont atteint le seuil critique.
|
||||
|
||||
## L'obligation à la vente
|
||||
|
||||
En cas de vente d'un bien non raccordé au tout-à-l'égout, le rapport de contrôle doit dater de **moins de 3 ans** au moment de la signature. S'il est plus ancien ou inexistant, c'est au **vendeur** de faire réaliser le contrôle avant la vente — une bonne surprise de dernière minute à éviter en s'y prenant à l'avance.
|
||||
|
||||
## En pratique
|
||||
|
||||
Contactez le SPANC de votre commune pour connaître la date de votre dernier contrôle et la périodicité qui vous est appliquée : c'est une information publique, gratuite à obtenir, et c'est la seule source fiable — la fréquence variant d'une collectivité à l'autre.
|
||||
|
|
@ -425,9 +425,13 @@
|
|||
source: "Service-public.gouv.fr fiche F22730 ; collectivites-locales.gouv.fr (TEOM)"
|
||||
|
||||
- id: revision-loyer-irl
|
||||
version: 2
|
||||
version: 3
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { statut: locataire } }
|
||||
# v3 (docs/pilier-maison.md § 0.c) : un logement F ou G a le loyer entièrement gelé depuis le
|
||||
# 24 août 2022 (ni IRL, ni renouvellement, ni relocation) — sans cette exclusion, la fiche
|
||||
# poussait un locataire à vérifier un calcul que la loi interdit purement et simplement.
|
||||
# dpe_etiquette absent (jamais confirmé) => not_in passe : la fiche reste affichée par défaut.
|
||||
applicabilite: { asset_type: logement, when: { statut: locataire, dpe_etiquette: { not_in: [F, G] } } }
|
||||
recurrence: { freq: yearly }
|
||||
comportement_fait: reprogrammer_cycle
|
||||
glissement: silencieux
|
||||
|
|
@ -444,4 +448,147 @@
|
|||
page: revision-loyer.md
|
||||
actions:
|
||||
- { label: "Vérifier l'indice IRL en vigueur (INSEE)" }
|
||||
source: "Loi 89-462 art. 17-1 ; loi ALUR 2014 (délai d'un an) ; IRL publié par l'INSEE"
|
||||
source: "Loi 89-462 art. 17-1 ; loi ALUR 2014 (délai d'un an) ; IRL publié par l'INSEE ; gel des loyers F/G depuis le 24/08/2022 (loi Climat & Résilience)"
|
||||
|
||||
# ── Phase 0 du plan pilier maison (docs/pilier-maison.md § 3.d) ────────────
|
||||
# Fiches ne nécessitant aucune nouvelle source externe : soit calendaires (aucune donnée
|
||||
# dérivée), soit conditionnées à un DPE confirmé par l'utilisateur (jamais la seule donnée
|
||||
# publique — cf. src/knowledge/dpe-confirmation.ts et applicability.ts pour `not_in`).
|
||||
|
||||
- id: dpe-fin-validite
|
||||
version: 1
|
||||
pilier: maison
|
||||
# dpe_fin_validite n'existe sur l'asset que si l'utilisateur a confirmé le diagnostic proposé
|
||||
# pendant le quiz — jamais depuis la seule donnée publique (§ 1, docs/pilier-maison.md).
|
||||
applicabilite: { asset_type: logement, when: { dpe_fin_validite: { exists: true } } }
|
||||
recurrence: { freq: once }
|
||||
strategy: dpe_validite
|
||||
comportement_fait: clore
|
||||
glissement: silencieux
|
||||
enjeu: { type: administratif, resume: "Le DPE est valable 10 ans ; expiré, il bloque toute vente ou mise en location tant qu'il n'est pas refait." }
|
||||
contenus:
|
||||
hub: { titre: "Fin de validité du diagnostic (DPE)", description: "À refaire avant l'échéance si tu envisages de vendre ou de louer." }
|
||||
mail:
|
||||
objet: "Le diagnostic énergétique de ton logement arrive en fin de validité"
|
||||
corps: "Le DPE que tu nous avais confirmé arrive à échéance. Tant que tu ne vends ni ne loues, rien ne t'oblige à le refaire dans l'immédiat — mais un DPE expiré bloque toute transaction : ni vente, ni nouvelle location tant qu'un diagnostic à jour n'est pas produit. Mieux vaut le savoir avant d'en avoir besoin dans l'urgence. Si tu l'as déjà refait, dis-le-moi et je recale la date."
|
||||
seo:
|
||||
slug: dpe-fin-validite-renouvellement
|
||||
titre: "Fin de validité du DPE : ce qui se bloque, comment renouveler"
|
||||
meta_description: "Le diagnostic de performance énergétique est valable 10 ans. Une fois expiré, il bloque la vente et la location. Comment vérifier sa date et le renouveler."
|
||||
page: dpe-fin-validite.md
|
||||
actions:
|
||||
- { label: "Diagnostiqueurs certifiés près de chez toi", slot_affiliation: diagnostiqueur }
|
||||
document_attendu: dpe
|
||||
reclamable_par: [acheteur_vente, locataire]
|
||||
source: "Code de la construction et de l'habitation art. L126-28 ; DPE opposable depuis le 1er juillet 2021, validité 10 ans"
|
||||
|
||||
- id: decence-energetique
|
||||
version: 1
|
||||
pilier: maison
|
||||
# Ciblé sur les propriétaires : c'est leur logement qui devient impossible à louer ou à revendre
|
||||
# dans de bonnes conditions. dpe_etiquette n'existe que confirmé par l'utilisateur (cf. ci-dessus).
|
||||
applicabilite: { asset_type: logement, when: { statut: proprietaire, dpe_etiquette: { in: [F, G] } } }
|
||||
recurrence: { freq: once }
|
||||
comportement_fait: clore
|
||||
glissement: silencieux
|
||||
enjeu: { type: legal, resume: "Un logement classé F ou G ne peut déjà plus être loué à un nouveau locataire depuis 2025, et voit son loyer entièrement gelé s'il est déjà en location." }
|
||||
contenus:
|
||||
hub: { titre: "Logement classé F ou G : ce que ça bloque déjà", description: "Location interdite pour le G, loyer gelé, revente impactée — et un calendrier qui se resserre." }
|
||||
mail:
|
||||
objet: "Ton logement est classé énergivore — ce que ça bloque concrètement"
|
||||
corps: "Un logement classé G ne peut plus être proposé à la location depuis le 1er janvier 2025 ; le F suivra au 1er janvier 2028, puis le E au 1er janvier 2034. S'il est déjà loué, le loyer est gelé depuis août 2022 : ni révision, ni hausse au renouvellement. Ça pèse aussi sur le prix de revente — l'étiquette DPE est affichée dans toute annonce. Bonne nouvelle : les travaux qui font sortir de F/G ouvrent droit à MaPrimeRénov' rénovation d'ampleur. Si un DPE plus récent existe, dis-le-moi et je réévalue."
|
||||
seo:
|
||||
slug: decence-energetique-passoire-thermique
|
||||
titre: "Passoire énergétique (DPE F/G) : location interdite, loyer gelé"
|
||||
meta_description: "Un logement classé F ou G ne peut plus être loué (G dès 2025, F en 2028, E en 2034) et voit son loyer gelé s'il est déjà en location. Ce que ça change."
|
||||
page: decence-energetique.md
|
||||
actions:
|
||||
- { label: "Simuler mes aides à la rénovation (France Rénov')" }
|
||||
source: "Loi Climat & Résilience ; décret n° 2023-796 ; interdiction de location G (01/01/2025), F (01/01/2028), E (01/01/2034) ; gel des loyers F/G depuis le 24/08/2022"
|
||||
|
||||
- id: cheque-energie-guichet
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: foyer }
|
||||
recurrence: { freq: yearly, saison: [4] }
|
||||
comportement_fait: reprogrammer_cycle
|
||||
glissement: silencieux
|
||||
enjeu: { type: argent, resume: "L'attribution automatique rate régulièrement les foyers qui ont déménagé ou changé de fournisseur d'électricité dans l'année — le guichet de rattrapage reste ouvert jusqu'à fin décembre." }
|
||||
contenus:
|
||||
hub: { titre: "Chèque énergie — vérifier son éligibilité", description: "Attribution automatique en avril ; guichet de demande ouvert jusqu'à fin décembre pour les oubliés." }
|
||||
mail:
|
||||
objet: "Le chèque énergie — deux minutes pour vérifier que tu l'as reçu"
|
||||
corps: "L'attribution automatique a lieu en avril, sur la base de ton revenu fiscal et de ton point de livraison électrique. Elle rate régulièrement les foyers ayant déménagé ou changé de contrat d'électricité dans l'année. Le simulateur officiel prend deux minutes ; si tu es éligible et que rien n'est arrivé, le guichet de demande reste ouvert jusqu'au 31 décembre. Si tu sais que tu n'y es pas éligible, dis-le-moi et je me tais l'an prochain."
|
||||
seo:
|
||||
slug: cheque-energie-guichet-demande
|
||||
titre: "Chèque énergie : attribution automatique et guichet de rattrapage"
|
||||
meta_description: "Le chèque énergie est attribué automatiquement en avril, mais rate souvent les foyers ayant déménagé. Comment vérifier son éligibilité et le demander."
|
||||
page: cheque-energie.md
|
||||
actions:
|
||||
- { label: "Simulateur officiel chèque énergie" }
|
||||
source: "chequeenergie.gouv.fr ; guichet de demande ouvert du 1er avril au 31 décembre pour les foyers non identifiés automatiquement"
|
||||
|
||||
- id: spanc-controle-periodique
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: fosse_septique } } }
|
||||
recurrence: { freq: custom, interval_years: 10 }
|
||||
comportement_fait: reprogrammer_cycle
|
||||
glissement: silencieux
|
||||
enjeu: { type: legal, resume: "Distinct de la vidange : le SPANC contrôle le bon fonctionnement de l'installation au moins une fois tous les 10 ans, et un rapport de plus de 3 ans doit être refait avant toute vente." }
|
||||
contenus:
|
||||
hub: { titre: "Contrôle SPANC de l'assainissement non collectif", description: "Périodicité fixée par ta commune, au maximum tous les 10 ans — à ne pas confondre avec la vidange." }
|
||||
mail:
|
||||
objet: "Le contrôle SPANC de ta fosse septique — pas la vidange, l'autre visite"
|
||||
corps: "En plus de la vidange, le service public d'assainissement non collectif (SPANC) de ta commune doit contrôler le bon fonctionnement de l'installation, au maximum tous les 10 ans — la fréquence exacte est fixée par la collectivité. Si tu vends, ce rapport doit dater de moins de 3 ans, sinon c'est à toi de le faire refaire avant la vente. Si un contrôle a déjà eu lieu, dis-moi la date et je recale l'échéance."
|
||||
seo:
|
||||
slug: spanc-controle-periodique-assainissement
|
||||
titre: "Contrôle SPANC : périodicité, obligations, vente du logement"
|
||||
meta_description: "Le SPANC contrôle l'assainissement non collectif au moins tous les 10 ans, distinct de la vidange. Rapport de moins de 3 ans exigé à la vente : le guide."
|
||||
page: spanc-controle.md
|
||||
document_attendu: rapport_controle_spanc
|
||||
reclamable_par: [spanc, acheteur_vente]
|
||||
source: "Arrêté du 27 avril 2012 (modalités du contrôle) ; art. L1331-11-1 du Code de la santé publique (rapport < 3 ans à la vente)"
|
||||
|
||||
- id: declaration-fonciere-90j
|
||||
version: 1
|
||||
pilier: maison
|
||||
# Piscine choisie comme déclencheur car c'est le seul équipement du quiz qui relève de la taxe
|
||||
# d'aménagement ; le contenu élargit volontairement à l'abri de jardin et à la véranda, que le
|
||||
# quiz ne capture pas (§ 3.d, docs/pilier-maison.md — rappel ponctuel, pas un suivi de travaux).
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: piscine } } }
|
||||
recurrence: { freq: once }
|
||||
comportement_fait: clore
|
||||
glissement: silencieux
|
||||
enjeu: { type: legal, resume: "Toute construction (piscine, abri de jardin, véranda) doit être déclarée aux impôts dans les 90 jours suivant son achèvement, sous peine de redressement avec pénalités." }
|
||||
contenus:
|
||||
hub: { titre: "Déclaration foncière après travaux", description: "Piscine, abri de jardin, véranda : 90 jours pour déclarer, même sans permis de construire." }
|
||||
mail:
|
||||
objet: "Ta piscine (ou tout autre agrandissement) — la déclaration aux impôts, sous 90 jours"
|
||||
corps: "Une piscine, un abri de jardin, une véranda ou tout agrandissement doivent être déclarés sur impots.gouv.fr (espace « Gérer mes biens immobiliers ») dans les 90 jours suivant la fin des travaux — même les installations démontables. C'est ce qui calcule la taxe d'aménagement et met à jour ta taxe foncière. Un oubli découvert plus tard s'accompagne de pénalités de retard. Si c'est déjà fait, dis-le-moi et je me tais."
|
||||
seo:
|
||||
slug: declaration-fonciere-90-jours
|
||||
titre: "Déclaration foncière après travaux : le délai de 90 jours"
|
||||
meta_description: "Piscine, abri de jardin, véranda : toute construction doit être déclarée aux impôts dans les 90 jours suivant son achèvement, sous peine de pénalités."
|
||||
page: declaration-fonciere.md
|
||||
source: "Service « Gérer mes biens immobiliers » (impots.gouv.fr) ; taxe d'aménagement, Code de l'urbanisme art. L331-6 et s."
|
||||
|
||||
- id: carnet-information-logement
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { statut: proprietaire } }
|
||||
recurrence: { freq: once }
|
||||
comportement_fait: clore
|
||||
glissement: silencieux
|
||||
enjeu: { type: administratif, resume: "Obligatoire depuis 2023 dès des travaux d'isolation, de chauffage ou d'énergie renouvelable — à transmettre à l'acquéreur en cas de vente, et quasiment jamais tenu." }
|
||||
contenus:
|
||||
hub: { titre: "Carnet d'information du logement", description: "Un simple recueil à jour de tes travaux d'isolation et de chauffage — à remettre si tu vends." }
|
||||
mail:
|
||||
objet: "Le carnet d'information du logement — un classeur, pas une usine à gaz"
|
||||
corps: "Depuis 2023, dès que tu fais des travaux d'isolation, changes de chauffage ou installes une source d'énergie renouvelable, tu es censé tenir un carnet d'information du logement — factures et caractéristiques des équipements posés. Format libre, papier ou numérique. Personne ne le vérifie au jour le jour, mais il doit être remis à l'acquéreur si tu vends, et il te sera bien utile à toi-même pour retrouver ce qui a été fait et quand. On indexe ce qui compte, on ne te demande jamais de tout scanner."
|
||||
seo:
|
||||
slug: carnet-information-logement-cil
|
||||
titre: "Carnet d'information du logement (CIL) : ce qu'il faut y mettre"
|
||||
meta_description: "Obligatoire depuis 2023 dès des travaux d'isolation, de chauffage ou d'ENR, le carnet d'information du logement se transmet à l'acquéreur en cas de vente."
|
||||
page: carnet-information-logement.md
|
||||
source: "Décret n° 2022-1674 du 27 décembre 2022 ; Code de la construction et de l'habitation art. L126-35-2 et s."
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@
|
|||
source: "Code de la route, art. R323-22"
|
||||
|
||||
- id: revision-constructeur
|
||||
version: 2
|
||||
version: 3
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule }
|
||||
recurrence: { freq: yearly }
|
||||
strategy: revision_constructeur
|
||||
comportement_fait: reprogrammer_cycle
|
||||
glissement: silencieux
|
||||
enjeu: { type: argent, resume: "Une révision suivie préserve la garantie constructeur, évite les pannes coûteuses et se vérifie à la revente." }
|
||||
|
|
@ -51,7 +52,7 @@
|
|||
- id: courroie-distribution
|
||||
version: 2
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule, when: { carburant: { in: [essence, diesel] } } }
|
||||
applicabilite: { asset_type: vehicule, when: { energie: { in: [essence, diesel] } } }
|
||||
recurrence: { freq: custom, interval_years: 6 }
|
||||
strategy: courroie_distribution
|
||||
comportement_fait: reprogrammer_cycle
|
||||
|
|
|
|||
666
package-lock.json
generated
666
package-lock.json
generated
|
|
@ -25,17 +25,32 @@
|
|||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/core": {
|
||||
"version": "1.6.24",
|
||||
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.24.tgz",
|
||||
|
|
@ -1539,6 +1554,38 @@
|
|||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -1546,6 +1593,17 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
|
||||
|
|
@ -2473,6 +2531,306 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.11.1",
|
||||
"@emnapi/runtime": "^1.11.1",
|
||||
"@emnapi/wasi-threads": "^1.2.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz",
|
||||
"integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss/node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -2945,8 +3303,8 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -3147,6 +3505,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
|
||||
"integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
|
@ -3274,6 +3646,13 @@
|
|||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -3315,6 +3694,16 @@
|
|||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
|
||||
|
|
@ -3349,6 +3738,267 @@
|
|||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
|
|
@ -4075,6 +4725,20 @@
|
|||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"db:push": "drizzle-kit push",
|
||||
"knowledge:check": "tsx scripts/validate-knowledge.ts",
|
||||
"reconcile:all": "tsx scripts/reconcile-all.ts",
|
||||
"vehicules:sync": "tsx scripts/sync-vehicules-rdw.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -30,12 +31,14 @@
|
|||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
|
|
|||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** Tailwind v4 s'installe entièrement via PostCSS — pas de tailwind.config.js,
|
||||
* le thème est déclaré en CSS dans src/app/globals.css (@theme). */
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
32
scripts/sync-vehicules-rdw.ts
Normal file
32
scripts/sync-vehicules-rdw.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* CLI de synchronisation du référentiel véhicules (RDW Open Data).
|
||||
* La logique vit dans src/knowledge/sync-vehicules.ts — partagée avec le cron hebdomadaire
|
||||
* (src/instrumentation.ts), pour qu'un passage manuel et un passage planifié soient identiques.
|
||||
*
|
||||
* Usage : npm run vehicules:sync [-- --limit N] [-- --categorie M1] [-- --catalogue]
|
||||
* --limit N n'importe que N réceptions (mise au point, évite un passage complet de ~2 h)
|
||||
* --categorie catégorie européenne à importer (défaut M1 = voitures particulières)
|
||||
* --catalogue ne reconstruit QUE le catalogue agrégé depuis le référentiel déjà en base,
|
||||
* sans appeler RDW (quelques secondes) — après un changement de schéma
|
||||
* du catalogue, inutile de refaire deux heures d'appels réseau.
|
||||
*/
|
||||
import { syncVehiculesRdw, reconstruireCatalogue } from "../src/knowledge/sync-vehicules";
|
||||
|
||||
function arg(nom: string): string | undefined {
|
||||
const i = process.argv.indexOf(nom);
|
||||
return i !== -1 ? process.argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
const tache = process.argv.includes("--catalogue")
|
||||
? reconstruireCatalogue().then(() => console.log("[rdw] ✓ catalogue reconstruit"))
|
||||
: syncVehiculesRdw({
|
||||
limite: arg("--limit") ? Number(arg("--limit")) : undefined,
|
||||
categorie: arg("--categorie"),
|
||||
});
|
||||
|
||||
tache
|
||||
.then(() => process.exit(0))
|
||||
.catch((e) => {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
/** CI de la base de connaissance : une fiche invalide casse le build, jamais la prod (§ 23). */
|
||||
/**
|
||||
* CI de la base de connaissance : une fiche invalide casse le build, jamais la prod (§ 23).
|
||||
*
|
||||
* Ne valide QUE la couche Connaissance (knowledge/, versionnée en git). Les référentiels
|
||||
* externes — véhicules, rappels, entretien constructeur — vivent en base et se contrôlent au
|
||||
* moment de leur synchronisation : ce script doit pouvoir tourner sans base.
|
||||
*/
|
||||
import { loadTemplates } from "../src/knowledge/loader";
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -6,15 +6,36 @@
|
|||
export type AdresseSuggestion = {
|
||||
label: string;
|
||||
citycode: string;
|
||||
/** Identifiant BAN — clé de jointure exacte vers l'observatoire DPE (docs/pilier-maison.md § 0.b). */
|
||||
banId: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
departement: string;
|
||||
loi_montagne: boolean;
|
||||
};
|
||||
|
||||
export type LogementSuggestion = {
|
||||
/**
|
||||
* "haute" = un seul DPE trouvé, sur une maison, géocodé exactement à l'adresse. En dessous,
|
||||
* on ne propose rien plutôt que de risquer un logement voisin (docs/pilier-maison.md § 1) :
|
||||
* un appartement partage son adresse avec d'autres logements, un DPE ne s'y raccroche jamais
|
||||
* de façon fiable.
|
||||
*/
|
||||
confidence: "haute" | "basse";
|
||||
nbResultats: number;
|
||||
type?: "maison" | "appartement";
|
||||
annee_construction?: number;
|
||||
/** Année exacte quand la source la fournit — jamais déduite de la tranche ci-dessous. */
|
||||
anneeConstruction?: number;
|
||||
/** Tranche de construction ("avant 1948"...), bien plus souvent renseignée que l'année exacte. */
|
||||
periodeConstruction?: string;
|
||||
chauffage?: string;
|
||||
surface?: number;
|
||||
/**
|
||||
* Un DPE peut décrire un logement qui n'existe plus (travaux d'isolation ou de chauffage
|
||||
* faits sans refaire le diagnostic) : cette donnée ne doit jamais créer d'échéance seule,
|
||||
* elle doit toujours être confirmée par l'utilisateur (cf. src/knowledge/dpe-confirmation.ts).
|
||||
*/
|
||||
dpe?: { etiquette: string; dateEtablissement: string; dateFinValidite: string };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -33,47 +54,112 @@ export async function searchAdresse(q: string): Promise<AdresseSuggestion[]> {
|
|||
`https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(q)}&type=housenumber&limit=5`,
|
||||
{ signal: AbortSignal.timeout(4000) },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
if (!res.ok) {
|
||||
console.error(`[adresse] BAN indisponible (HTTP ${res.status})`);
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
return (data.features ?? []).map((f: { properties: { label: string; citycode: string } }) => {
|
||||
return (data.features ?? []).map((f: {
|
||||
properties: { label: string; citycode: string; id: string };
|
||||
geometry: { coordinates: [number, number] };
|
||||
}) => {
|
||||
const citycode: string = f.properties.citycode ?? "";
|
||||
const departement = citycode.startsWith("97") ? citycode.slice(0, 3) : citycode.slice(0, 2);
|
||||
const [lon, lat] = f.geometry.coordinates;
|
||||
return {
|
||||
label: f.properties.label,
|
||||
citycode,
|
||||
banId: f.properties.id ?? "",
|
||||
lat,
|
||||
lon,
|
||||
departement,
|
||||
loi_montagne: DEPARTEMENTS_LOI_MONTAGNE.has(departement),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("[adresse] échec searchAdresse :", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Pré-suggestion logement via l'observatoire DPE de l'ADEME. Couverture imparfaite assumée. */
|
||||
export async function suggestLogement(adresse: string): Promise<LogementSuggestion | null> {
|
||||
type HitDpe = Record<string, unknown>;
|
||||
|
||||
/** PAC (générateur) avant électricité générique — sinon toute PAC ressortirait "électrique". */
|
||||
function inferChauffage(hit: HitDpe): string | undefined {
|
||||
const generateur = String(hit.type_generateur_chauffage_principal ?? "").toLowerCase();
|
||||
if (generateur.includes("pompe à chaleur") || generateur.includes("pompe a chaleur")) return "pac";
|
||||
const energie = String(hit.type_energie_principale_chauffage ?? "").toLowerCase();
|
||||
if (energie.includes("gaz")) return "gaz";
|
||||
if (energie.includes("fioul")) return "fioul";
|
||||
if (energie.includes("bois")) return "bois";
|
||||
if (energie.includes("électricité") || energie.includes("electricite")) return "electrique";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-suggestion logement via l'observatoire DPE de l'ADEME (dataset `dpe03existant` —
|
||||
* le précédent identifiant, `dpe-v2-logements-existants`, renvoie 404 depuis la refonte de
|
||||
* data.ademe.fr ; le pré-remplissage était donc silencieusement mort en production).
|
||||
*
|
||||
* Jointure exacte sur `identifiant_ban` quand on a le banId (§ 0.b) : une recherche plein texte
|
||||
* sur le libellé peut raccrocher le logement du voisin. Repli sur le texte libre en dernier
|
||||
* recours (banId absent), avec une confiance nécessairement plus faible.
|
||||
*/
|
||||
export async function suggestLogement(params: { banId?: string; label: string }): Promise<LogementSuggestion | null> {
|
||||
const base =
|
||||
process.env.ADEME_DPE_DATASET_URL ??
|
||||
"https://data.ademe.fr/data-fair/api/v1/datasets/dpe-v2-logements-existants/lines";
|
||||
"https://data.ademe.fr/data-fair/api/v1/datasets/dpe03existant/lines";
|
||||
const champs = [
|
||||
"identifiant_ban", "statut_geocodage", "type_batiment",
|
||||
"annee_construction", "periode_construction",
|
||||
"type_energie_principale_chauffage", "type_generateur_chauffage_principal",
|
||||
"surface_habitable_logement", "etiquette_dpe", "date_etablissement_dpe", "date_fin_validite_dpe",
|
||||
].join(",");
|
||||
const requete = params.banId
|
||||
? `qs=${encodeURIComponent(`identifiant_ban:"${params.banId}"`)}`
|
||||
: `q=${encodeURIComponent(params.label)}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${base}?q=${encodeURIComponent(adresse)}&size=1&select=type_batiment,annee_construction,type_energie_principale_chauffage,surface_habitable_logement`,
|
||||
`${base}?${requete}&size=5&sort=-date_etablissement_dpe&select=${champs}`,
|
||||
{ signal: AbortSignal.timeout(4000) },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {
|
||||
console.error(`[adresse] ADEME DPE indisponible (HTTP ${res.status})`);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
const hit = data.results?.[0];
|
||||
if (!hit) return null;
|
||||
const energie = String(hit.type_energie_principale_chauffage ?? "").toLowerCase();
|
||||
const hits: HitDpe[] = data.results ?? [];
|
||||
if (!hits.length) return null;
|
||||
|
||||
const meilleur = hits[0];
|
||||
const confidence: LogementSuggestion["confidence"] =
|
||||
hits.length === 1 &&
|
||||
String(meilleur.type_batiment) === "maison" &&
|
||||
String(meilleur.statut_geocodage) === "adresse géocodée ban à l'adresse"
|
||||
? "haute"
|
||||
: "basse";
|
||||
|
||||
if (confidence === "basse") return { confidence, nbResultats: hits.length };
|
||||
|
||||
return {
|
||||
type: /maison/i.test(String(hit.type_batiment ?? "")) ? "maison" : /appartement/i.test(String(hit.type_batiment ?? "")) ? "appartement" : undefined,
|
||||
annee_construction: hit.annee_construction ? Number(hit.annee_construction) : undefined,
|
||||
chauffage: energie.includes("gaz") ? "gaz" : energie.includes("fioul") ? "fioul"
|
||||
: energie.includes("électricité") || energie.includes("electricite") ? "electrique"
|
||||
: energie.includes("bois") ? "bois" : undefined,
|
||||
surface: hit.surface_habitable_logement ? Number(hit.surface_habitable_logement) : undefined,
|
||||
confidence,
|
||||
nbResultats: hits.length,
|
||||
type: "maison",
|
||||
anneeConstruction: meilleur.annee_construction ? Number(meilleur.annee_construction) : undefined,
|
||||
periodeConstruction: meilleur.periode_construction ? String(meilleur.periode_construction) : undefined,
|
||||
chauffage: inferChauffage(meilleur),
|
||||
surface: meilleur.surface_habitable_logement ? Number(meilleur.surface_habitable_logement) : undefined,
|
||||
dpe: meilleur.etiquette_dpe && meilleur.date_fin_validite_dpe
|
||||
? {
|
||||
etiquette: String(meilleur.etiquette_dpe),
|
||||
dateEtablissement: String(meilleur.date_etablissement_dpe ?? ""),
|
||||
dateFinValidite: String(meilleur.date_fin_validite_dpe),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("[adresse] échec suggestLogement :", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
/**
|
||||
* Adapter plaque (§ 20.8, décision D-002) : entrée = plaque ; sortie = attributs d'asset véhicule.
|
||||
* Fournisseur interchangeable. Sans clé API → mock déterministe (dev/staging).
|
||||
*/
|
||||
|
||||
export type VehiculeDetecte = {
|
||||
marque: string;
|
||||
modele: string;
|
||||
annee: number;
|
||||
carburant: "essence" | "diesel" | "electrique" | "hybride";
|
||||
date_premiere_immat: string; // ISO
|
||||
critair?: string;
|
||||
source: "api" | "mock";
|
||||
};
|
||||
|
||||
const MOCKS: Omit<VehiculeDetecte, "source">[] = [
|
||||
{ marque: "Peugeot", modele: "308", annee: 2019, carburant: "essence", date_premiere_immat: "2019-03-12", critair: "1" },
|
||||
{ marque: "Renault", modele: "Clio V", annee: 2021, carburant: "essence", date_premiere_immat: "2021-06-04", critair: "1" },
|
||||
{ marque: "Dacia", modele: "Duster", annee: 2017, carburant: "diesel", date_premiere_immat: "2017-09-22", critair: "2" },
|
||||
{ marque: "Citroën", modele: "C3", annee: 2015, carburant: "diesel", date_premiere_immat: "2015-01-30", critair: "2" },
|
||||
{ marque: "Toyota", modele: "Yaris", annee: 2023, carburant: "hybride", date_premiere_immat: "2023-11-08", critair: "1" },
|
||||
];
|
||||
|
||||
function hashPlaque(plaque: string): number {
|
||||
let h = 0;
|
||||
for (const c of plaque) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
export function normalizePlaque(input: string): string | null {
|
||||
const p = input.toUpperCase().replace(/[\s-]/g, "");
|
||||
return /^[A-Z]{2}\d{3}[A-Z]{2}$/.test(p) || /^\d{1,4}[A-Z]{2,3}\d{2}$/.test(p) ? p : null;
|
||||
}
|
||||
|
||||
export async function lookupPlaque(plaque: string): Promise<VehiculeDetecte | null> {
|
||||
const normalized = normalizePlaque(plaque);
|
||||
if (!normalized) return null;
|
||||
|
||||
const apiUrl = process.env.PLAQUE_API_URL;
|
||||
const apiKey = process.env.PLAQUE_API_KEY;
|
||||
if (apiUrl && apiKey) {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}?immatriculation=${encodeURIComponent(normalized)}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Mapping générique — à ajuster au fournisseur retenu (comparatif en cours).
|
||||
const annee = Number(data.annee ?? data.year ?? new Date(data.date1erCir ?? data.datePremiereImmat ?? "").getFullYear());
|
||||
if (data.marque && annee) {
|
||||
return {
|
||||
marque: String(data.marque),
|
||||
modele: String(data.modele ?? data.model ?? ""),
|
||||
annee,
|
||||
carburant: /diesel|gazole/i.test(String(data.energie ?? data.carburant ?? "")) ? "diesel"
|
||||
: /elec/i.test(String(data.energie ?? "")) ? "electrique"
|
||||
: /hybr/i.test(String(data.energie ?? "")) ? "hybride" : "essence",
|
||||
date_premiere_immat: String(data.date1erCir ?? data.datePremiereImmat ?? `${annee}-07-01`),
|
||||
critair: data.critair ? String(data.critair) : undefined,
|
||||
source: "api",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Dégradation douce : on tombe sur le repli déclaratif côté UI, jamais d'erreur bloquante.
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...MOCKS[hashPlaque(normalized) % MOCKS.length], source: "mock" };
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ export async function GET(req: NextRequest) {
|
|||
if (q.length < 5) return NextResponse.json({ suggestions: [] });
|
||||
const withLogement = req.nextUrl.searchParams.get("logement") === "1";
|
||||
if (withLogement) {
|
||||
const logement = await suggestLogement(q);
|
||||
const banId = req.nextUrl.searchParams.get("banId") ?? undefined;
|
||||
const logement = await suggestLogement({ banId, label: q });
|
||||
return NextResponse.json({ suggestions: [], logement });
|
||||
}
|
||||
const suggestions = await searchAdresse(q);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ import { auth } from "@/lib/auth";
|
|||
|
||||
const Body = z.object({
|
||||
householdId: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
/**
|
||||
* Optionnel depuis le retrait du mur e-mail (D-005) : plus rien n'est demandé au visiteur
|
||||
* avant contrepartie, donc on n'a le plus souvent aucune adresse à ce stade — c'est Stripe
|
||||
* Checkout qui la collecte. On ne la pré-remplit que si une session est déjà ouverte.
|
||||
*/
|
||||
email: z.string().email().optional(),
|
||||
// Case obligatoire (§ 10.7, art. L221-28 Code conso) — le serveur refuse sans elle.
|
||||
retractationWaived: z.literal(true),
|
||||
});
|
||||
|
|
@ -43,7 +48,8 @@ export async function POST(req: NextRequest) {
|
|||
const checkoutSession = await client.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: process.env.STRIPE_PRICE_ANNUAL, quantity: 1 }],
|
||||
customer_email: email,
|
||||
// Sans adresse connue, Stripe la demande lui-même : un champ de moins avant le paiement.
|
||||
customer_email: email ?? session?.user.email ?? undefined,
|
||||
success_url: `${baseUrl}/foyer/${householdId}?checkout=success`,
|
||||
cancel_url: `${baseUrl}/foyer/${householdId}?checkout=cancel`,
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { lookupPlaque } from "@/adapters/plaque";
|
||||
|
||||
// Anti-abus v0 (§ 7.3) : cache mémoire par plaque + compteur naïf par IP.
|
||||
const cache = new Map<string, unknown>();
|
||||
const ipCounts = new Map<string, { count: number; reset: number }>();
|
||||
const IP_LIMIT = 5;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const immat = req.nextUrl.searchParams.get("immat") ?? "";
|
||||
const ip = req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0] ?? "local";
|
||||
|
||||
const now = Date.now();
|
||||
const entry = ipCounts.get(ip);
|
||||
if (entry && entry.reset > now && entry.count >= IP_LIMIT) {
|
||||
return NextResponse.json({ vehicule: null, error: "limite atteinte" }, { status: 429 });
|
||||
}
|
||||
ipCounts.set(ip, entry && entry.reset > now
|
||||
? { count: entry.count + 1, reset: entry.reset }
|
||||
: { count: 1, reset: now + 24 * 3600 * 1000 });
|
||||
|
||||
const key = immat.toUpperCase().replace(/[\s-]/g, "");
|
||||
if (cache.has(key)) return NextResponse.json({ vehicule: cache.get(key) });
|
||||
|
||||
const vehicule = await lookupPlaque(immat);
|
||||
if (vehicule) cache.set(key, vehicule);
|
||||
return NextResponse.json({ vehicule });
|
||||
}
|
||||
|
|
@ -20,9 +20,9 @@ export async function POST(req: NextRequest) {
|
|||
assetType: a.type,
|
||||
label: a.label,
|
||||
attributes: a.attributes,
|
||||
provenance: a.type === "vehicule" && (a.attributes.source === "api" || a.attributes.source === "mock")
|
||||
? "detection_plaque"
|
||||
: "quiz",
|
||||
// « carte_grise » se distingue du quiz : c'est une identification exacte dans le
|
||||
// référentiel (champ K), pas une saisie déclarative.
|
||||
provenance: a.type === "vehicule" && a.attributes.source === "carte_grise" ? "carte_grise" : "quiz",
|
||||
}));
|
||||
if (assetRows.length) await database.insert(assets).values(assetRows);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, assets as assetsTable } from "@/db";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { lookupPlaque } from "@/adapters/plaque";
|
||||
import { runReconciliation } from "@/engine/reconcile-db";
|
||||
|
||||
const Body = z.object({
|
||||
householdId: z.string().uuid(),
|
||||
assetId: z.string().uuid(),
|
||||
plaque: z.string().min(4).max(12),
|
||||
});
|
||||
|
||||
/**
|
||||
* Le débloquant explicite du pilier Véhicules (§ 10.2, § 10.6) : réservé aux abonnés,
|
||||
* un seul appel par véhicule, mis en cache immédiatement en base, jamais rejoué.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = Body.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "réponse invalide" }, { status: 400 });
|
||||
const { householdId, assetId, plaque } = parsed.data;
|
||||
|
||||
const subscription = await getSubscription(householdId);
|
||||
if (!subscription.isActive) {
|
||||
return NextResponse.json({ error: "réservé aux abonnés" }, { status: 402 });
|
||||
}
|
||||
|
||||
const database = db();
|
||||
const [asset] = await database
|
||||
.select()
|
||||
.from(assetsTable)
|
||||
.where(and(eq(assetsTable.id, assetId), eq(assetsTable.householdId, householdId)));
|
||||
if (!asset || asset.assetType !== "vehicule") {
|
||||
return NextResponse.json({ error: "véhicule introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
const vehicule = await lookupPlaque(plaque);
|
||||
if (!vehicule) return NextResponse.json({ error: "plaque non reconnue" }, { status: 422 });
|
||||
|
||||
const attributes = asset.attributes as Record<string, unknown>;
|
||||
await database
|
||||
.update(assetsTable)
|
||||
.set({
|
||||
label: `${vehicule.marque} ${vehicule.modele}`.trim(),
|
||||
attributes: {
|
||||
...attributes,
|
||||
marque: vehicule.marque,
|
||||
modele: vehicule.modele,
|
||||
annee: vehicule.annee,
|
||||
carburant: vehicule.carburant,
|
||||
date_premiere_immat: vehicule.date_premiere_immat,
|
||||
critair: vehicule.critair,
|
||||
plaque,
|
||||
source: vehicule.source,
|
||||
plaque_verifiee: true, // ne redemande jamais — plus jamais rejoué pour cet asset
|
||||
},
|
||||
})
|
||||
.where(eq(assetsTable.id, assetId));
|
||||
|
||||
await runReconciliation(householdId); // les fenêtres CT passent de "estimée" à "connue"
|
||||
|
||||
return NextResponse.json({ ok: true, vehicule });
|
||||
}
|
||||
25
src/app/api/vehicule/taxonomie/route.ts
Normal file
25
src/app/api/vehicule/taxonomie/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { identifierParReception, searchModeles } from "@/knowledge/vehicules-recherche";
|
||||
|
||||
/**
|
||||
* Identification véhicule du quiz — référentiel RDW, aucune saisie libre (§ 4.1), zéro API payante.
|
||||
*
|
||||
* ?q=<texte> → autocomplétion marque/modèle
|
||||
* ?k=<champ K>[&variante=&version=] → identification exacte par la carte grise (champs K et D.2)
|
||||
*
|
||||
* Dégradation douce : une réception inconnue renvoie `vehicule: null` (repli déclaratif côté UI),
|
||||
* jamais une erreur bloquante — « je ne sais pas » est une réponse de première classe (§ 4.3).
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const champK = req.nextUrl.searchParams.get("k");
|
||||
if (champK) {
|
||||
const vehicule = await identifierParReception(champK, {
|
||||
variante: req.nextUrl.searchParams.get("variante") ?? undefined,
|
||||
version: req.nextUrl.searchParams.get("version") ?? undefined,
|
||||
}).catch(() => null);
|
||||
return NextResponse.json({ vehicule });
|
||||
}
|
||||
|
||||
const suggestions = await searchModeles(req.nextUrl.searchParams.get("q") ?? "").catch(() => []);
|
||||
return NextResponse.json({ suggestions });
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MailCheck } from "lucide-react";
|
||||
import { Container } from "@/components/ui/primitives";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorNote, FieldLabel, Input } from "@/components/ui/form";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function Connexion() {
|
||||
|
|
@ -26,52 +29,81 @@ export default function Connexion() {
|
|||
setError(null);
|
||||
const { error: err } = await authClient.signIn.email({ email, password, callbackURL: "/mes-foyers" });
|
||||
setSending(false);
|
||||
if (err) setError("Email ou mot de passe incorrect.");
|
||||
if (err) setError("Adresse mail ou mot de passe incorrect.");
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<main>
|
||||
<div className="card" style={{ textAlign: "center" }}>
|
||||
<p><strong>Regarde ta boîte mail ✓</strong></p>
|
||||
<p className="muted">On t'a envoyé un lien de connexion — il expire dans 15 minutes.</p>
|
||||
</div>
|
||||
<Container width="narrow" className="py-20">
|
||||
<div className="surface-card mx-auto max-w-md p-8 text-center">
|
||||
<MailCheck size={28} className="mx-auto text-accent" aria-hidden />
|
||||
<p className="mt-4 font-display text-xl font-semibold">Regarde ta boîte mail</p>
|
||||
<p className="mt-2 text-muted">
|
||||
On t'a envoyé un lien de connexion — il expire dans 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Se connecter</h1>
|
||||
<p className="muted">Ton foyer t'attend. Un lien par mail suffit — pas de mot de passe à retenir.</p>
|
||||
|
||||
<input type="email" placeholder="ton@mail.fr" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
|
||||
{mode === "magic" ? (
|
||||
<p style={{ marginTop: "1rem" }}>
|
||||
<button className="btn btn-primary" disabled={sending || !email.includes("@")} onClick={envoyerLienMagique}>
|
||||
{sending ? "…" : "Envoyer le lien magique"}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<input type="password" placeholder="Mot de passe" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} />
|
||||
<p style={{ marginTop: "1rem" }}>
|
||||
<button className="btn btn-primary" disabled={sending || !email.includes("@") || !password} onClick={connecterAvecMotDePasse}>
|
||||
{sending ? "…" : "Se connecter"}
|
||||
</button>
|
||||
<Container width="narrow" className="py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-md">
|
||||
<h1 className="text-title">Se connecter</h1>
|
||||
<p className="mt-3 text-muted">
|
||||
Ton foyer t'attend. Un lien par mail suffit — pas de mot de passe à retenir.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="muted small">{error}</p>}
|
||||
<div className="surface-card mt-8 p-6">
|
||||
<FieldLabel htmlFor="email">Adresse mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="ton@mail.fr"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && mode === "magic" && email.includes("@") && envoyerLienMagique()}
|
||||
/>
|
||||
|
||||
<p className="muted small" style={{ marginTop: "1.5rem" }}>
|
||||
<button className="btn btn-ghost" onClick={() => setMode(mode === "magic" ? "mot-de-passe" : "magic")}>
|
||||
{mode === "magic" ? "J'ai un mot de passe" : "Utiliser le lien magique à la place"}
|
||||
</button>
|
||||
</p>
|
||||
{mode === "mot-de-passe" && (
|
||||
<div className="mt-4">
|
||||
<FieldLabel htmlFor="password">Mot de passe</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && password && email.includes("@") && connecterAvecMotDePasse()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-5 w-full"
|
||||
disabled={sending || !email.includes("@") || (mode === "mot-de-passe" && !password)}
|
||||
onClick={mode === "magic" ? envoyerLienMagique : connecterAvecMotDePasse}
|
||||
>
|
||||
{sending ? "…" : mode === "magic" ? "Recevoir mon lien de connexion" : "Se connecter"}
|
||||
</Button>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode(mode === "magic" ? "mot-de-passe" : "magic"); setError(null); }}
|
||||
className="mt-5 w-full text-center text-sm text-muted underline underline-offset-2 hover:text-accent"
|
||||
>
|
||||
{mode === "magic" ? "J'ai un mot de passe" : "Utiliser le lien par mail à la place"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Lock, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorNote } from "@/components/ui/form";
|
||||
import { Eyebrow } from "@/components/ui/primitives";
|
||||
|
||||
/**
|
||||
* Le vrai mur de paiement (§ 10.1, § 10.7) — remplace l'ancien lead-capture Phase 0.
|
||||
* La case de renonciation est un blocage légal non négociable : sans elle, le parcours
|
||||
* Le mur de paiement (§ 10.1, § 10.7).
|
||||
*
|
||||
* Deux partis pris de conversion :
|
||||
* 1. **Aucun champ e-mail.** Stripe Checkout le collecte lui-même — un champ de moins
|
||||
* entre l'intention et le paiement, et plus aucune saisie demandée avant contrepartie.
|
||||
* 2. **Le prix est confronté à ce qu'il achète**, pas posé nu : le visiteur vient de voir
|
||||
* son calendrier, l'encart dit ce qui se débloque dessus.
|
||||
*
|
||||
* La case de renonciation reste un blocage légal non négociable : sans elle, le parcours
|
||||
* quiz → paiement → full reveal instantané → rétractation resterait une faille ouverte.
|
||||
* Texte repris verbatim du cadrage — reste à faire valider par un professionnel du droit
|
||||
* avant tout encaissement réel (§ 10.7, § 28).
|
||||
* Texte repris verbatim du cadrage — à faire valider par un professionnel du droit avant
|
||||
* tout encaissement réel (§ 10.7, § 28).
|
||||
*/
|
||||
export function Activer({ householdId }: { householdId: string }) {
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const INCLUS = [
|
||||
"Toutes les échéances, avec leurs dates et leurs fenêtres",
|
||||
"Le pourquoi de chacune, en une ligne — et le premier pas de l'action",
|
||||
"Les rappels par mail au bon moment, jamais plus",
|
||||
"Le détail des rappels constructeur de tes véhicules",
|
||||
"Le catalogue complet des guides, en accès libre",
|
||||
"Les nouvelles obligations ajoutées à ton calendrier sans rien faire",
|
||||
];
|
||||
|
||||
export function Activer({ householdId, total }: { householdId: string; total: number }) {
|
||||
const [waived, setWaived] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -30,13 +50,13 @@ export function Activer({ householdId }: { householdId: string }) {
|
|||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ householdId, email, retractationWaived: waived }),
|
||||
body: JSON.stringify({ householdId, retractationWaived: waived }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res?.ok) {
|
||||
setSending(false);
|
||||
if (res?.status === 501) setError("Le paiement n'est pas encore activé sur ce site.");
|
||||
else setError("Un souci est survenu — réessaie dans un instant.");
|
||||
else setError("Un souci est survenu de notre côté — réessaie dans un instant.");
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json();
|
||||
|
|
@ -44,27 +64,81 @@ export function Activer({ householdId }: { householdId: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ textAlign: "center" }}>
|
||||
<p><strong>Activer la veille — 19,99 €/an</strong></p>
|
||||
<p className="muted small">Le calendrier complet, les rappels par mail, ta vraie plaque
|
||||
pour des dates exactes, le catalogue de guides, la délégation au conjoint.</p>
|
||||
<section
|
||||
id="activer"
|
||||
className="surface-card scroll-mt-24 overflow-hidden border-accent-line p-0 shadow-lift"
|
||||
>
|
||||
<div className="grid md:grid-cols-[1.1fr_0.9fr]">
|
||||
{/* Ce qui se débloque, sur le calendrier qu'il vient de voir. */}
|
||||
<div className="p-6 sm:p-8">
|
||||
<Eyebrow>La Veille</Eyebrow>
|
||||
<h2 className="mt-4 text-section">
|
||||
Les {total} échéances de ton foyer, surveillées toute l'année
|
||||
</h2>
|
||||
<p className="mt-3 text-[0.975rem] text-muted">
|
||||
Tu viens d'avoir un aperçu de quoi et quand. L'abonnement te permet de
|
||||
tout débloquer et de te les rappeler au bon moment.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-2.5">
|
||||
{INCLUS.map((l) => (
|
||||
<li key={l} className="flex gap-2.5 text-[0.95rem] text-ink-soft">
|
||||
<Check size={17} className="mt-0.5 shrink-0 text-accent" strokeWidth={2.5} aria-hidden />
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<input type="email" placeholder="ton@mail.fr" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
{/* Le geste de paiement, isolé du discours. */}
|
||||
<div className="flex flex-col justify-center border-t border-line bg-surface-alt p-6 sm:p-8 md:border-t-0 md:border-l">
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
<span className="tnum font-display text-5xl font-semibold tracking-tight">19,99 €</span>
|
||||
<span className="text-muted">/ an</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
1,66 € par mois, pour tout le foyer.
|
||||
</p>
|
||||
|
||||
<label className="waiver-checkbox">
|
||||
<input type="checkbox" checked={waived} onChange={(e) => setWaived(e.target.checked)} />
|
||||
<span>Je demande l'exécution immédiate du service et renonce à mon droit de
|
||||
rétractation de 14 jours.</span>
|
||||
</label>
|
||||
<label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-3.5 text-[0.825rem] leading-relaxed text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={waived}
|
||||
onChange={(e) => setWaived(e.target.checked)}
|
||||
className="mt-0.5 size-4 shrink-0 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
Je demande l'exécution immédiate du service et renonce à mon droit de
|
||||
rétractation de 14 jours.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p style={{ marginTop: "1rem" }}>
|
||||
<button className="btn btn-primary btn-lg" disabled={sending || !email.includes("@") || !waived} onClick={payer}>
|
||||
{sending ? "…" : "Payer 19,99 €/an"}
|
||||
</button>
|
||||
</p>
|
||||
<Button size="lg" className="mt-4 w-full" disabled={sending || !waived} onClick={payer}>
|
||||
{sending ? "Redirection…" : "Activer la veille"}
|
||||
</Button>
|
||||
|
||||
{error && <p className="muted small">{error}</p>}
|
||||
<p className="muted small">Paiement sécurisé par Stripe. Sans engagement au-delà de l'année en cours.</p>
|
||||
</div>
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-muted">
|
||||
<ShieldCheck size={14} className="mt-0.5 shrink-0" aria-hidden />
|
||||
Paiement sécurisé par Stripe, qui te demandera ton adresse mail. Sans engagement
|
||||
au-delà de l'année en cours, sans reconduction surprise : le récap annuel te
|
||||
prévient avant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rappel discret du verrou, posé sous les listes masquées plutôt qu'en travers. */
|
||||
export function LienVersActiver({ label }: { label: string }) {
|
||||
return (
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-muted">
|
||||
<Lock size={13} className="shrink-0" aria-hidden />
|
||||
{label}{" "}
|
||||
<a href="#activer" className="font-medium text-accent underline underline-offset-2">
|
||||
Activer la veille
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Chip, FieldLabel, Input, Select } from "@/components/ui/form";
|
||||
import { SectionTitle } from "@/components/ui/primitives";
|
||||
import type { ContractType } from "@/engine/contrats";
|
||||
|
||||
const MOIS = [
|
||||
|
|
@ -11,6 +14,11 @@ const MOIS = [
|
|||
|
||||
type Option = { type: ContractType; label: string };
|
||||
|
||||
/**
|
||||
* Unique saisie facultative du produit (§ 4.2) : elle est la seule à récompense monétaire
|
||||
* directe. Présentée comme un bonus, jamais comme une case à remplir — et « je ne sais pas »
|
||||
* reste une réponse complète, y compris pour le mois.
|
||||
*/
|
||||
export function Contrats({ householdId, missing }: { householdId: string; missing: Option[] }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState<ContractType | null>(null);
|
||||
|
|
@ -41,43 +49,52 @@ export function Contrats({ householdId, missing }: { householdId: string; missin
|
|||
|
||||
return (
|
||||
<section>
|
||||
<h2>Tes contrats <span className="muted small">(optionnel — ça affine la veille)</span></h2>
|
||||
<div className="card">
|
||||
<p className="muted small">
|
||||
Chez qui, et à quel mois environ tombe l'échéance ? « Je ne sais pas » est une réponse tout à fait valable.
|
||||
<SectionTitle>
|
||||
Tes contrats
|
||||
<span className="ml-2 text-base font-normal text-muted">optionnel — ça affine la veille</span>
|
||||
</SectionTitle>
|
||||
|
||||
<div className="surface-card mt-4 p-5 sm:p-6">
|
||||
<p className="text-[0.95rem] text-muted">
|
||||
Chez qui, et à quel mois environ tombe l'échéance ? « Je ne sais pas » est une
|
||||
réponse tout à fait valable.
|
||||
</p>
|
||||
<div className="chips">
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2.5">
|
||||
{missing.map((c) => (
|
||||
<button
|
||||
key={c.type}
|
||||
type="button"
|
||||
className={`chip${open === c.type ? " on" : ""}`}
|
||||
onClick={() => setOpen(open === c.type ? null : c.type)}
|
||||
>
|
||||
<Chip key={c.type} on={open === c.type} onClick={() => setOpen(open === c.type ? null : c.type)}>
|
||||
+ {c.label}
|
||||
</button>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Chez qui ? (optionnel)"
|
||||
value={fournisseur}
|
||||
onChange={(e) => setFournisseur(e.target.value)}
|
||||
/>
|
||||
<select value={mois} onChange={(e) => setMois(e.target.value)}>
|
||||
<option value="">Mois d'échéance : je ne sais pas</option>
|
||||
{MOIS.map((m, i) => (
|
||||
<option key={m} value={i + 1}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ marginTop: "0.75rem" }}>
|
||||
<button className="btn btn-secondary" disabled={saving} onClick={() => submit(open)}>
|
||||
<div className="animate-fade mt-5 grid gap-4 border-t border-line pt-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="fournisseur">Chez qui ?</FieldLabel>
|
||||
<Input
|
||||
id="fournisseur"
|
||||
type="text"
|
||||
placeholder="Optionnel"
|
||||
value={fournisseur}
|
||||
onChange={(e) => setFournisseur(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="mois">Mois d'échéance</FieldLabel>
|
||||
<Select id="mois" value={mois} onChange={(e) => setMois(e.target.value)}>
|
||||
<option value="">Je ne sais pas</option>
|
||||
{MOIS.map((m, i) => (
|
||||
<option key={m} value={i + 1}>{m}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<Button variant="secondary" disabled={saving} onClick={() => submit(open)}>
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
</button>{" "}
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(null)}>Annuler</button>
|
||||
</p>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setOpen(null)}>Annuler</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Email requis avant tout affichage (§ 10.2) — capture du lead + friction anti-scraping.
|
||||
* Une fois soumis, la même URL affiche la preview (le serveur revérifie via `leads`).
|
||||
*/
|
||||
export function EmailGate({ householdId }: { householdId: string }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/interet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, householdId, source: "preview_unlock" }),
|
||||
}).catch(() => null);
|
||||
setSending(false);
|
||||
if (!res?.ok) { setError("Petit souci — réessaie dans un instant."); return; }
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="etat">
|
||||
<p className="check">✓</p>
|
||||
<h1 style={{ fontSize: "1.4rem" }}>Ton calendrier est prêt</h1>
|
||||
<p className="muted">Entre ton email pour voir le détail — c'est aussi là qu'on
|
||||
t'enverra ton foyer si tu fermes cette page.</p>
|
||||
</div>
|
||||
<div className="card" style={{ maxWidth: "420px", margin: "0 auto", textAlign: "center" }}>
|
||||
<input type="email" placeholder="ton@mail.fr" value={email}
|
||||
onChange={(e) => setEmail(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
||||
<p style={{ marginTop: "0.9rem" }}>
|
||||
<button className="btn btn-primary" disabled={sending || !email.includes("@")} onClick={submit}>
|
||||
{sending ? "…" : "Voir mes échéances"}
|
||||
</button>
|
||||
</p>
|
||||
{error && <p className="muted small">{error}</p>}
|
||||
<p className="muted small">Juste ce mail-là. Pas de pub, désinscription en un clic.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
119
src/app/foyer/[id]/garder-lien.tsx
Normal file
119
src/app/foyer/[id]/garder-lien.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Mail, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/form";
|
||||
|
||||
/**
|
||||
* La capture d'adresse, après le service rendu — jamais avant (D-005).
|
||||
*
|
||||
* L'ancien mur e-mail exigeait une adresse pour afficher quoi que ce soit : c'était la
|
||||
* pièce qui faisait vivre le quiz comme un péage. Ici, l'adresse n'ouvre rien, elle rend
|
||||
* un service : retrouver un foyer dont l'URL est un UUID que personne ne mémorisera.
|
||||
*
|
||||
* Elle n'apparaît qu'une fois le calendrier parcouru (sentinelle d'intersection) : demander
|
||||
* dans le même écran que la révélation, ce serait remettre un péage trois secondes plus tard.
|
||||
* Refusable, et le refus est mémorisé.
|
||||
*/
|
||||
|
||||
const REFUS = "fokan.lien.refuse";
|
||||
|
||||
export function GarderLien({ householdId }: { householdId: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [ferme, setFerme] = useState(true);
|
||||
const [email, setEmail] = useState("");
|
||||
const [etat, setEtat] = useState<"idle" | "envoi" | "ok" | "erreur">("idle");
|
||||
const sentinelle = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let refuse = false;
|
||||
try { refuse = localStorage.getItem(`${REFUS}.${householdId}`) === "1"; } catch { /* ignore */ }
|
||||
setFerme(refuse);
|
||||
}, [householdId]);
|
||||
|
||||
useEffect(() => {
|
||||
const cible = sentinelle.current;
|
||||
if (!cible) return;
|
||||
const io = new IntersectionObserver(
|
||||
([e]) => { if (e.isIntersecting) setVisible(true); },
|
||||
{ rootMargin: "0px 0px -20% 0px" },
|
||||
);
|
||||
io.observe(cible);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
function refuser() {
|
||||
setFerme(true);
|
||||
try { localStorage.setItem(`${REFUS}.${householdId}`, "1"); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function envoyer() {
|
||||
setEtat("envoi");
|
||||
const res = await fetch("/api/interet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, householdId, source: "garder_lien" }),
|
||||
}).catch(() => null);
|
||||
setEtat(res?.ok ? "ok" : "erreur");
|
||||
}
|
||||
|
||||
if (etat === "ok") {
|
||||
return (
|
||||
<p className="mt-8 flex items-center gap-2.5 rounded-xl border border-accent-line bg-accent-soft px-4 py-3 text-[0.95rem] text-accent-soft-ink">
|
||||
<Check size={17} strokeWidth={2.5} className="shrink-0" aria-hidden />
|
||||
C'est noté — le lien de ce foyer part sur {email}. Tu peux fermer cette page.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={sentinelle} aria-hidden className="h-px" />
|
||||
{!ferme && visible && (
|
||||
<aside className="animate-rise surface-card mt-8 flex flex-col gap-4 bg-surface-alt p-5 sm:flex-row sm:items-center">
|
||||
<Mail size={20} className="shrink-0 text-accent" aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Je te renvoie ce lien par mail ?</p>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
Si jamais tu veux revenir plus tard.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 gap-2 sm:w-auto">
|
||||
<Input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
aria-label="Adresse mail"
|
||||
placeholder="ton@mail.fr"
|
||||
className="h-11 sm:w-52"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && email.includes("@") && envoyer()}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={etat === "envoi" || !email.includes("@")}
|
||||
onClick={envoyer}
|
||||
>
|
||||
{etat === "envoi" ? "…" : "Recevoir mon lien"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refuser}
|
||||
aria-label="Non merci"
|
||||
className="shrink-0 rounded-lg p-2 text-faint transition-colors hover:text-ink"
|
||||
>
|
||||
<X size={17} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{etat === "erreur" && (
|
||||
<p role="alert" className="text-sm text-warn">
|
||||
Ça n'est pas passé — réessaie dans un instant.
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/app/foyer/[id]/memoriser-foyer.tsx
Normal file
27
src/app/foyer/[id]/memoriser-foyer.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Mémorise localement le foyer visité, pour que la nav puisse proposer « Reprendre mon
|
||||
* foyer » à un visiteur sans compte.
|
||||
*
|
||||
* C'est l'autre moitié du retrait du mur e-mail (D-005) : si on ne demande plus d'adresse
|
||||
* pour afficher le résultat, il faut bien que le résultat reste retrouvable — sinon le
|
||||
* quiz redevient rétrospectivement du temps perdu, ce qu'on cherchait précisément à éviter.
|
||||
*
|
||||
* Rien de personnel n'est stocké : un identifiant de foyer et un compte, sur l'appareil
|
||||
* de l'utilisateur uniquement.
|
||||
*/
|
||||
|
||||
export const FOYER_LOCAL = "fokan.foyer";
|
||||
|
||||
export function MemoriserFoyer({ householdId, total }: { householdId: string; total: number }) {
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(FOYER_LOCAL, JSON.stringify({ id: householdId, total, vu: Date.now() }));
|
||||
} catch { /* navigation privée ou quota : simple confort, aucune dépendance */ }
|
||||
}, [householdId, total]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,60 +1,112 @@
|
|||
import type { Metadata } from "next";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Lock } from "lucide-react";
|
||||
import { Lock, PartyPopper, type LucideIcon } from "lucide-react";
|
||||
import { db, assets as assetsTable } from "@/db";
|
||||
import { buildHub, type HubDeadline } from "@/engine/hub";
|
||||
import { declaredContractTypes, relevantContractTypes } from "@/engine/contrats";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveMembership } from "@/lib/membership";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { hasUnlockedPreview } from "@/lib/leads";
|
||||
import { PILIER_META, ENJEU_META } from "@/lib/pilier-meta";
|
||||
import { hasLead } from "@/lib/leads";
|
||||
import { PILIER_META, PILIER_ORDER, ENJEU_META } from "@/lib/pilier-meta";
|
||||
import { Container, Eyebrow, IconTile, SectionTitle } from "@/components/ui/primitives";
|
||||
import { TimelineAnnee, LegendePiliers, type PointAnnee } from "@/components/timeline-annee";
|
||||
import type { Asset, AssetType, Pilier } from "@/knowledge/schema";
|
||||
import { Activer } from "./activer";
|
||||
import { Activer, LienVersActiver } from "./activer";
|
||||
import { Contrats } from "./contrats";
|
||||
import { Session } from "./session";
|
||||
import { EmailGate } from "./email-gate";
|
||||
import { VehiculePlaque } from "./vehicule-plaque";
|
||||
import { Rappels } from "./rappels";
|
||||
import { GarderLien } from "./garder-lien";
|
||||
import { MemoriserFoyer } from "./memoriser-foyer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function DeadlineRow({ d, detail }: { d: HubDeadline; detail: string }) {
|
||||
const { Icon } = ENJEU_META[d.enjeuType];
|
||||
// Un foyer est personnel et son URL n'est pas devinable : elle n'a rien à faire dans un index.
|
||||
export const metadata: Metadata = {
|
||||
title: "Ton foyer",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
/* ── Lignes d'échéance ────────────────────────────────────────────────── */
|
||||
|
||||
function LigneRevelee({ d, detail }: { d: HubDeadline; detail: string }) {
|
||||
const { Icon, label } = ENJEU_META[d.enjeuType];
|
||||
const { color } = PILIER_META[d.pilier];
|
||||
return (
|
||||
<div className="deadline">
|
||||
<p>
|
||||
<Icon size={14} className="enjeu-icon" strokeWidth={2.25} />
|
||||
<span className="titre">{d.titre}</span> <span className="muted small">· {d.assetLabel}</span>
|
||||
</p>
|
||||
<p className="fenetre">{d.window.label}</p>
|
||||
<p className="muted small">{detail}</p>
|
||||
<li className="flex gap-4 border-t border-line py-4 first:border-t-0 first:pt-0">
|
||||
<IconTile color={color} className="mt-0.5 size-9">
|
||||
<Icon size={17} strokeWidth={2.25} aria-hidden />
|
||||
</IconTile>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-balance">
|
||||
{d.titre}
|
||||
<span className="ml-1.5 text-sm font-normal text-faint">· {d.assetLabel}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-accent">{d.window.label}</p>
|
||||
<p className="mt-1.5 text-[0.925rem] text-muted">{detail}</p>
|
||||
<p className="mt-1 text-xs text-faint uppercase">{label}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue verrouillée d'un pilier : le compte, jamais le détail (§ 10.2).
|
||||
*
|
||||
* Une première version listait chaque échéance avec son titre en clair (date et enjeu
|
||||
* masqués seulement). C'était encore trop : le titre EST déjà le savoir — « il faut penser
|
||||
* à faire vacciner le chat contre la rage avant un voyage » n'a pas besoin d'une date pour
|
||||
* être utile. Ici, seul le compte par pilier est montré ; aucun intitulé n'est cherchable.
|
||||
*
|
||||
* `children` accueille les rappels constructeur pour le pilier Véhicules : ils doivent
|
||||
* vivre DANS cette carte (une seule `surface-card`), pas dans une carte à part en dessous.
|
||||
*/
|
||||
function PilierVerrouille({
|
||||
label,
|
||||
Icon,
|
||||
color,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
color: string;
|
||||
count: number;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-card mt-3 p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconTile color={color}>
|
||||
<Icon size={19} strokeWidth={2} aria-hidden />
|
||||
</IconTile>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="tnum text-sm text-muted">
|
||||
{count} échéance{count > 1 ? "s" : ""} verrouillée{count > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Lock size={15} className="shrink-0 text-faint" aria-hidden />
|
||||
<span className="sr-only">Titres, dates et enjeux réservés aux abonnés</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** « Titre + pilier visibles, date/enjeu masqués » (§ 10.2) — le débloquant explicite du payant. */
|
||||
function LockedDeadlineRow({ d }: { d: HubDeadline }) {
|
||||
return (
|
||||
<div className="deadline deadline-locked">
|
||||
<p><span className="titre">{d.titre}</span> <span className="muted small">· {d.assetLabel}</span></p>
|
||||
<p className="muted small">
|
||||
<Lock size={12} style={{ verticalAlign: "-1px", marginRight: "0.35rem" }} />
|
||||
Réservé aux abonnés
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* ── Page ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function FoyerPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ invite?: string }>;
|
||||
searchParams: Promise<{ invite?: string; checkout?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { invite } = await searchParams;
|
||||
const { invite, checkout } = await searchParams;
|
||||
if (!/^[0-9a-f-]{36}$/.test(id)) notFound();
|
||||
|
||||
const rows = await db().select().from(assetsTable).where(eq(assetsTable.householdId, id));
|
||||
|
|
@ -63,11 +115,6 @@ export default async function FoyerPage({
|
|||
const subscription = await getSubscription(id);
|
||||
const unlocked = subscription.isActive;
|
||||
|
||||
// Email requis avant tout affichage (§ 10.2) — rien n'est montré tant que le lead n'existe pas.
|
||||
if (!unlocked && !(await hasUnlockedPreview(id))) {
|
||||
return <EmailGate householdId={id} />;
|
||||
}
|
||||
|
||||
const assets: Asset[] = rows.map((r) => ({
|
||||
type: r.assetType as AssetType,
|
||||
label: r.label,
|
||||
|
|
@ -77,67 +124,158 @@ export default async function FoyerPage({
|
|||
const declared = declaredContractTypes(assets);
|
||||
const missingContracts = relevantContractTypes(assets).filter((c) => !declared.has(c.type));
|
||||
|
||||
const vehiculesAAmeliorer = rows
|
||||
.filter((r) => r.assetType === "vehicule" && !(r.attributes as Record<string, unknown>).plaque_verifiee)
|
||||
.map((r) => ({ id: r.id, label: r.label }));
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const members = await resolveMembership(id, session?.user ?? null, invite);
|
||||
// Ne jamais redemander une adresse déjà donnée : la relance serait vécue comme un péage.
|
||||
const adresseConnue = Boolean(session) || (await hasLead(id));
|
||||
|
||||
const toutes = PILIER_ORDER.flatMap((p) => hub.parPilier[p]);
|
||||
const points: PointAnnee[] = toutes
|
||||
.filter((d) => d.window.start)
|
||||
.map((d) => ({ mois: d.window.start!.getMonth(), pilier: d.pilier }));
|
||||
const sansDate = toutes.length - points.length;
|
||||
const compte = Object.fromEntries(
|
||||
PILIER_ORDER.map((p) => [p, hub.parPilier[p].length]),
|
||||
) as Partial<Record<Pilier, number>>;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="etat">
|
||||
<p className="check">✓</p>
|
||||
<h1 style={{ fontSize: "1.5rem" }}>{hub.total} échéances sous contrôle</h1>
|
||||
<p className="muted">Tu n'as plus besoin d'y penser. Voici les 3 prochaines :</p>
|
||||
</div>
|
||||
<main className="pb-16">
|
||||
<MemoriserFoyer householdId={id} total={hub.total} />
|
||||
|
||||
<div className="card">
|
||||
{hub.prochaines.map((d) => (
|
||||
<DeadlineRow d={d} detail={d.enjeu} key={d.templateId + d.assetLabel} />
|
||||
))}
|
||||
</div>
|
||||
{checkout === "success" && (
|
||||
<Container width="default" className="pt-8">
|
||||
<p className="animate-rise flex items-center gap-3 rounded-xl border border-accent-line bg-accent-soft px-5 py-4 text-accent-soft-ink">
|
||||
<PartyPopper size={20} className="shrink-0" aria-hidden />
|
||||
<span>
|
||||
<strong>La veille est active.</strong> Tout est déverrouillé ci-dessous — et à
|
||||
partir de maintenant, c'est nous qui y pensons.
|
||||
</span>
|
||||
</p>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!unlocked && <Activer householdId={id} />}
|
||||
{/* ── La révélation ─────────────────────────────────────────────── */}
|
||||
<Container width="default" className="pt-12 pb-10 sm:pt-16">
|
||||
<div className="animate-rise text-center">
|
||||
<Eyebrow>Ton foyer</Eyebrow>
|
||||
<h1 className="mt-5">
|
||||
<span className="tnum block text-stat text-accent">{hub.total}</span>
|
||||
<span className="mt-1 block text-title">échéances sous contrôle</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-md text-muted">
|
||||
{unlocked
|
||||
? "Tu n'as plus besoin d'y penser. On te prévient au bon moment, et seulement au bon moment."
|
||||
: "Voici ce que ton foyer porte sur les douze prochains mois."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Session householdId={id} currentUserEmail={session?.user.email ?? null} members={members} />
|
||||
<div className="animate-rise mt-10 [animation-delay:120ms]">
|
||||
<TimelineAnnee points={points} moisCourant={new Date().getMonth()} />
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<LegendePiliers compte={compte} />
|
||||
{sansDate > 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
<span className="tnum font-semibold text-ink-soft">+{sansDate}</span> sans date
|
||||
fixée — surveillées aussi
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Contrats householdId={id} missing={missingContracts} />
|
||||
{/* ── Les prochaines, entièrement révélées ──────────────────────── */}
|
||||
{/* Seule brèche volontaire dans « révéler l'inventaire, jamais le mode d'emploi »
|
||||
(§ 10.2) : un échantillon borné (2-3 échéances RÉELLES du foyer, en entier) plutôt
|
||||
que zéro. Sans lui, tout ce que voit un visiteur sans abonnement reste abstrait
|
||||
(compteur, timeline, agrégats) — rien ne prouve concrètement qu'on a trouvé quelque
|
||||
chose pour SON foyer. À ne pas confondre avec le mur d'échéances ou la liste
|
||||
verrouillée : ceux-là exposaient l'inventaire ENTIER ou les catégories de rappel à
|
||||
tout le monde, ce qui est différent en nature d'un échantillon de 2-3 items. */}
|
||||
{hub.prochaines.length > 0 && (
|
||||
<Container width="default" className="py-8">
|
||||
<SectionTitle>Ce qui arrive en premier</SectionTitle>
|
||||
<p className="mt-2 text-muted">
|
||||
{unlocked
|
||||
? "Les trois plus proches. Tu recevras un mail au bon moment pour chacune."
|
||||
: "Trois échéances en clair, pour que tu voies exactement ce qu'on t'enverra."}
|
||||
</p>
|
||||
<ul className="surface-card mt-5 p-5 sm:p-6">
|
||||
{hub.prochaines.map((d) => (
|
||||
<LigneRevelee d={d} detail={d.enjeu} key={d.templateId + d.assetLabel} />
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{unlocked && <VehiculePlaque householdId={id} vehicules={vehiculesAAmeliorer} />}
|
||||
|
||||
{(Object.entries(hub.parPilier) as [Pilier, HubDeadline[]][]).map(([pilier, deadlines]) => {
|
||||
if (!deadlines.length) return null;
|
||||
const { label, Icon, color } = PILIER_META[pilier];
|
||||
return (
|
||||
<section key={pilier}>
|
||||
<h2 className="guide-section-title">
|
||||
<span className="guide-card-icon" style={{ ["--tile-color" as string]: color }}>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</span>
|
||||
{label}
|
||||
<span className="muted small">{deadlines.length} échéance{deadlines.length > 1 ? "s" : ""}</span>
|
||||
</h2>
|
||||
<div className="card">
|
||||
{deadlines.map((d, i) =>
|
||||
unlocked
|
||||
? <DeadlineRow d={d} detail={d.description} key={d.templateId + i} />
|
||||
: <LockedDeadlineRow d={d} key={d.templateId + i} />,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="footer-note muted small">
|
||||
{unlocked ? (
|
||||
<p>La veille est active : rappels par mail au bon moment, délégation au conjoint,
|
||||
mise à jour continue de ton calendrier.</p>
|
||||
) : (
|
||||
<p>Cet aperçu montre ce qu'on surveillerait pour toi. Le calendrier complet, les
|
||||
rappels par mail et la délégation au conjoint — c'est l'abonnement.</p>
|
||||
{/* ── Le calendrier, pilier par pilier ──────────────────────────── */}
|
||||
<Container width="default" className="py-8">
|
||||
<SectionTitle>Le calendrier complet</SectionTitle>
|
||||
{!unlocked && (
|
||||
<p className="mt-2 text-muted">
|
||||
On t'a compté tout ce qu'on a repéré, réparti par domaine. Le détail —
|
||||
quoi, quand, pourquoi — fait partie de l'abonnement.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-8">
|
||||
{PILIER_ORDER.map((pilier) => {
|
||||
const deadlines = hub.parPilier[pilier];
|
||||
if (!deadlines.length) return null;
|
||||
const { label, Icon, color } = PILIER_META[pilier];
|
||||
// Les rappels constructeur portent sur les mêmes véhicules : ils vivent dans ce
|
||||
// pilier plutôt qu'en section à part, sur demande explicite de revue.
|
||||
const estVehicules = pilier === "vehicules";
|
||||
|
||||
if (!unlocked) {
|
||||
return (
|
||||
<PilierVerrouille key={pilier} label={label} Icon={Icon} color={color} count={deadlines.length}>
|
||||
{estVehicules && <Rappels assets={assets} unlocked={unlocked} />}
|
||||
</PilierVerrouille>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={pilier}>
|
||||
<h3 className="flex items-center gap-3">
|
||||
<IconTile color={color}>
|
||||
<Icon size={19} strokeWidth={2} aria-hidden />
|
||||
</IconTile>
|
||||
<span className="flex-1 text-lg">{label}</span>
|
||||
<span className="tnum text-sm font-normal text-muted">
|
||||
{deadlines.length} échéance{deadlines.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</h3>
|
||||
{/* Une seule carte pour la liste et, si applicable, les rappels constructeur
|
||||
du même pilier : jamais deux cartes empilées pour un même domaine. */}
|
||||
<div className="surface-card mt-3 p-5">
|
||||
<ul>
|
||||
{deadlines.map((d, i) => (
|
||||
<LigneRevelee d={d} detail={d.description} key={d.templateId + i} />
|
||||
))}
|
||||
</ul>
|
||||
{estVehicules && <Rappels assets={assets} unlocked={unlocked} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!unlocked && <LienVersActiver label="Titres, dates et enjeux masqués." />}
|
||||
</Container>
|
||||
|
||||
{/* ── Le mur de paiement, une fois le manque installé ───────────── */}
|
||||
{!unlocked && (
|
||||
<Container width="default" className="py-8">
|
||||
<Activer householdId={id} total={hub.total} />
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{/* ── Affinage et délégation ────────────────────────────────────── */}
|
||||
<Container width="default" className="space-y-8 py-8">
|
||||
<Contrats householdId={id} missing={missingContracts} />
|
||||
<Session householdId={id} currentUserEmail={session?.user.email ?? null} members={members} />
|
||||
{!unlocked && !adresseConnue && <GarderLien householdId={id} />}
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
129
src/app/foyer/[id]/rappels.tsx
Normal file
129
src/app/foyer/[id]/rappels.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { ExternalLink, Lock, ShieldAlert } from "lucide-react";
|
||||
import { rappelsPourVehicule, apercuRappels, type RappelVehicule } from "@/knowledge/rappels";
|
||||
import { resoudreVehicules } from "@/knowledge/vehicules-resolution";
|
||||
import type { Asset } from "@/knowledge/schema";
|
||||
|
||||
/**
|
||||
* Rappels constructeur des véhicules du foyer.
|
||||
*
|
||||
* Rendu à l'intérieur de la carte du pilier Véhicules (voir page.tsx), pas dans une carte
|
||||
* à part en dessous : c'est une information sur les mêmes véhicules, pas un domaine séparé.
|
||||
* D'où l'absence de `surface-card` ici — le contenu vit dans la carte du pilier appelant,
|
||||
* séparé d'elle par une simple bordure supérieure, jamais par une carte imbriquée.
|
||||
*
|
||||
* Ton : jamais alarmiste (§ 4.5, aucune comptabilité morale). Un rappel n'est pas une faute
|
||||
* du foyer — c'est un courrier constructeur qui s'est perdu, et la réparation est gratuite.
|
||||
* On informe, on ne réprimande pas.
|
||||
*
|
||||
* Réserve portée dans le texte lui-même : la source ne précise ni millésimes ni VIN, donc on
|
||||
* dit « ce modèle a fait l'objet d'un rappel », jamais « ta voiture est rappelée ».
|
||||
*/
|
||||
|
||||
type VehiculeRappels = { label: string; rappels: RappelVehicule[] };
|
||||
|
||||
async function chargerRappels(assets: Asset[]): Promise<VehiculeRappels[]> {
|
||||
// Une seule résolution par véhicule, partagée par tous les enrichissements : c'est elle
|
||||
// qui garantit qu'un foyer identifié au champ K et un foyer ayant tapé « citroen c3 »
|
||||
// voient les mêmes campagnes (src/knowledge/vehicules-resolution.ts).
|
||||
const vehicules = await resoudreVehicules(assets).catch(() => []);
|
||||
const resultats = await Promise.all(
|
||||
vehicules.map(async ({ asset, resolu }) => ({
|
||||
label: asset.label,
|
||||
rappels: await rappelsPourVehicule(resolu).catch(() => []),
|
||||
})),
|
||||
);
|
||||
return resultats.filter((r) => r.rappels.length > 0);
|
||||
}
|
||||
|
||||
const GRAVITE_LABEL: Record<string, string> = {
|
||||
grave: "jugée sérieuse par l'autorité",
|
||||
moyenne: "de gravité moyenne",
|
||||
legere: "de gravité légère",
|
||||
non_precisee: "de gravité non précisée",
|
||||
};
|
||||
|
||||
export async function Rappels({ assets, unlocked }: { assets: Asset[]; unlocked: boolean }) {
|
||||
const parVehicule = await chargerRappels(assets);
|
||||
if (!parVehicule.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4 border-t border-line pt-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-warn">
|
||||
<ShieldAlert size={16} strokeWidth={2.25} aria-hidden />
|
||||
Rappels constructeur
|
||||
</p>
|
||||
|
||||
{parVehicule.map(({ label, rappels }) => {
|
||||
const apercu = apercuRappels(rappels);
|
||||
return (
|
||||
<div key={label}>
|
||||
<p className="font-medium">
|
||||
{label}
|
||||
<span className="tnum ml-1.5 text-sm font-normal text-faint">
|
||||
· {apercu.total} campagne{apercu.total > 1 ? "s" : ""} recensée
|
||||
{apercu.total > 1 ? "s" : ""}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{!unlocked ? (
|
||||
/* Teaser (§ 10.2) : on révèle l'existence, jamais la nature. Une première
|
||||
version listait les catégories concernées (« freinage, direction ») — déjà
|
||||
assez pour motiver un passage au garage sans jamais payer. Seul le compte
|
||||
reste montré ici. */
|
||||
<>
|
||||
<p className="mt-2 text-[0.95rem] text-muted">
|
||||
Ce modèle a fait l'objet de campagnes de rappel. La réparation est
|
||||
généralement prise en charge par le constructeur.
|
||||
</p>
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-faint">
|
||||
<Lock size={13} className="shrink-0" aria-hidden />
|
||||
Le détail et la marche à suivre sont réservés aux abonnés
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-3">
|
||||
{rappels.slice(0, 5).map((r) => (
|
||||
<li className="border-t border-line py-3.5 first:border-t-0" key={r.reference}>
|
||||
<p className="font-medium">
|
||||
{r.categorie}
|
||||
<span className="ml-1.5 text-sm font-normal text-faint">
|
||||
· campagne {GRAVITE_LABEL[r.gravite] ?? GRAVITE_LABEL.non_precisee}
|
||||
{r.datePublication && <> · {r.datePublication.slice(0, 4)}</>}
|
||||
</span>
|
||||
</p>
|
||||
{r.dangers.length > 0 && (
|
||||
<p className="mt-1 text-[0.925rem] text-muted">
|
||||
Risque identifié : {r.dangers.join(", ").toLowerCase()}.
|
||||
</p>
|
||||
)}
|
||||
{r.infoUrl && (
|
||||
<a
|
||||
href={r.infoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1.5 inline-flex items-center gap-1.5 text-sm font-medium text-accent underline underline-offset-2"
|
||||
>
|
||||
Détail publié par le constructeur
|
||||
<ExternalLink size={13} aria-hidden />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Réserve obligatoire : la source rattache un rappel à un MODÈLE (marque +
|
||||
dénomination), sans millésime ni VIN. Une « 308 » couvre ainsi 103 réceptions
|
||||
de 2007 à 2026 — on ne peut donc jamais affirmer que l'exemplaire est visé. */}
|
||||
<p className="mt-3 border-t border-line pt-3 text-sm text-muted">
|
||||
Ces campagnes visent le modèle de ta voiture, pas nécessairement ta version
|
||||
précise. Ton concessionnaire ou un garage agréé de la marque te le confirmera
|
||||
en quelques minutes. La réparation est à leur charge, pas à la tienne.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import type { Member } from "@/lib/membership";
|
||||
|
||||
/**
|
||||
* État du compte, pour qui en a déjà un.
|
||||
*
|
||||
* Deux blocs retirés pour le moment (à réintroduire séparément si besoin) :
|
||||
* - le lien magique anonyme « suivre ce foyer depuis un compte » : il faisait doublon avec
|
||||
* `garder-lien.tsx`, qui offre la même mécanique (retrouver ce foyer plus tard) sans
|
||||
* présenter ça comme un compte à créer ;
|
||||
* - l'invitation du conjoint : la délégation n'est plus mise en avant dans le produit
|
||||
* pour le moment (le code d'invitation reste disponible, seulement plus surfacé ici).
|
||||
*
|
||||
* Sans session, ce composant ne rend donc rien — `garder-lien.tsx` couvre déjà le besoin
|
||||
* de retrouver le foyer, et `/connexion` reste accessible depuis la nav pour qui veut un
|
||||
* vrai compte.
|
||||
*/
|
||||
export function Session({
|
||||
householdId,
|
||||
currentUserEmail,
|
||||
members,
|
||||
}: {
|
||||
|
|
@ -13,86 +25,26 @@ export function Session({
|
|||
currentUserEmail: string | null;
|
||||
members: Member[];
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteSending, setInviteSending] = useState(false);
|
||||
const [inviteSent, setInviteSent] = useState(false);
|
||||
|
||||
async function connecter() {
|
||||
setSending(true);
|
||||
await authClient.signIn.magicLink({ email, callbackURL: `/foyer/${householdId}` }).catch(() => {});
|
||||
setSending(false);
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
async function inviter() {
|
||||
setInviteSending(true);
|
||||
await fetch("/api/invitations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ householdId, email: inviteEmail }),
|
||||
}).catch(() => {});
|
||||
setInviteSending(false);
|
||||
setInviteSent(true);
|
||||
setInviteEmail("");
|
||||
}
|
||||
|
||||
if (!currentUserEmail) {
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="muted small">✓ Regarde ta boîte mail — le lien te connecte directement à ce foyer.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="muted small">
|
||||
Envie de suivre ce foyer depuis ton compte, et de pouvoir déléguer des échéances plus tard ?
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input type="email" placeholder="ton@mail.fr" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<button className="btn btn-secondary" disabled={sending || !email.includes("@")} onClick={connecter}>
|
||||
{sending ? "…" : "Recevoir mon lien"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!currentUserEmail) return null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="muted small">
|
||||
Connecté comme <strong>{currentUserEmail}</strong>
|
||||
{" · "}
|
||||
<button className="btn btn-ghost" onClick={() => authClient.signOut().then(() => location.reload())}>
|
||||
<div className="surface-card p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.95rem]">
|
||||
<span className="text-muted">
|
||||
Connecté comme <strong className="font-medium text-ink">{currentUserEmail}</strong>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authClient.signOut().then(() => location.reload())}
|
||||
className="text-sm text-muted underline underline-offset-2 hover:text-accent"
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</p>
|
||||
<p className="muted small">
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Membres du foyer : {members.map((m) => m.email).join(", ")}
|
||||
</p>
|
||||
{inviteSent ? (
|
||||
<p className="muted small">✓ Invitation envoyée.</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Inviter quelqu'un (email)"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={inviteSending || !inviteEmail.includes("@")}
|
||||
onClick={inviter}
|
||||
>
|
||||
{inviteSending ? "…" : "Inviter"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type VehiculeAAmeliorer = { id: string; label: string };
|
||||
|
||||
export function VehiculePlaque({ householdId, vehicules }: { householdId: string; vehicules: VehiculeAAmeliorer[] }) {
|
||||
const router = useRouter();
|
||||
const [plaques, setPlaques] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
|
||||
if (vehicules.length === 0) return null;
|
||||
|
||||
async function ajouter(assetId: string) {
|
||||
const plaque = plaques[assetId];
|
||||
if (!plaque) return;
|
||||
setLoading(assetId);
|
||||
setErreur(null);
|
||||
const res = await fetch("/api/vehicule/plaque", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ householdId, assetId, plaque }),
|
||||
}).catch(() => null);
|
||||
setLoading(null);
|
||||
if (!res?.ok) { setErreur("Plaque non reconnue — vérifie le format."); return; }
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 style={{ fontSize: "1.05rem" }}>Tes véhicules, en exact</h2>
|
||||
<div className="card">
|
||||
<p className="muted small">
|
||||
Ajoute la vraie plaque pour la date exacte du contrôle technique et les
|
||||
préconisations du modèle — un seul appel, mis en cache pour toujours.
|
||||
</p>
|
||||
{vehicules.map((v) => (
|
||||
<div key={v.id} style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem", alignItems: "center" }}>
|
||||
<span className="muted small" style={{ minWidth: "120px" }}>{v.label}</span>
|
||||
<input type="text" placeholder="AB-123-CD" value={plaques[v.id] ?? ""}
|
||||
onChange={(e) => setPlaques((s) => ({ ...s, [v.id]: e.target.value }))}
|
||||
style={{ maxWidth: "160px", marginTop: 0 }} />
|
||||
<button className="btn btn-secondary" disabled={loading === v.id || !plaques[v.id]} onClick={() => ajouter(v.id)}>
|
||||
{loading === v.id ? "…" : "Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{erreur && <p className="muted small">{erreur}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,398 +1,350 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
fokan — socle de design
|
||||
───────────────────────────────────────────────────────────────────────────
|
||||
Trois couches, dans cet ordre :
|
||||
1. PALETTE — les valeurs brutes, jamais utilisées directement dans les vues.
|
||||
2. SÉMANTIQUE — le rôle (surface, texte, bordure…) ; c'est la seule couche que
|
||||
le mode sombre réécrit, et la seule que les composants consomment.
|
||||
3. @theme — expose la couche sémantique à Tailwind (bg-surface, text-muted…).
|
||||
Conséquence : aucun composant ne connaît le mode sombre. Il n'existe qu'ici.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--bg: #faf8f4;
|
||||
--card: #ffffff;
|
||||
--ink: #26302c;
|
||||
--muted: #6b7570;
|
||||
--accent: #2e6650;
|
||||
--accent-soft: #e7f0ec;
|
||||
--border: #e4e0d8;
|
||||
--warn: #8a6d3b;
|
||||
/* ── 1. Palette ─────────────────────────────────────────────────────────
|
||||
Vert : la marque. Sable : des gris chauds, jamais neutres — c'est ce qui
|
||||
éloigne du SaaS bleu-gris et rapproche du papier. */
|
||||
--vert-50: #eef4f1;
|
||||
--vert-100: #dbe8e2;
|
||||
--vert-200: #b8d1c6;
|
||||
--vert-300: #8db5a4;
|
||||
--vert-400: #5f9480;
|
||||
--vert-500: #3f7a62;
|
||||
--vert-600: #2e6650;
|
||||
--vert-700: #255343;
|
||||
--vert-800: #1e4237;
|
||||
--vert-900: #18342c;
|
||||
--vert-950: #0d1c17;
|
||||
|
||||
--sable-50: #faf8f4;
|
||||
--sable-100: #f4f1ea;
|
||||
--sable-200: #e7e2d8;
|
||||
--sable-300: #d5cfc2;
|
||||
--sable-400: #ada697;
|
||||
--sable-500: #857f72;
|
||||
--sable-600: #635d52;
|
||||
--sable-700: #4a453d;
|
||||
--sable-800: #33302a;
|
||||
--sable-900: #26302c;
|
||||
--sable-950: #171a18;
|
||||
|
||||
--ambre-50: #fdf7ea;
|
||||
--ambre-100: #f7ecd4;
|
||||
--ambre-200: #ecd8ab;
|
||||
--ambre-300: #d9b877;
|
||||
--ambre-600: #8a6d3b;
|
||||
--ambre-700: #6f5730;
|
||||
|
||||
/* Une couleur par pilier — repère de tri, jamais décoratif seul (cf. lib/pilier-meta). */
|
||||
--pilier-maison: #2e6650;
|
||||
--pilier-vehicules: #8a5a2b;
|
||||
--pilier-papiers: #3a5f8a;
|
||||
--pilier-animaux: #8a3b6b;
|
||||
|
||||
/* ── 2. Sémantique — mode clair ───────────────────────────────────────── */
|
||||
--canvas: var(--sable-50);
|
||||
--canvas-alt: var(--sable-100);
|
||||
--surface: #ffffff;
|
||||
--surface-alt: var(--sable-100);
|
||||
--surface-inset: var(--sable-100);
|
||||
|
||||
--ink: var(--sable-900);
|
||||
--ink-soft: var(--sable-700);
|
||||
--ink-muted: var(--sable-600);
|
||||
--ink-faint: var(--sable-500);
|
||||
|
||||
--line: var(--sable-200);
|
||||
--line-strong: var(--sable-300);
|
||||
|
||||
--accent: var(--vert-600);
|
||||
--accent-hover: var(--vert-700);
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: var(--vert-50);
|
||||
--accent-soft-ink: var(--vert-700);
|
||||
--accent-line: var(--vert-200);
|
||||
|
||||
--warn: var(--ambre-700);
|
||||
--warn-soft: var(--ambre-50);
|
||||
--warn-line: var(--ambre-200);
|
||||
|
||||
--shadow-color: 38 48 44;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--canvas: #121715;
|
||||
--canvas-alt: #171d1a;
|
||||
--surface: #1a211d;
|
||||
--surface-alt: #212a25;
|
||||
--surface-inset: #151b18;
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--ink: #e9e7e0;
|
||||
--ink-soft: #c9c8c0;
|
||||
--ink-muted: #a3a89f;
|
||||
--ink-faint: #868b83;
|
||||
|
||||
--line: #2b342f;
|
||||
--line-strong: #3a453f;
|
||||
|
||||
--accent: var(--vert-300);
|
||||
--accent-hover: var(--vert-200);
|
||||
--accent-ink: var(--vert-950);
|
||||
--accent-soft: #1c2a24;
|
||||
--accent-soft-ink: var(--vert-200);
|
||||
--accent-line: #2f4239;
|
||||
|
||||
--warn: var(--ambre-300);
|
||||
--warn-soft: #2a2318;
|
||||
--warn-line: #453a25;
|
||||
|
||||
--pilier-maison: #6faa8f;
|
||||
--pilier-vehicules: #c99a63;
|
||||
--pilier-papiers: #85a9d3;
|
||||
--pilier-animaux: #cf8ab4;
|
||||
|
||||
--shadow-color: 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
main { max-width: 680px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
main.wide { max-width: 1080px; }
|
||||
/* ── 3. Exposition à Tailwind ─────────────────────────────────────────────
|
||||
`inline` est indispensable : sans lui, Tailwind fige la valeur au build et le
|
||||
basculement clair/sombre ne traverserait pas les utilitaires. */
|
||||
@theme inline {
|
||||
--color-canvas: var(--canvas);
|
||||
--color-canvas-alt: var(--canvas-alt);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-alt: var(--surface-alt);
|
||||
--color-surface-inset: var(--surface-inset);
|
||||
|
||||
a { color: inherit; }
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-soft: var(--ink-soft);
|
||||
--color-muted: var(--ink-muted);
|
||||
--color-faint: var(--ink-faint);
|
||||
|
||||
/* ── Navigation & pied de page ────────────────────────────────────────── */
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: rgba(250, 248, 244, 0.92);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.site-nav-inner {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 0.9rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.site-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.05rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.site-nav-links {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.site-nav-links a { text-decoration: none; color: var(--ink); }
|
||||
.site-nav-links a:hover { color: var(--accent); }
|
||||
.btn-sm { padding: 0.55rem 1.1rem; font-size: 0.9rem; }
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-soft-ink: var(--accent-soft-ink);
|
||||
--color-accent-line: var(--accent-line);
|
||||
|
||||
.site-footer { border-top: 1px solid var(--border); margin-top: 4rem; }
|
||||
.site-footer-inner {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.site-footer a { color: var(--accent); text-decoration: none; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
.site-footer-links { display: flex; gap: 1rem; }
|
||||
--color-warn: var(--warn);
|
||||
--color-warn-soft: var(--warn-soft);
|
||||
--color-warn-line: var(--warn-line);
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.site-nav-inner { flex-wrap: wrap; }
|
||||
.site-nav-links { order: 3; width: 100%; }
|
||||
.site-footer-inner { flex-direction: column; }
|
||||
--color-maison: var(--pilier-maison);
|
||||
--color-vehicules: var(--pilier-vehicules);
|
||||
--color-papiers: var(--pilier-papiers);
|
||||
--color-animaux: var(--pilier-animaux);
|
||||
|
||||
--font-display: var(--font-display), Georgia, "Times New Roman", serif;
|
||||
--font-sans: var(--font-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
/* Typo fluide : une seule échelle du mobile au desktop, pas de breakpoint à maintenir. */
|
||||
--text-hero: clamp(2.25rem, 1.55rem + 3.1vw, 3.75rem);
|
||||
--text-hero--line-height: 1.05;
|
||||
--text-hero--letter-spacing: -0.025em;
|
||||
--text-title: clamp(1.75rem, 1.35rem + 1.7vw, 2.5rem);
|
||||
--text-title--line-height: 1.12;
|
||||
--text-title--letter-spacing: -0.02em;
|
||||
--text-section: clamp(1.35rem, 1.15rem + 0.85vw, 1.75rem);
|
||||
--text-section--line-height: 1.2;
|
||||
--text-section--letter-spacing: -0.015em;
|
||||
--text-stat: clamp(3rem, 2rem + 4.5vw, 5.5rem);
|
||||
--text-stat--line-height: 0.95;
|
||||
--text-stat--letter-spacing: -0.035em;
|
||||
|
||||
--radius-card: 1rem;
|
||||
--radius-panel: 1.5rem;
|
||||
|
||||
--shadow-soft: 0 1px 2px rgb(var(--shadow-color) / 0.04), 0 8px 24px -14px rgb(var(--shadow-color) / 0.18);
|
||||
--shadow-lift: 0 2px 4px rgb(var(--shadow-color) / 0.05), 0 16px 40px -18px rgb(var(--shadow-color) / 0.28);
|
||||
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; line-height: 1.2; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 1.3rem; margin: 2.5rem 0 0.75rem; }
|
||||
h3 { font-size: 1.05rem; }
|
||||
p { margin: 0.75rem 0; }
|
||||
.muted { color: var(--muted); font-size: 0.925rem; }
|
||||
.small { font-size: 0.85rem; }
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Base
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.btn-ghost { background: none; color: var(--muted); text-decoration: underline; font-weight: 400; }
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem 1.4rem;
|
||||
margin: 1rem 0;
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: "kern", "liga";
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display), Georgia, serif;
|
||||
font-weight: 600;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Chiffres alignés dans les tableaux, compteurs et timelines. */
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--accent-soft);
|
||||
color: var(--accent-soft-ink);
|
||||
}
|
||||
|
||||
/* Le mouvement n'est jamais porteur d'information ici : on peut tout couper. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 1rem 0; }
|
||||
.chip {
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.chip.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Utilitaires de projet
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
input[type="text"], input[type="email"], select {
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 1rem;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
||||
|
||||
.progress { height: 4px; background: var(--border); border-radius: 2px; margin-bottom: 2rem; }
|
||||
.progress > div { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }
|
||||
|
||||
.pilier-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
@utility surface-card {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.enjeu-icon { vertical-align: -2px; margin-right: 0.4rem; color: var(--muted); }
|
||||
.deadline { border-top: 1px solid var(--border); padding: 0.9rem 0; }
|
||||
.deadline-locked { opacity: 0.65; }
|
||||
.deadline-locked .titre { font-weight: 500; }
|
||||
.deadline:first-child { border-top: none; }
|
||||
.deadline .titre { font-weight: 600; }
|
||||
.deadline .fenetre { color: var(--accent); font-weight: 600; font-size: 0.9rem; }
|
||||
|
||||
.etat {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 14px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.etat .check { font-size: 2.2rem; }
|
||||
|
||||
.waiver-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin: 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.waiver-checkbox input { width: auto; margin-top: 0.2rem; flex-shrink: 0; }
|
||||
|
||||
.suggestion {
|
||||
background: var(--accent-soft);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.footer-note { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
|
||||
|
||||
.mail-demo { font-size: 0.92rem; }
|
||||
.mail-demo .objet { font-weight: 700; }
|
||||
.mail-demo .actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.6rem; }
|
||||
.mail-demo .actions span {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* ── Landing : hero ────────────────────────────────────────────────────── */
|
||||
|
||||
.accent-text { color: var(--accent); }
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
}
|
||||
.lede { font-size: 1.08rem; color: var(--muted); max-width: 42ch; }
|
||||
.hero-cta { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1.5rem; }
|
||||
.btn-lg { padding: 1rem 1.6rem; font-size: 1.02rem; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
padding: 2.5rem 0 1.5rem;
|
||||
}
|
||||
.hero h1 { margin-top: 0.9rem; font-size: 2.4rem; }
|
||||
|
||||
.hero-visual { position: relative; display: flex; flex-direction: column; align-items: center; gap: 1rem; }
|
||||
.hero-blob {
|
||||
/* Le lien de contournement du menu : visible uniquement au clavier. */
|
||||
@utility skip-link {
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
background: radial-gradient(circle at 30% 30%, var(--accent-soft), transparent 70%);
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
.hero-pilier-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
.hero-pilier-tile {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.1rem 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
left: 50%;
|
||||
top: 0.5rem;
|
||||
translate: -50% -200%;
|
||||
z-index: 100;
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--tile-color, var(--accent));
|
||||
box-shadow: 0 8px 24px -12px rgba(38, 48, 44, 0.15);
|
||||
}
|
||||
.hero-pilier-tile span { color: var(--ink); }
|
||||
.hero-visual-caption { color: var(--muted); font-size: 0.82rem; position: relative; z-index: 1; }
|
||||
transition: translate 0.18s var(--ease-out-soft);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.hero { grid-template-columns: 1fr; padding-top: 1.5rem; }
|
||||
.hero h1 { font-size: 1.9rem; }
|
||||
.hero-visual { order: -1; }
|
||||
.hero-pilier-grid { max-width: 340px; margin: 0 auto; }
|
||||
&:focus {
|
||||
translate: -50% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Landing : démonstration mail ─────────────────────────────────────── */
|
||||
/* Prose des guides MDX — le seul endroit où l'on style du HTML non maîtrisé. */
|
||||
@utility prose-fokan {
|
||||
color: var(--ink-soft);
|
||||
|
||||
.demo-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; }
|
||||
.mail-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--warn);
|
||||
background: #f4ead4;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
& h1 {
|
||||
font-size: var(--text-title);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
margin-block: 1.5rem 1rem;
|
||||
}
|
||||
& h2 {
|
||||
font-size: var(--text-section);
|
||||
line-height: 1.2;
|
||||
color: var(--ink);
|
||||
margin-block: 2.25rem 0.75rem;
|
||||
}
|
||||
& h3 {
|
||||
font-family: var(--font-display), Georgia, serif;
|
||||
font-size: 1.15rem;
|
||||
color: var(--ink);
|
||||
margin-block: 1.75rem 0.5rem;
|
||||
}
|
||||
& p { margin-block: 0.85rem; }
|
||||
& ul, & ol { margin-block: 0.85rem; padding-inline-start: 1.4rem; }
|
||||
& ul { list-style: disc; }
|
||||
& ol { list-style: decimal; }
|
||||
& li { margin-block: 0.35rem; }
|
||||
& li::marker { color: var(--accent); }
|
||||
& strong { color: var(--ink); font-weight: 650; }
|
||||
& a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
& table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-block: 1.25rem;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
& th, & td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.55rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
& th { background-color: var(--surface-inset); color: var(--ink); font-weight: 600; }
|
||||
& blockquote {
|
||||
border-inline-start: 3px solid var(--accent-line);
|
||||
padding-inline-start: 1rem;
|
||||
margin-block: 1.25rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Landing : étapes ──────────────────────────────────────────────────── */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Animations (toutes neutralisées par prefers-reduced-motion)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.steps-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.step {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.4rem 1.2rem 1.2rem;
|
||||
}
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.7rem;
|
||||
@keyframes fokan-rise {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── Landing : prix ────────────────────────────────────────────────────── */
|
||||
|
||||
.pricing-card { display: grid; gap: 1.1rem; }
|
||||
.pricing-tier { font-weight: 700; font-size: 1.05rem; margin-bottom: 0.2rem; }
|
||||
|
||||
/* ── Guides : cartes ───────────────────────────────────────────────────── */
|
||||
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||||
.guide-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
|
||||
@keyframes fokan-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.guide-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 10px 28px -16px rgba(38, 48, 44, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.guide-card-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--tile-color, var(--accent)) 14%, var(--card));
|
||||
color: var(--tile-color, var(--accent));
|
||||
}
|
||||
.guide-card-title { font-weight: 700; font-size: 0.98rem; line-height: 1.3; }
|
||||
|
||||
.guide-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.15rem;
|
||||
@keyframes fokan-marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
.guide-section-title .muted { margin-left: auto; font-weight: 400; }
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-top: 1rem;
|
||||
@keyframes fokan-pulse-soft {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.breadcrumb a { color: var(--muted); text-decoration: none; }
|
||||
.breadcrumb a:hover { color: var(--accent); }
|
||||
.breadcrumb span { font-weight: 600; }
|
||||
|
||||
.enjeu-callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
background: #f4ead4;
|
||||
color: var(--warn);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem 1.1rem;
|
||||
margin: 1.25rem 0;
|
||||
@utility animate-rise {
|
||||
animation: fokan-rise 0.5s var(--ease-out-soft) both;
|
||||
}
|
||||
.enjeu-callout-label { font-weight: 700; font-size: 0.9rem; color: var(--warn); }
|
||||
.enjeu-callout .muted { color: #7a6236; }
|
||||
|
||||
.article h1 { font-size: 1.7rem; margin-bottom: 1rem; margin-top: 1rem; }
|
||||
.article h2 { font-size: 1.25rem; margin: 2rem 0 0.5rem; }
|
||||
.article ul, .article ol { margin: 0.75rem 0 0.75rem 1.4rem; }
|
||||
.article li { margin: 0.35rem 0; }
|
||||
.article strong { color: var(--accent); }
|
||||
.guides-links { display: flex; flex-direction: column; gap: 0.4rem; margin: 1rem 0; }
|
||||
@utility animate-fade {
|
||||
animation: fokan-fade 0.4s var(--ease-out-soft) both;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { notFound } from "next/navigation";
|
|||
import { ArrowRight, ChevronRight } from "lucide-react";
|
||||
import { loadSeoPages } from "@/knowledge/loader";
|
||||
import { PILIER_META, ENJEU_META } from "@/lib/pilier-meta";
|
||||
import { Container } from "@/components/ui/primitives";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return loadSeoPages().map((p) => ({ slug: p.slug }));
|
||||
|
|
@ -17,6 +19,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|||
title: page.titre,
|
||||
description: page.metaDescription,
|
||||
alternates: { canonical: `/guide/${page.slug}` },
|
||||
openGraph: { title: page.titre, description: page.metaDescription, type: "article" as const },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -38,55 +41,87 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
|
|||
.filter((p) => p.template.pilier === page.template.pilier && p.slug !== page.slug)
|
||||
.slice(0, 3);
|
||||
|
||||
/* Balisage Article : le canal n°1 est le SEO longue traîne (§ 11.2), les données
|
||||
structurées ne sont pas un bonus mais une part du travail. */
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: page.titre,
|
||||
description: page.metaDescription,
|
||||
inLanguage: "fr-FR",
|
||||
articleSection: pilier.label,
|
||||
isAccessibleForFree: true,
|
||||
publisher: { "@type": "Organization", name: "fokan" },
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="breadcrumb">
|
||||
<Link href="/guide">Guides</Link>
|
||||
<ChevronRight size={14} />
|
||||
<span style={{ color: pilier.color }}>
|
||||
<pilier.Icon size={14} strokeWidth={2.25} style={{ verticalAlign: "-2px", marginRight: "0.25rem" }} />
|
||||
{pilier.label}
|
||||
</span>
|
||||
</p>
|
||||
<main className="pb-8">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
|
||||
{enjeu && (
|
||||
<div className="enjeu-callout">
|
||||
<EnjeuIcon size={18} strokeWidth={2.25} />
|
||||
<div>
|
||||
<p className="enjeu-callout-label">{enjeu.label}</p>
|
||||
<p className="muted small">{page.template.enjeu.resume}</p>
|
||||
<Container width="narrow" className="pt-10">
|
||||
<nav aria-label="Fil d'Ariane" className="flex items-center gap-1.5 text-sm text-muted">
|
||||
<Link href="/guide" className="no-underline hover:text-accent">Guides</Link>
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
<span className="inline-flex items-center gap-1.5 font-medium" style={{ color: pilier.color }}>
|
||||
<pilier.Icon size={14} strokeWidth={2.25} aria-hidden />
|
||||
{pilier.label}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{enjeu && (
|
||||
<div className="mt-6 flex items-start gap-3 rounded-xl border border-warn-line bg-warn-soft px-4 py-3.5">
|
||||
<EnjeuIcon size={18} strokeWidth={2.25} className="mt-0.5 shrink-0 text-warn" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-warn">{enjeu.label}</p>
|
||||
<p className="mt-0.5 text-[0.925rem] text-warn/85">{page.template.enjeu.resume}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<article className="article" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<article className="prose-fokan mt-6" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
|
||||
<div className="card" style={{ textAlign: "center", marginTop: "2.5rem" }}>
|
||||
<p><strong>Cette échéance, et les 30 autres de votre foyer.</strong></p>
|
||||
<p className="muted">Chaudière, contrôle technique, papiers, animaux : répondez à 8 questions
|
||||
faciles, on génère le calendrier complet — et on peut monter la garde pour vous.</p>
|
||||
<p><Link href="/quiz" className="btn btn-primary">Voir les échéances de mon foyer — 2 min</Link></p>
|
||||
</div>
|
||||
<aside className="surface-card mt-12 bg-surface-alt p-6 text-center sm:p-8">
|
||||
<p className="font-display text-xl font-semibold text-balance">
|
||||
Cette échéance, et toutes les autres de ton foyer.
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-muted">
|
||||
Chaudière, contrôle technique, papiers, animaux : 8 questions faciles, et on te dit
|
||||
exactement ce qui te concerne — puis on monte la garde.
|
||||
</p>
|
||||
<Link href="/quiz" className={buttonClass("primary", "lg", "mt-5")}>
|
||||
Voir les échéances de mon foyer
|
||||
<ArrowRight size={17} aria-hidden />
|
||||
</Link>
|
||||
<p className="mt-3 text-xs text-muted">2 minutes, sans compte, sans carte bancaire.</p>
|
||||
</aside>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section style={{ marginTop: "2.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.05rem" }}>À lire aussi — {pilier.label}</h2>
|
||||
<div className="card-grid">
|
||||
{related.map((p) => (
|
||||
<Link href={`/guide/${p.slug}`} className="guide-card" key={p.slug}>
|
||||
<span className="guide-card-title">{p.titre}</span>
|
||||
<span className="muted small">{p.metaDescription}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{related.length > 0 && (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-lg">À lire aussi — {pilier.label}</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{related.map((p) => (
|
||||
<Link
|
||||
href={`/guide/${p.slug}`}
|
||||
key={p.slug}
|
||||
className="surface-card group flex flex-col gap-1.5 p-4 no-underline transition-all duration-150 hover:-translate-y-0.5 hover:border-accent hover:shadow-lift"
|
||||
>
|
||||
<span className="font-display font-semibold text-ink group-hover:text-accent">{p.titre}</span>
|
||||
<span className="line-clamp-2 text-sm text-muted">{p.metaDescription}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="footer-note muted small">
|
||||
Référence : {page.template.source ?? "sources officielles"}. Contenu vérifié,
|
||||
mis à jour quand la règle change — c'est notre métier.{" "}
|
||||
<Link href="/guide">Tous les guides <ArrowRight size={12} style={{ verticalAlign: "-1px" }} /></Link>
|
||||
</p>
|
||||
<p className="mt-12 border-t border-line pt-5 text-sm text-muted">
|
||||
Référence : {page.template.source ?? "sources officielles"}. Contenu vérifié et mis à
|
||||
jour quand la règle change — c'est notre métier.{" "}
|
||||
<Link href="/guide" className="inline-flex items-center gap-1 font-medium text-accent underline underline-offset-2">
|
||||
Tous les guides
|
||||
<ArrowRight size={12} aria-hidden />
|
||||
</Link>
|
||||
</p>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Lock } from "lucide-react";
|
||||
import { loadSeoPages } from "@/knowledge/loader";
|
||||
import { PILIER_META } from "@/lib/pilier-meta";
|
||||
import { PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, memberships as membershipsTable, households as householdsTable } from "@/db";
|
||||
import type { Pilier } from "@/knowledge/schema";
|
||||
import { Container, Eyebrow, IconTile, Lede } from "@/components/ui/primitives";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "Guides des échéances du foyer — fokan",
|
||||
title: "Guides des échéances du foyer",
|
||||
description:
|
||||
"Contrôle technique, ramonage, chaudière, passeports : les règles, fréquences et sanctions des échéances que personne ne retient.",
|
||||
alternates: { canonical: "/guide" },
|
||||
};
|
||||
|
||||
const PILIER_ORDER: Pilier[] = ["maison", "vehicules", "papiers", "animaux"];
|
||||
|
||||
/** Un compte est "abonné" si au moins un de ses foyers a un abonnement actif (§ 10.2). */
|
||||
async function hasActiveSubscription(): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
|
@ -41,69 +41,94 @@ export default async function GuidesIndex() {
|
|||
const unlocked = await hasActiveSubscription();
|
||||
|
||||
return (
|
||||
<main className="wide">
|
||||
<p className="eyebrow">Base de connaissance</p>
|
||||
<h1 style={{ marginTop: "0.9rem" }}>Les guides des échéances du foyer</h1>
|
||||
<p className="lede">
|
||||
Les règles que personne ne retient — vérifiées aux sources officielles, mises à jour
|
||||
quand la loi change.
|
||||
</p>
|
||||
<main className="pb-8">
|
||||
<Container width="wide" className="pt-12">
|
||||
<Eyebrow>Base de connaissance</Eyebrow>
|
||||
<h1 className="mt-5 text-title">Les guides des échéances du foyer</h1>
|
||||
<Lede className="mt-4 max-w-2xl">
|
||||
Les règles que personne ne retient — vérifiées aux sources officielles, mises à jour
|
||||
quand la loi change.
|
||||
</Lede>
|
||||
|
||||
{unlocked ? (
|
||||
parPilier.map(({ pilier, pages: piliersPages }) => {
|
||||
const { label, Icon, color } = PILIER_META[pilier];
|
||||
return (
|
||||
<section key={pilier} style={{ marginTop: "2.5rem" }}>
|
||||
<h2 className="guide-section-title">
|
||||
<span className="guide-card-icon" style={{ ["--tile-color" as string]: color }}>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</span>
|
||||
{label}
|
||||
<span className="muted small">{piliersPages.length} guide{piliersPages.length > 1 ? "s" : ""}</span>
|
||||
</h2>
|
||||
<div className="card-grid">
|
||||
{piliersPages.map((page) => (
|
||||
<Link href={`/guide/${page.slug}`} className="guide-card" key={page.slug}>
|
||||
<span className="guide-card-title">{page.titre}</span>
|
||||
<span className="muted small">{page.metaDescription}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<div className="hero-pilier-grid" style={{ maxWidth: "100%", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", margin: "2rem 0" }}>
|
||||
{PILIER_ORDER.map((pilier) => {
|
||||
{unlocked ? (
|
||||
<div className="mt-12 space-y-12">
|
||||
{parPilier.map(({ pilier, pages: piliersPages }) => {
|
||||
const { label, Icon, color } = PILIER_META[pilier];
|
||||
const count = parPilier.find((g) => g.pilier === pilier)?.pages.length ?? 0;
|
||||
return (
|
||||
<div className="hero-pilier-tile" key={pilier} style={{ ["--tile-color" as string]: color }}>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
<span>{label} · {count}</span>
|
||||
</div>
|
||||
<section key={pilier}>
|
||||
<h2 className="flex items-center gap-3">
|
||||
<IconTile color={color}>
|
||||
<Icon size={19} strokeWidth={2} aria-hidden />
|
||||
</IconTile>
|
||||
<span className="flex-1 text-lg">{label}</span>
|
||||
<span className="tnum text-sm font-normal text-muted">
|
||||
{piliersPages.length} guide{piliersPages.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{piliersPages.map((page) => (
|
||||
<Link
|
||||
href={`/guide/${page.slug}`}
|
||||
key={page.slug}
|
||||
className="surface-card group flex flex-col gap-2 p-5 no-underline transition-all duration-150 hover:-translate-y-0.5 hover:border-accent hover:shadow-lift"
|
||||
>
|
||||
<span className="font-display font-semibold text-ink group-hover:text-accent">
|
||||
{page.titre}
|
||||
</span>
|
||||
<span className="line-clamp-3 text-sm text-muted">{page.metaDescription}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Le volume est montré, le contenu ne l'est pas : le catalogue EST la valeur (§ 10.2). */}
|
||||
<ul className="mt-10 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{PILIER_ORDER.map((pilier) => {
|
||||
const { label, Icon, color } = PILIER_META[pilier];
|
||||
const count = parPilier.find((g) => g.pilier === pilier)?.pages.length ?? 0;
|
||||
return (
|
||||
<li key={pilier} className="surface-card flex flex-col items-center gap-2 p-5 text-center">
|
||||
<IconTile color={color}>
|
||||
<Icon size={20} strokeWidth={2} aria-hidden />
|
||||
</IconTile>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="tnum text-sm text-muted">
|
||||
{count} guide{count > 1 ? "s" : ""}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="card" style={{ textAlign: "center" }}>
|
||||
<p><Lock size={18} style={{ verticalAlign: "-3px", marginRight: "0.4rem", color: "var(--muted)" }} />
|
||||
<strong>Le catalogue complet ({pages.length} guides) est réservé aux abonnés.</strong></p>
|
||||
<p className="muted">
|
||||
Chaque guide reste trouvable individuellement (recherche, lien direct) — mais le
|
||||
parcourir en entier, savoir ce qu'on ne pense même pas à chercher, c'est
|
||||
exactement ce que la veille fait à ta place.
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/quiz" className="btn btn-primary btn-lg">
|
||||
Voir les échéances de mon foyer <ArrowRight size={18} />
|
||||
<div className="surface-card mt-6 bg-surface-alt p-6 text-center sm:p-8">
|
||||
<p className="inline-flex items-center gap-2 font-display text-lg font-semibold text-balance">
|
||||
<Lock size={17} className="shrink-0 text-muted" aria-hidden />
|
||||
Le catalogue complet ({pages.length} guides) est réservé aux abonnés
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted">
|
||||
Chaque guide reste trouvable individuellement — par une recherche, par un lien.
|
||||
Mais le parcourir en entier, c'est-à-dire découvrir ce qu'on ne pense
|
||||
même pas à chercher, c'est exactement ce que la veille fait à ta place.
|
||||
</p>
|
||||
<Link href="/quiz" className={buttonClass("primary", "lg", "mt-6")}>
|
||||
Voir les échéances de mon foyer
|
||||
<ArrowRight size={17} aria-hidden />
|
||||
</Link>
|
||||
</p>
|
||||
<p className="muted small">Déjà abonné ? <Link href="/connexion">Connecte-toi</Link>.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-muted">
|
||||
Déjà abonné ?{" "}
|
||||
<Link href="/connexion" className="font-medium text-accent underline underline-offset-2">
|
||||
Connecte-toi
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,37 @@
|
|||
import type { Metadata } from "next";
|
||||
import { SiteNav } from "@/components/nav";
|
||||
import { SiteFooter } from "@/components/footer";
|
||||
import { fontDisplay, fontSans } from "@/lib/fonts";
|
||||
import "./globals.css";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.g0tch.myds.me";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.g0tch.myds.me"),
|
||||
title: "fokan — la veille du foyer",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "fokan — la veille du foyer",
|
||||
template: "%s — fokan",
|
||||
},
|
||||
description:
|
||||
"Chaudière, contrôle technique, passeport des enfants : réponds à 8 questions, on monte la garde sur toutes les échéances de ton foyer.",
|
||||
applicationName: "fokan",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fr_FR",
|
||||
siteName: "fokan",
|
||||
url: SITE_URL,
|
||||
},
|
||||
twitter: { card: "summary_large_image" },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body>
|
||||
<html lang="fr" className={`${fontDisplay.variable} ${fontSans.variable}`}>
|
||||
<body className="flex min-h-dvh flex-col">
|
||||
<a href="#contenu" className="skip-link">Aller au contenu</a>
|
||||
<SiteNav />
|
||||
{children}
|
||||
<div id="contenu" className="flex-1">{children}</div>
|
||||
<SiteFooter />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,19 @@ import { eq } from "drizzle-orm";
|
|||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Home } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, memberships as membershipsTable, households as householdsTable } from "@/db";
|
||||
import { Container, IconTile } from "@/components/ui/primitives";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "Tes foyers",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function MesFoyers() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/connexion");
|
||||
|
|
@ -19,24 +27,40 @@ export default async function MesFoyers() {
|
|||
|
||||
return (
|
||||
<main>
|
||||
<h1>Tes foyers</h1>
|
||||
<p className="muted">Connecté comme {session.user.email}.</p>
|
||||
<Container width="default" className="py-16">
|
||||
<h1 className="text-title">Tes foyers</h1>
|
||||
<p className="mt-2 text-muted">Connecté comme {session.user.email}.</p>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="muted">Tu n'es encore membre d'aucun foyer. Si on t'a
|
||||
invité·e, vérifie le lien reçu par mail — sinon, fais le quiz pour créer le tien.</p>
|
||||
<p><Link href="/quiz" className="btn btn-primary">Faire le quiz</Link></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
{rows.map((r) => (
|
||||
<div className="deadline" key={r.householdId}>
|
||||
<p><Link href={`/foyer/${r.householdId}`} className="titre">Voir ce foyer</Link></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<div className="surface-card mt-8 p-6">
|
||||
<p className="text-muted">
|
||||
Tu n'es encore membre d'aucun foyer. Si on t'a invité·e, vérifie le
|
||||
lien reçu par mail — sinon, le quiz crée le tien en deux minutes.
|
||||
</p>
|
||||
<Link href="/quiz" className={buttonClass("primary", "md", "mt-5")}>
|
||||
Faire le quiz
|
||||
<ArrowRight size={17} aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{rows.map((r) => (
|
||||
<li key={r.householdId}>
|
||||
<Link
|
||||
href={`/foyer/${r.householdId}`}
|
||||
className="surface-card group flex items-center gap-4 p-5 no-underline transition-all duration-150 hover:-translate-y-0.5 hover:border-accent hover:shadow-lift"
|
||||
>
|
||||
<IconTile color="var(--accent)">
|
||||
<Home size={19} strokeWidth={2} aria-hidden />
|
||||
</IconTile>
|
||||
<span className="flex-1 font-medium group-hover:text-accent">Voir ce foyer</span>
|
||||
<ArrowRight size={17} className="text-faint group-hover:text-accent" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
78
src/app/opengraph-image.tsx
Normal file
78
src/app/opengraph-image.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { ImageResponse } from "next/og";
|
||||
|
||||
/**
|
||||
* Vignette de partage. Volontairement sobre et sans promesse chiffrée : elle circule hors
|
||||
* contexte (Slack, SMS, LinkedIn) et doit rester vraie dans un an sans être régénérée.
|
||||
*/
|
||||
|
||||
export const alt = "fokan — la veille du foyer";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function OpengraphImage() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
background: "#faf8f4",
|
||||
padding: "72px 80px",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, color: "#2e6650" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
background: "#2e6650",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#faf8f4",
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
f
|
||||
</div>
|
||||
<div style={{ fontSize: 34, fontWeight: 700, letterSpacing: "-0.02em" }}>fokan</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 68,
|
||||
lineHeight: 1.08,
|
||||
letterSpacing: "-0.03em",
|
||||
color: "#26302c",
|
||||
maxWidth: 900,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<span>C'est normal de ne pas</span>
|
||||
<span>tout retenir.</span>
|
||||
<span style={{ color: "#2e6650" }}>On est là pour ça.</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 28, color: "#635d52", maxWidth: 820 }}>
|
||||
Chaudière, contrôle technique, passeports, animaux : la veille des échéances de ton
|
||||
foyer.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
{["#2e6650", "#8a5a2b", "#3a5f8a", "#8a3b6b"].map((c) => (
|
||||
<div key={c} style={{ width: 84, height: 8, borderRadius: 999, background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
361
src/app/page.tsx
361
src/app/page.tsx
|
|
@ -1,112 +1,291 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Check, Scale, Euro, ShieldAlert } from "lucide-react";
|
||||
import { PILIER_META } from "@/lib/pilier-meta";
|
||||
import {
|
||||
ArrowRight,
|
||||
BellOff,
|
||||
FileX2,
|
||||
Landmark,
|
||||
ListX,
|
||||
MailCheck,
|
||||
Scan,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
} from "lucide-react";
|
||||
import { Band, Container, Eyebrow, IconTile, Lede, SectionTitle } from "@/components/ui/primitives";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
import { TimelineAnnee, LegendePiliers, type PointAnnee } from "@/components/timeline-annee";
|
||||
import { MurEcheances, compteEcheances } from "@/components/landing/mur-echeances";
|
||||
import { PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "fokan — la veille du foyer",
|
||||
description:
|
||||
"Chaudière, contrôle technique, passeport des enfants, vaccins du chat. Réponds à 8 questions faciles : on monte la garde sur toutes les échéances de ton foyer et on te prévient au bon moment.",
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
title: "fokan — la veille du foyer",
|
||||
description:
|
||||
"C'est normal de ne pas tout retenir. Réponds à 8 questions, on surveille les échéances de ton foyer toute l'année.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Distribution d'illustration du hero. Volontairement figée (jamais aléatoire) : une
|
||||
* démo qui change à chaque rendu casse la confiance, et le hero n'est pas une donnée.
|
||||
*/
|
||||
const APERCU: PointAnnee[] = [
|
||||
[0, "vehicules"], [0, "papiers"], [1, "maison"], [1, "vehicules"], [1, "animaux"],
|
||||
[2, "papiers"], [2, "maison"], [2, "maison"], [3, "animaux"], [3, "maison"],
|
||||
[4, "maison"], [4, "papiers"], [4, "vehicules"], [5, "animaux"], [5, "maison"],
|
||||
[6, "papiers"], [7, "maison"], [8, "maison"], [8, "maison"], [8, "vehicules"],
|
||||
[8, "papiers"], [9, "maison"], [9, "animaux"], [9, "papiers"], [10, "maison"],
|
||||
[10, "vehicules"], [10, "maison"], [11, "papiers"], [11, "animaux"], [11, "maison"],
|
||||
].map(([mois, pilier]) => ({ mois: mois as number, pilier: pilier as PointAnnee["pilier"] }));
|
||||
|
||||
const GARANTIES = [
|
||||
{ Icon: Landmark, texte: "Hébergé en France" },
|
||||
{ Icon: ShieldCheck, texte: "Vérifié aux sources officielles" },
|
||||
{ Icon: BellOff, texte: "Aucune publicité" },
|
||||
{ Icon: FileX2, texte: "Aucun document stocké" },
|
||||
];
|
||||
|
||||
const REFUS = [
|
||||
{ Icon: ListX, titre: "Pas une to-do list", texte: "Rien à cocher, rien à tenir à jour. Une échéance ignorée glisse en silence." },
|
||||
{ Icon: Scan, titre: "Pas un coffre-fort", texte: "On indexe une date, une info, jamais un fichier. Tes documents restent chez toi." },
|
||||
{ Icon: Trophy, titre: "Pas de score", texte: "Aucun badge, aucun retard, aucune série à ne pas briser. Aucune culpabilité." },
|
||||
{ Icon: MailCheck, titre: "Pas une appli à ouvrir", texte: "Ça marche même pour quelqu'un qui a oublié qu'on existe. C'est le principe." },
|
||||
];
|
||||
|
||||
export default function Landing() {
|
||||
const { fiches, parPilier } = compteEcheances();
|
||||
const compte = { maison: 14, vehicules: 5, papiers: 7, animaux: 4 };
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="wide">
|
||||
{/* ── Hero ────────────────────────────────────────────────────── */}
|
||||
<section className="hero">
|
||||
<div className="hero-copy">
|
||||
<p className="eyebrow">La veille du foyer</p>
|
||||
<h1>
|
||||
Personne ne retient tout ça — c'est normal.
|
||||
<br />
|
||||
<span className="accent-text">Nous, c'est notre métier.</span>
|
||||
<main>
|
||||
{/* ── Hero ──────────────────────────────────────────────────────── */}
|
||||
<Container width="wide" className="pt-12 pb-14 sm:pt-20 sm:pb-20">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:gap-16">
|
||||
<div className="animate-rise">
|
||||
<Eyebrow>La veille du foyer</Eyebrow>
|
||||
<h1 className="mt-5 text-hero">
|
||||
C'est normal de ne pas<br className="hidden sm:inline" /> tout retenir.
|
||||
<span className="block text-accent">On est là pour ça.</span>
|
||||
</h1>
|
||||
<p className="lede">
|
||||
Chaudière, contrôle technique, passeport des enfants, vaccins du chat :
|
||||
réponds à 8 questions faciles, on monte la garde sur toutes les échéances
|
||||
de ton foyer. Par mail, au bon moment, avec l'action incluse.
|
||||
</p>
|
||||
<p className="hero-cta">
|
||||
<Link href="/quiz" className="btn btn-primary btn-lg">
|
||||
Faire le quiz — 2 minutes <ArrowRight size={18} />
|
||||
<Lede className="mt-6 max-w-xl">
|
||||
Chaudière, contrôle technique, passeport des enfants, vaccins du chat. Réponds à
|
||||
8 questions faciles : on dresse le calendrier complet de ton foyer, et on te
|
||||
prévient au bon moment. Le reste du temps, on se tait.
|
||||
</Lede>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Link href="/quiz" className={buttonClass("primary", "lg")}>
|
||||
Faire le quiz — 2 minutes
|
||||
<ArrowRight size={18} aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted">
|
||||
Aperçu gratuit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hero-visual" aria-hidden="true">
|
||||
<div className="hero-blob" />
|
||||
<div className="hero-pilier-grid">
|
||||
{(Object.entries(PILIER_META) as [string, (typeof PILIER_META)[keyof typeof PILIER_META]][]).map(
|
||||
([key, { label, Icon, color }]) => (
|
||||
<div className="hero-pilier-tile" key={key} style={{ ["--tile-color" as string]: color }}>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{/* La timeline dès le hero : le produit se montre avant de se raconter. */}
|
||||
<div className="animate-rise [animation-delay:120ms]">
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -inset-8 -z-10 rounded-full opacity-70 blur-2xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 35% 30%, var(--accent-soft), transparent 68%)",
|
||||
}}
|
||||
/>
|
||||
<div className="surface-card overflow-hidden shadow-lift">
|
||||
<div className="flex items-baseline justify-between border-b border-line px-5 py-4">
|
||||
<p className="font-display text-lg font-semibold">Ton année, d'un coup d'œil</p>
|
||||
<p className="tnum text-sm text-muted">30 échéances</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<TimelineAnnee points={APERCU} moisCourant={new Date().getMonth()} className="border-0 bg-transparent p-0" />
|
||||
<LegendePiliers compte={compte} className="mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-visual-caption">4 domaines, un seul endroit qui y pense</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Démonstration ───────────────────────────────────────────── */}
|
||||
<h2>Ce qu'on t'évite d'oublier</h2>
|
||||
<div className="demo-grid">
|
||||
<div className="card mail-demo">
|
||||
<span className="mail-badge"><ShieldAlert size={14} /> Obligatoire · assurance</span>
|
||||
<p className="objet">Ta chaudière gaz mérite son entretien annuel avant l'hiver</p>
|
||||
<p className="muted">Obligatoire — et ton assurance peut exiger l'attestation en cas de sinistre.
|
||||
Si c'est déjà fait, dis-le-moi et je me tais.</p>
|
||||
<div className="actions"><span>✓ Fait</span><span>Déjà fait</span><span>3 chauffagistes près de chez toi</span></div>
|
||||
</div>
|
||||
<div className="card mail-demo">
|
||||
<span className="mail-badge"><Scale size={14} /> Obligatoire</span>
|
||||
<p className="objet">Le contrôle technique de la 308 est à faire avant le 12 mars</p>
|
||||
<p className="muted">135 € d'amende sinon. Les délais s'allongent en février.</p>
|
||||
<div className="actions"><span>Prendre rdv</span><span>✓ Fait</span></div>
|
||||
</div>
|
||||
<div className="card mail-demo">
|
||||
<span className="mail-badge"><Euro size={14} /> Ça peut coûter cher</span>
|
||||
<p className="objet">Le passeport de ton enfant expire en novembre</p>
|
||||
<p className="muted">Valable 5 ans seulement pour un enfant. Certains pays exigent 6 mois de
|
||||
validité : pour partir cet été, c'est maintenant — les rdv mairie prennent 2-4 mois.</p>
|
||||
<div className="actions"><span>Trouver un créneau</span></div>
|
||||
<p className="mt-3 text-center text-sm text-muted">
|
||||
Exemple : une maison, une voiture, deux enfants, un chat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{/* ── Comment ça marche ───────────────────────────────────────── */}
|
||||
<h2>Comment ça marche</h2>
|
||||
<div className="steps-grid">
|
||||
<div className="step">
|
||||
<span className="step-number">1</span>
|
||||
<p><strong>Tu réponds à 8 questions faciles</strong> — des choses que tu sais sans chercher.</p>
|
||||
</div>
|
||||
<div className="step">
|
||||
<span className="step-number">2</span>
|
||||
<p><strong>On génère le calendrier complet</strong> de ton foyer : 30 à 45 échéances, instantanément.</p>
|
||||
</div>
|
||||
<div className="step">
|
||||
<span className="step-number">3</span>
|
||||
<p><strong>On te prévient par mail au bon moment</strong>, avec le premier pas de l'action.
|
||||
Le reste du temps, on se tait.</p>
|
||||
{/* ── Bandeau de garanties ──────────────────────────────────────── */}
|
||||
<div className="border-y border-line bg-surface">
|
||||
<Container width="wide">
|
||||
<ul className="grid grid-cols-2 gap-x-6 gap-y-3 py-5 sm:grid-cols-4">
|
||||
{GARANTIES.map(({ Icon, texte }) => (
|
||||
<li key={texte} className="flex items-center gap-2 text-sm text-muted">
|
||||
<Icon size={16} className="shrink-0 text-accent" aria-hidden />
|
||||
{texte}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/* ── Le mur d'échéances ────────────────────────────────────────── */}
|
||||
<Band tone="alt">
|
||||
<Container width="wide">
|
||||
<div className="max-w-2xl">
|
||||
<SectionTitle>Ce que personne ne retient</SectionTitle>
|
||||
<Lede className="mt-3">
|
||||
La charge mentale, ce n'est pas d'oublier une date qu'on connaît.
|
||||
C'est de ne pas savoir qu'il fallait y penser.
|
||||
</Lede>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="mt-10">
|
||||
<MurEcheances />
|
||||
</div>
|
||||
<Container width="wide">
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
||||
<LegendePiliers compte={parPilier} />
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-muted">
|
||||
<span className="tnum font-semibold text-ink">{fiches} obligations</span> suivies
|
||||
aujourd'hui, sur 4 domaines — et la liste s'allonge à chaque évolution de la
|
||||
réglementation, et à chacun de nos ajouts.
|
||||
</p>
|
||||
</Container>
|
||||
</Band>
|
||||
|
||||
{/* ── Ce qu'on n'est pas ──────────────────────────────────────── */}
|
||||
<h2>Ce qu'on n'est pas</h2>
|
||||
<p className="muted">
|
||||
Pas une to-do list. Pas une appli à ouvrir. Pas de badge, pas de retard, pas de
|
||||
culpabilité. Si tu ignores un rappel, il s'efface en silence. Ça marche même
|
||||
pour quelqu'un qui a oublié qu'on existe — c'est le principe.
|
||||
</p>
|
||||
{/* ── Comment ça marche ─────────────────────────────────────────── */}
|
||||
<Container width="wide" className="py-16 sm:py-24">
|
||||
<SectionTitle>Comment ça marche</SectionTitle>
|
||||
<ol className="mt-8 grid gap-5 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
titre: "Tu réponds à 8 questions faciles",
|
||||
texte:
|
||||
"Des choses que tu sais sans chercher. Jamais « quand as-tu fait ramoner ? » — ça, c'est notre problème, pas ta mémoire.",
|
||||
},
|
||||
{
|
||||
titre: "On dresse le calendrier de ton foyer",
|
||||
texte:
|
||||
"Instantanément. Ce qui te concerne, ce qui ne te concerne pas, et à quel moment de l'année ça tombe.",
|
||||
},
|
||||
{
|
||||
titre: "On te prévient au bon moment",
|
||||
texte:
|
||||
"Par mail, avec le premier pas de l'action. Quelques envois par an, jamais plus. Quand ça sonne, c'est que ça compte.",
|
||||
},
|
||||
].map((etape, i) => (
|
||||
<li key={etape.titre} className="surface-card p-6">
|
||||
<span className="tnum inline-flex size-8 items-center justify-center rounded-full bg-accent font-display text-sm font-bold text-accent-ink">
|
||||
{i + 1}
|
||||
</span>
|
||||
<h3 className="mt-4 text-lg">{etape.titre}</h3>
|
||||
<p className="mt-2 text-[0.95rem] text-muted">{etape.texte}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Container>
|
||||
|
||||
<p style={{ margin: "2rem 0" }}>
|
||||
<Link href="/quiz" className="btn btn-primary btn-lg">
|
||||
Voir les échéances de mon foyer <ArrowRight size={18} />
|
||||
{/* ── Pourquoi ce n'est pas gratuit ─────────────────────────────── */}
|
||||
<Band tone="accent">
|
||||
<Container width="wide">
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_auto] lg:items-center lg:gap-16">
|
||||
<div className="max-w-2xl">
|
||||
<Eyebrow className="bg-surface">
|
||||
<Sparkles size={13} aria-hidden />
|
||||
Le modèle, sans détour
|
||||
</Eyebrow>
|
||||
<SectionTitle className="mt-5">Pourquoi ce n'est pas gratuit</SectionTitle>
|
||||
<div className="mt-5 space-y-4 text-[1.05rem] leading-relaxed text-ink-soft">
|
||||
<p>
|
||||
Parce qu'un service gratuit se paie toujours — en publicité ou revente de
|
||||
données. <strong className="text-ink">Ici, tu es le client, pas le
|
||||
produit.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface-card w-full shrink-0 p-7 shadow-lift lg:w-80">
|
||||
<p className="text-sm font-medium text-muted">La Veille</p>
|
||||
<p className="mt-1 flex items-baseline gap-1.5">
|
||||
<span className="tnum font-display text-5xl font-semibold tracking-tight">19,99 €</span>
|
||||
<span className="text-muted">/ an</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">Soit 1,66 € par mois, pour tout le foyer.</p>
|
||||
<ul className="mt-5 space-y-2.5 border-t border-line pt-5 text-[0.95rem] text-ink-soft">
|
||||
{[
|
||||
"Le calendrier complet, dates et fenêtres",
|
||||
"Les rappels par mail, action incluse",
|
||||
"Le catalogue de guides en accès libre",
|
||||
"Les nouvelles obligations ajoutées sans rien faire",
|
||||
].map((l) => (
|
||||
<li key={l} className="flex gap-2.5">
|
||||
<ShieldCheck size={17} className="mt-0.5 shrink-0 text-accent" aria-hidden />
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/quiz" className={buttonClass("primary", "md", "mt-6 w-full")}>
|
||||
Voir mon foyer d'abord
|
||||
</Link>
|
||||
<p className="mt-3 text-center text-xs text-muted">
|
||||
Le quiz et l'aperçu sont gratuits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Band>
|
||||
|
||||
{/* ── Ce qu'on n'est pas ────────────────────────────────────────── */}
|
||||
<Container width="wide" className="py-16 sm:py-24">
|
||||
<div className="max-w-2xl">
|
||||
<SectionTitle>Ce qu'on n'est pas</SectionTitle>
|
||||
<Lede className="mt-3">
|
||||
Un produit se définit autant par ce qu'il se refuse.
|
||||
</Lede>
|
||||
</div>
|
||||
<ul className="mt-8 grid gap-5 sm:grid-cols-2">
|
||||
{REFUS.map(({ Icon, titre, texte }) => (
|
||||
<li key={titre} className="flex gap-4">
|
||||
<IconTile color="var(--ink-muted)">
|
||||
<Icon size={19} strokeWidth={2} aria-hidden />
|
||||
</IconTile>
|
||||
<div>
|
||||
<h3 className="text-base">{titre}</h3>
|
||||
<p className="mt-1 text-[0.95rem] text-muted">{texte}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
|
||||
{/* ── CTA final ─────────────────────────────────────────────────── */}
|
||||
<Container width="wide" className="pb-20">
|
||||
<div className="surface-card flex flex-col items-center gap-6 bg-surface-alt px-6 py-14 text-center">
|
||||
<div className="flex gap-2">
|
||||
{PILIER_ORDER.map((p) => {
|
||||
const { Icon, color, label } = PILIER_META[p];
|
||||
return (
|
||||
<IconTile key={p} color={color}>
|
||||
<Icon size={19} strokeWidth={2} aria-label={label} />
|
||||
</IconTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<h2 className="max-w-xl text-title">Combien d'échéances ton foyer porte-t-il ?</h2>
|
||||
<p className="max-w-md text-muted">
|
||||
Deux minutes pour le savoir. Tu vois le résultat immédiatement.
|
||||
</p>
|
||||
<Link href="/quiz" className={buttonClass("primary", "lg")}>
|
||||
Faire le quiz
|
||||
<ArrowRight size={18} aria-hidden />
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="footer-note muted small">
|
||||
<p><Check size={14} style={{ verticalAlign: "-2px", marginRight: "0.3rem" }} />
|
||||
<strong>Pourquoi pas 100 % gratuit ?</strong> Parce que sans pub, c'est toi le client,
|
||||
pas le produit. Le gratuit te donne le savoir ; l'abonnement paie quelqu'un qui y
|
||||
pense à ta place, toute l'année.</p>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
67
src/app/quiz/fabrication.tsx
Normal file
67
src/app/quiz/fabrication.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
/**
|
||||
* L'écran de fabrication, entre la dernière question et le hub.
|
||||
*
|
||||
* C'est le seul moment du parcours où l'attente sert à quelque chose : elle donne au
|
||||
* résultat le poids d'un travail effectué. Les étapes décrivent ce qui se passe
|
||||
* réellement côté serveur — aucune n'est décorative, aucun délai n'est inventé.
|
||||
* Le composant se contente de garantir une durée minimale de lisibilité ; si le serveur
|
||||
* est plus lent, il attend, s'il est plus rapide, on ne ment pas pour autant.
|
||||
*/
|
||||
|
||||
const CADENCE_MS = 420;
|
||||
|
||||
export function Fabrication({ nbFiches }: { nbFiches: number }) {
|
||||
const etapes = [
|
||||
"Lecture de ce que tu as déclaré",
|
||||
`Croisement avec les ${nbFiches} obligations suivies`,
|
||||
"Application des règles de ton département",
|
||||
"Construction de ton calendrier",
|
||||
];
|
||||
const [faites, setFaites] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setFaites((n) => Math.min(n + 1, etapes.length)), CADENCE_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [etapes.length]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center py-16" aria-live="polite">
|
||||
<div className="surface-card w-full max-w-md p-8">
|
||||
<p className="font-display text-xl font-semibold">On monte ta veille…</p>
|
||||
<ul className="mt-6 space-y-3.5">
|
||||
{etapes.map((e, i) => {
|
||||
const done = i < faites;
|
||||
const active = i === faites;
|
||||
return (
|
||||
<li
|
||||
key={e}
|
||||
className={cn(
|
||||
"flex items-center gap-3 text-[0.95rem] transition-opacity duration-300",
|
||||
done ? "text-ink-soft" : active ? "text-ink-soft" : "text-faint opacity-45",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex size-5 shrink-0 items-center justify-center">
|
||||
{done ? (
|
||||
<Check size={16} strokeWidth={3} className="text-accent" aria-hidden />
|
||||
) : active ? (
|
||||
<Loader2 size={15} className="animate-spin text-accent" aria-hidden />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-line-strong" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
{e}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="mt-5 text-sm text-muted">Quelques secondes, pas davantage.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/app/quiz/hud.tsx
Normal file
91
src/app/quiz/hud.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
import type { Pilier } from "@/knowledge/schema";
|
||||
|
||||
/**
|
||||
* Le bandeau de progression du quiz.
|
||||
*
|
||||
* Son vrai rôle n'est pas de mesurer l'avancement mais de **rendre la valeur pendant**
|
||||
* la saisie : les piliers s'allument à mesure qu'ils deviennent concernés, et le compteur
|
||||
* d'éléments monte. C'est ce qui empêche le quiz d'être vécu comme un péage — l'utilisateur
|
||||
* voit sa veille se constituer avant même d'avoir terminé.
|
||||
*
|
||||
* Le compteur reste **strictement factuel** (nombre d'éléments déclarés, jamais un nombre
|
||||
* d'échéances promis) : annoncer « 34 » ici pour en afficher 31 ensuite serait la version
|
||||
* la plus bête possible d'un piège.
|
||||
*/
|
||||
export function QuizHud({
|
||||
etape,
|
||||
total,
|
||||
elements,
|
||||
piliersActifs,
|
||||
}: {
|
||||
etape: number;
|
||||
total: number;
|
||||
elements: number;
|
||||
piliersActifs: Set<Pilier>;
|
||||
}) {
|
||||
const pct = ((etape + 1) / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="sticky top-16 z-20 -mx-5 border-b border-line bg-canvas/90 px-5 py-3 backdrop-blur-md sm:-mx-6 sm:px-6">
|
||||
<div
|
||||
className="h-1 overflow-hidden rounded-full bg-line"
|
||||
role="progressbar"
|
||||
aria-valuenow={etape + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={total}
|
||||
aria-label={`Question ${etape + 1} sur ${total}`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-400 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex items-center justify-between gap-4">
|
||||
<p className="tnum text-xs font-medium text-muted">
|
||||
Question {etape + 1} / {total}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ul className="flex items-center gap-1.5">
|
||||
{PILIER_ORDER.map((p) => {
|
||||
const { Icon, label, color } = PILIER_META[p];
|
||||
const actif = piliersActifs.has(p);
|
||||
return (
|
||||
<li key={p}>
|
||||
<span
|
||||
title={actif ? `${label} — concerné` : `${label} — pas encore concerné`}
|
||||
className={cn(
|
||||
"inline-flex size-7 items-center justify-center rounded-lg transition-all duration-300",
|
||||
actif ? "scale-100" : "scale-90 opacity-30",
|
||||
)}
|
||||
style={
|
||||
actif
|
||||
? { color, backgroundColor: `color-mix(in srgb, ${color} 14%, transparent)` }
|
||||
: { color: "var(--ink-faint)" }
|
||||
}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.25} aria-hidden />
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{elements > 0 && (
|
||||
<p className="tnum flex items-center gap-1.5 text-xs font-semibold text-accent">
|
||||
<Check size={13} strokeWidth={3} aria-hidden />
|
||||
{elements} élément{elements > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/quiz/layout.tsx
Normal file
12
src/app/quiz/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Le quiz du foyer — 2 minutes",
|
||||
description:
|
||||
"8 questions faciles, aucune obligatoire : logement, véhicules, foyer, animaux. On en déduit toutes les échéances qui te concernent — sans compte et sans carte bancaire.",
|
||||
alternates: { canonical: "/quiz" },
|
||||
};
|
||||
|
||||
export default function QuizLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
|
@ -1,330 +1,11 @@
|
|||
"use client";
|
||||
import { loadTemplates } from "@/knowledge/loader";
|
||||
import { QuizClient } from "./quiz-client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Adresse = { label: string; departement: string; loi_montagne: boolean };
|
||||
type Vehicule = {
|
||||
plaque?: string; marque?: string; modele?: string; annee?: number;
|
||||
carburant?: "essence" | "diesel" | "electrique" | "hybride";
|
||||
date_premiere_immat?: string; critair?: string; source: "api" | "mock" | "declaratif";
|
||||
};
|
||||
type Animal = { espece: "chien" | "chat" | "autre"; age: "jeune" | "adulte" | "senior" | "nsp" };
|
||||
|
||||
type Answers = {
|
||||
adresse: Adresse | null;
|
||||
logement: { type: "maison" | "appartement"; statut: "proprietaire" | "locataire"; chauffage: string[]; equipements: string[] };
|
||||
vehicules: Vehicule[];
|
||||
adultes: number;
|
||||
enfants: string[];
|
||||
animaux: Animal[];
|
||||
papiers_anciens: "oui" | "non" | "nsp";
|
||||
cesu: boolean;
|
||||
};
|
||||
|
||||
const TOTAL_SCREENS = 8;
|
||||
|
||||
function track(name: string, props?: Record<string, unknown>) {
|
||||
fetch("/api/event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, props }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function Chip({ on, onClick, children }: { on: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" className={`chip${on ? " on" : ""}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Quiz() {
|
||||
const router = useRouter();
|
||||
const [screen, setScreen] = useState(0);
|
||||
const [answers, setAnswers] = useState<Answers>({
|
||||
adresse: null,
|
||||
logement: { type: "maison", statut: "proprietaire", chauffage: [], equipements: [] },
|
||||
vehicules: [],
|
||||
adultes: 2,
|
||||
enfants: [],
|
||||
animaux: [],
|
||||
papiers_anciens: "nsp",
|
||||
cesu: false,
|
||||
});
|
||||
const [logementChoisi, setLogementChoisi] = useState<{ type?: string; statut?: string }>({});
|
||||
const [suggestion, setSuggestion] = useState<{ chauffage?: string; type?: string; annee_construction?: number } | null>(null);
|
||||
const [adresseQuery, setAdresseQuery] = useState("");
|
||||
const [adresseResults, setAdresseResults] = useState<Adresse[]>([]);
|
||||
const [decl, setDecl] = useState<{ marque: string; annee: string; carburant: Vehicule["carburant"] }>({ marque: "", annee: "", carburant: "essence" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => { track("quiz_start"); }, []);
|
||||
const goTo = useCallback((n: number) => {
|
||||
setScreen(n);
|
||||
track("quiz_screen", { screen: n });
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
function searchAdresse(q: string) {
|
||||
setAdresseQuery(q);
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
if (q.length < 8) { setAdresseResults([]); return; }
|
||||
debounce.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/adresse?q=${encodeURIComponent(q)}`).catch(() => null);
|
||||
if (res?.ok) setAdresseResults((await res.json()).suggestions ?? []);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function pickAdresse(a: Adresse) {
|
||||
setAnswers((s) => ({ ...s, adresse: a }));
|
||||
setAdresseResults([]);
|
||||
setAdresseQuery(a.label);
|
||||
const res = await fetch(`/api/adresse?q=${encodeURIComponent(a.label)}&logement=1`).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const data = await res.json();
|
||||
if (data.logement) setSuggestion(data.logement);
|
||||
}
|
||||
}
|
||||
|
||||
function addDeclaratif() {
|
||||
if (!decl.marque) return;
|
||||
setAnswers((s) => ({
|
||||
...s,
|
||||
vehicules: [...s.vehicules, {
|
||||
marque: decl.marque,
|
||||
annee: decl.annee ? Number(decl.annee) : undefined,
|
||||
carburant: decl.carburant,
|
||||
source: "declaratif",
|
||||
}],
|
||||
}));
|
||||
setDecl({ marque: "", annee: "", carburant: "essence" });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
const res = await fetch("/api/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(answers),
|
||||
}).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const { id } = await res.json();
|
||||
track("quiz_complete", { householdId: id });
|
||||
router.push(`/foyer/${id}`);
|
||||
} else {
|
||||
setSubmitting(false);
|
||||
alert("Petit souci de notre côté — réessaie dans un instant.");
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (list: string[], v: string) => list.includes(v) ? list.filter((x) => x !== v) : [...list, v];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="progress"><div style={{ width: `${((screen + 1) / TOTAL_SCREENS) * 100}%` }} /></div>
|
||||
|
||||
{screen === 0 && (
|
||||
<section>
|
||||
<h1>Ton adresse ?</h1>
|
||||
<p className="muted">On pré-remplit ce qu'on peut avec les données publiques (type de logement,
|
||||
chauffage, règles de ton département). Rien d'obligatoire.</p>
|
||||
<input type="text" placeholder="12 rue de la Paix, Lyon" value={adresseQuery}
|
||||
onChange={(e) => searchAdresse(e.target.value)} />
|
||||
{adresseResults.length > 0 && (
|
||||
<div className="card" style={{ padding: "0.5rem" }}>
|
||||
{adresseResults.map((a) => (
|
||||
<button key={a.label} type="button" className="btn btn-ghost"
|
||||
style={{ display: "block", width: "100%", textAlign: "left", padding: "0.5rem" }}
|
||||
onClick={() => pickAdresse(a)}>{a.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{answers.adresse && <p className="suggestion">✓ {answers.adresse.label}</p>}
|
||||
<p style={{ marginTop: "1.5rem" }}>
|
||||
<button className="btn btn-primary" onClick={() => goTo(1)}>Continuer</button>{" "}
|
||||
<button className="btn btn-ghost" onClick={() => { setAnswers((s) => ({ ...s, adresse: null })); goTo(1); }}>Passer</button>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 1 && (
|
||||
<section>
|
||||
<h1>Ton logement, c'est plutôt :</h1>
|
||||
{suggestion?.type && !logementChoisi.type && (
|
||||
<p className="suggestion">D'après les données publiques : {suggestion.type === "maison" ? "une maison" : "un appartement"}
|
||||
{suggestion.annee_construction ? ` de ${suggestion.annee_construction}` : ""} — corrige si besoin.</p>
|
||||
)}
|
||||
<div className="chips">
|
||||
{(["maison", "appartement"] as const).map((t) => (
|
||||
<Chip key={t} on={(logementChoisi.type ?? suggestion?.type ?? answers.logement.type) === t}
|
||||
onClick={() => { setLogementChoisi((s) => ({ ...s, type: t })); setAnswers((s) => ({ ...s, logement: { ...s.logement, type: t } })); }}>
|
||||
{t === "maison" ? "Une maison" : "Un appartement"}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="chips">
|
||||
{(["proprietaire", "locataire"] as const).map((st) => (
|
||||
<Chip key={st} on={answers.logement.statut === st}
|
||||
onClick={() => setAnswers((s) => ({ ...s, logement: { ...s.logement, statut: st } }))}>
|
||||
{st === "proprietaire" ? "Propriétaire" : "Locataire"}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<p><button className="btn btn-primary" onClick={() => goTo(2)}>Continuer</button></p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 2 && (
|
||||
<section>
|
||||
<h1>Tu te chauffes comment ?</h1>
|
||||
<p className="muted">Plusieurs réponses possibles.</p>
|
||||
{suggestion?.chauffage && answers.logement.chauffage.length === 0 && (
|
||||
<p className="suggestion">D'après les données publiques : chauffage {suggestion.chauffage} — c'est toujours ça ?</p>
|
||||
)}
|
||||
<div className="chips">
|
||||
{([["gaz", "Gaz"], ["fioul", "Fioul"], ["electrique", "Électrique"], ["pac", "Pompe à chaleur / clim"], ["bois", "Bois / poêle / cheminée"], ["nsp", "Je ne sais pas"]] as const).map(([v, l]) => (
|
||||
<Chip key={v} on={answers.logement.chauffage.includes(v)}
|
||||
onClick={() => setAnswers((s) => ({ ...s, logement: { ...s.logement, chauffage: toggle(s.logement.chauffage, v) } }))}>{l}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<p><button className="btn btn-primary" onClick={() => goTo(3)}>Continuer</button></p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 3 && (
|
||||
<section>
|
||||
<h1>Coche ce que tu as :</h1>
|
||||
<div className="chips">
|
||||
{([["jardin", "Jardin / haies"], ["piscine", "Piscine"], ["fosse_septique", "Fosse septique"], ["adoucisseur", "Adoucisseur d'eau"], ["chauffe_eau", "Chauffe-eau électrique"]] as const).map(([v, l]) => (
|
||||
<Chip key={v} on={answers.logement.equipements.includes(v)}
|
||||
onClick={() => setAnswers((s) => ({ ...s, logement: { ...s.logement, equipements: toggle(s.logement.equipements, v) } }))}>{l}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<button className="btn btn-primary" onClick={() => goTo(4)}>Continuer</button>{" "}
|
||||
<button className="btn btn-ghost" onClick={() => goTo(4)}>Rien de tout ça</button>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 4 && (
|
||||
<section>
|
||||
<h1>Une voiture au foyer ?</h1>
|
||||
<p className="muted">Marque, modèle, année — des choses que tu sais sans chercher.
|
||||
(En t'abonnant, tu pourras ajouter la vraie plaque pour des dates exactes.)</p>
|
||||
{answers.vehicules.map((v, i) => (
|
||||
<p className="suggestion" key={i}>✓ {v.marque} {v.annee ? `(${v.annee})` : ""}</p>
|
||||
))}
|
||||
<div className="card">
|
||||
<input type="text" placeholder="Marque et modèle (ex. Peugeot 308)" value={decl.marque}
|
||||
onChange={(e) => setDecl((s) => ({ ...s, marque: e.target.value }))} />
|
||||
<div style={{ display: "flex", gap: "0.6rem", marginTop: "0.6rem" }}>
|
||||
<input type="text" placeholder="Année" value={decl.annee}
|
||||
onChange={(e) => setDecl((s) => ({ ...s, annee: e.target.value.replace(/\D/g, "") }))} style={{ maxWidth: "110px" }} />
|
||||
</div>
|
||||
<div className="chips">
|
||||
{(["essence", "diesel", "electrique", "hybride"] as const).map((c) => (
|
||||
<Chip key={c} on={decl.carburant === c} onClick={() => setDecl((s) => ({ ...s, carburant: c }))}>{c}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={addDeclaratif}>Ajouter cette voiture</button>
|
||||
</div>
|
||||
<p style={{ marginTop: "1.5rem" }}>
|
||||
<button className="btn btn-primary" onClick={() => goTo(5)}>
|
||||
{answers.vehicules.length > 0 ? "Continuer" : "Pas de voiture"}
|
||||
</button>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 5 && (
|
||||
<section>
|
||||
<h1>Le foyer, c'est :</h1>
|
||||
<h3 style={{ marginTop: "1rem" }}>Adultes</h3>
|
||||
<div className="chips">
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Chip key={n} on={answers.adultes === n} onClick={() => setAnswers((s) => ({ ...s, adultes: n }))}>{n === 3 ? "3+" : n}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<h3>Enfants (tranches d'âge)</h3>
|
||||
<div className="chips">
|
||||
{["0-5", "6-11", "12-15", "16-17"].map((t) => (
|
||||
<Chip key={t} on={answers.enfants.includes(t)}
|
||||
onClick={() => setAnswers((s) => ({ ...s, enfants: toggle(s.enfants, t) }))}>{t} ans</Chip>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted small">Pas de prénoms — on n'en a pas besoin pour monter la garde.</p>
|
||||
<p><button className="btn btn-primary" onClick={() => goTo(6)}>Continuer</button></p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 6 && (
|
||||
<section>
|
||||
<h1>Des animaux ?</h1>
|
||||
{answers.animaux.map((a, i) => (
|
||||
<p className="suggestion" key={i}>✓ {a.espece} ({a.age === "nsp" ? "âge inconnu" : a.age})</p>
|
||||
))}
|
||||
<div className="chips">
|
||||
{(["chien", "chat", "autre"] as const).map((e) => (
|
||||
<Chip key={e} on={false}
|
||||
onClick={() => setAnswers((s) => ({ ...s, animaux: [...s.animaux, { espece: e, age: "adulte" }] }))}>
|
||||
+ {e === "autre" ? "Autre animal" : e}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
{answers.animaux.length > 0 && (
|
||||
<>
|
||||
<p className="muted small">Âge du dernier ajouté :</p>
|
||||
<div className="chips">
|
||||
{(["jeune", "adulte", "senior", "nsp"] as const).map((age) => (
|
||||
<Chip key={age} on={answers.animaux[answers.animaux.length - 1]?.age === age}
|
||||
onClick={() => setAnswers((s) => {
|
||||
const animaux = [...s.animaux];
|
||||
animaux[animaux.length - 1] = { ...animaux[animaux.length - 1], age };
|
||||
return { ...s, animaux };
|
||||
})}>{age === "nsp" ? "je ne sais pas" : age}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<button className="btn btn-primary" onClick={() => goTo(7)}>{answers.animaux.length > 0 ? "Continuer" : "Aucun animal"}</button>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 7 && (
|
||||
<section>
|
||||
<h1>Tes papiers d'identité ont plus de 10 ans ?</h1>
|
||||
<p className="muted">CNI, passeport… Réponse au pif acceptée — on t'aidera à vérifier sans rien recopier.</p>
|
||||
<div className="chips">
|
||||
{([["oui", "Oui, certains"], ["non", "Non, refaits récemment"], ["nsp", "Aucune idée"]] as const).map(([v, l]) => (
|
||||
<Chip key={v} on={answers.papiers_anciens === v}
|
||||
onClick={() => setAnswers((s) => ({ ...s, papiers_anciens: v }))}>{l}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<h3 style={{ marginTop: "1.5rem" }}>Tu emploies quelqu'un à domicile ?</h3>
|
||||
<p className="muted small">Ménage, garde d'enfants, jardin… (crédit d'impôt 50 %)</p>
|
||||
<div className="chips">
|
||||
<Chip on={answers.cesu} onClick={() => setAnswers((s) => ({ ...s, cesu: true }))}>Oui</Chip>
|
||||
<Chip on={!answers.cesu} onClick={() => setAnswers((s) => ({ ...s, cesu: false }))}>Non</Chip>
|
||||
</div>
|
||||
<p style={{ marginTop: "1.5rem" }}>
|
||||
<button className="btn btn-primary" disabled={submitting} onClick={submit}>
|
||||
{submitting ? "On calcule…" : "Voir les échéances de mon foyer"}
|
||||
</button>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen > 0 && (
|
||||
<p style={{ marginTop: "2rem" }}>
|
||||
<button className="btn btn-ghost" onClick={() => goTo(screen - 1)}>← Retour</button>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
/**
|
||||
* Coquille serveur : elle n'existe que pour donner au quiz le nombre réel de fiches
|
||||
* suivies. L'écran de fabrication annonce ce chiffre — il doit venir de la base, jamais
|
||||
* d'une constante recopiée qui dériverait au premier ajout de fiche.
|
||||
*/
|
||||
export default function QuizPage() {
|
||||
return <QuizClient nbFiches={loadTemplates().length} />;
|
||||
}
|
||||
|
|
|
|||
867
src/app/quiz/quiz-client.tsx
Normal file
867
src/app/quiz/quiz-client.tsx
Normal file
|
|
@ -0,0 +1,867 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, ArrowRight, Check, CreditCard, Search, Sparkles, X } from "lucide-react";
|
||||
import { Container } from "@/components/ui/primitives";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Chip, ChipGroup, ErrorNote, FieldLabel, Hint, Input } from "@/components/ui/form";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { Pilier } from "@/knowledge/schema";
|
||||
import { resoudreConfirmationDpe, type DpeConfirmationReponse } from "@/knowledge/dpe-confirmation";
|
||||
import { QuizHud } from "./hud";
|
||||
import { Fabrication } from "./fabrication";
|
||||
|
||||
type Adresse = {
|
||||
label: string;
|
||||
departement: string;
|
||||
loi_montagne: boolean;
|
||||
citycode?: string;
|
||||
banId?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
};
|
||||
type SuggestionLogement = {
|
||||
confidence: "haute" | "basse";
|
||||
type?: "maison" | "appartement";
|
||||
anneeConstruction?: number;
|
||||
periodeConstruction?: string;
|
||||
chauffage?: string;
|
||||
dpe?: { etiquette: string; dateEtablissement: string; dateFinValidite: string };
|
||||
};
|
||||
type Vehicule = {
|
||||
marque?: string; modele?: string; annee?: number;
|
||||
/** Champs K et D.2 de la carte grise, conservés quand l'identification est passée par eux. */
|
||||
reception_numero?: string; variante?: string; version?: string;
|
||||
energie?: "essence" | "diesel" | "electrique" | "hybride";
|
||||
date_premiere_immat?: string;
|
||||
source: "carte_grise" | "declaratif";
|
||||
};
|
||||
type Animal = { espece: "chien" | "chat" | "autre"; age: "jeune" | "adulte" | "senior" | "nsp" };
|
||||
type SuggestionVehicule = { marque: string; modele: string; energies: string[] };
|
||||
|
||||
const ENERGIES_VALIDES = ["essence", "diesel", "electrique", "hybride"] as const;
|
||||
const ENERGIE_LABEL: Record<NonNullable<Vehicule["energie"]>, string> = {
|
||||
essence: "Essence",
|
||||
diesel: "Diesel",
|
||||
electrique: "Électrique",
|
||||
hybride: "Hybride",
|
||||
};
|
||||
|
||||
function energiesValides(energies: string[]): Vehicule["energie"][] {
|
||||
return energies.filter((c): c is NonNullable<Vehicule["energie"]> =>
|
||||
(ENERGIES_VALIDES as readonly string[]).includes(c),
|
||||
);
|
||||
}
|
||||
|
||||
type Answers = {
|
||||
adresse: Adresse | null;
|
||||
logement: {
|
||||
type: "maison" | "appartement";
|
||||
statut: "proprietaire" | "locataire";
|
||||
chauffage: string[];
|
||||
equipements: string[];
|
||||
dpe_etiquette?: string;
|
||||
dpe_fin_validite?: string;
|
||||
};
|
||||
vehicules: Vehicule[];
|
||||
adultes: number;
|
||||
enfants: string[];
|
||||
animaux: Animal[];
|
||||
papiers_anciens: "oui" | "non" | "nsp";
|
||||
cesu: boolean;
|
||||
};
|
||||
|
||||
const TOTAL_SCREENS = 8;
|
||||
const BROUILLON = "fokan.quiz.v1";
|
||||
|
||||
const ANSWERS_VIDES: Answers = {
|
||||
adresse: null,
|
||||
logement: { type: "maison", statut: "proprietaire", chauffage: [], equipements: [] },
|
||||
vehicules: [],
|
||||
adultes: 2,
|
||||
enfants: [],
|
||||
animaux: [],
|
||||
papiers_anciens: "nsp",
|
||||
cesu: false,
|
||||
};
|
||||
|
||||
function track(name: string, props?: Record<string, unknown>) {
|
||||
fetch("/api/event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, props }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Piliers déjà concernés par ce qui a été déclaré. Sert uniquement au retour visuel du
|
||||
* HUD : « maison » et « papiers » sont acquis d'office (tout foyer a un logement et des
|
||||
* papiers), les deux autres s'allument à la déclaration.
|
||||
*/
|
||||
function piliersConcernes(a: Answers, ecran: number): Set<Pilier> {
|
||||
const s = new Set<Pilier>();
|
||||
if (ecran >= 1) s.add("maison");
|
||||
if (a.vehicules.length) s.add("vehicules");
|
||||
if (a.animaux.length) s.add("animaux");
|
||||
if (ecran >= 5) s.add("papiers");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Éléments réellement déclarés — le même décompte que celui qui deviendra des assets. */
|
||||
function compterElements(a: Answers, ecran: number): number {
|
||||
return (
|
||||
(ecran >= 1 ? 1 : 0) +
|
||||
a.vehicules.length +
|
||||
(ecran >= 5 ? a.adultes + a.enfants.length : 0) +
|
||||
a.animaux.length
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Éléments de mise en page communs aux écrans ──────────────────────── */
|
||||
|
||||
function Ecran({ titre, aide, children }: { titre: string; aide?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="animate-rise">
|
||||
<h1 className="text-title text-balance">{titre}</h1>
|
||||
{aide && <p className="mt-3 text-[1.02rem] text-muted">{aide}</p>}
|
||||
<div className="mt-7">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inférence rendue visible : « voilà ce qu'on a déduit », toujours corrigeable (§ 4.2). */
|
||||
function Deduction({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="animate-fade mb-5 flex items-start gap-2.5 rounded-xl border border-accent-line bg-accent-soft px-4 py-3 text-[0.925rem] text-accent-soft-ink">
|
||||
<Sparkles size={16} className="mt-0.5 shrink-0" aria-hidden />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Retenu({ children, onRemove }: { children: React.ReactNode; onRemove?: () => void }) {
|
||||
return (
|
||||
<p className="animate-fade mb-2.5 flex items-center gap-2.5 rounded-xl border border-line bg-surface-inset px-4 py-2.5 text-[0.925rem] text-ink-soft">
|
||||
<Check size={16} className="shrink-0 text-accent" strokeWidth={2.5} aria-hidden />
|
||||
<span className="flex-1">{children}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Retirer"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:text-warn"
|
||||
>
|
||||
<X size={15} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chaque écran offre « Passer » : aucune saisie n'est obligatoire (§ 4.2). */
|
||||
function Actions({
|
||||
onNext,
|
||||
labelNext = "Continuer",
|
||||
onSkip,
|
||||
labelSkip = "Passer",
|
||||
}: {
|
||||
onNext: () => void;
|
||||
labelNext?: string;
|
||||
onSkip?: () => void;
|
||||
labelSkip?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-9 flex flex-wrap items-center gap-3">
|
||||
<Button size="lg" onClick={onNext}>
|
||||
{labelNext}
|
||||
<ArrowRight size={17} aria-hidden />
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button variant="ghost" size="lg" onClick={onSkip}>
|
||||
{labelSkip}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Le quiz ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export function QuizClient({ nbFiches }: { nbFiches: number }) {
|
||||
const router = useRouter();
|
||||
const [screen, setScreen] = useState(0);
|
||||
const [answers, setAnswers] = useState<Answers>(ANSWERS_VIDES);
|
||||
const [logementChoisi, setLogementChoisi] = useState<{ type?: string; statut?: string }>({});
|
||||
const [suggestion, setSuggestion] = useState<SuggestionLogement | null>(null);
|
||||
/** Réponse à la confirmation DPE (§ 1, docs/pilier-maison.md) — jamais adoptée sans elle. */
|
||||
const [dpeReponse, setDpeReponse] = useState<DpeConfirmationReponse | undefined>(undefined);
|
||||
const [adresseQuery, setAdresseQuery] = useState("");
|
||||
const [adresseResults, setAdresseResults] = useState<Adresse[]>([]);
|
||||
/**
|
||||
* `reception` n'est renseigné que par la voie champ K : il porte l'identification exacte
|
||||
* jusqu'à l'asset, là où `pick` ne porte qu'un couple marque/modèle lisible.
|
||||
*/
|
||||
type Pick = { marque: string; modele: string; reception?: { numero: string; variante?: string; version?: string } };
|
||||
const [decl, setDecl] = useState<{ pick: Pick | null; annee: string; energie: Vehicule["energie"] }>({ pick: null, annee: "", energie: "essence" });
|
||||
const [vehiculeQuery, setVehiculeQuery] = useState("");
|
||||
const [vehiculeResults, setVehiculeResults] = useState<SuggestionVehicule[]>([]);
|
||||
const [energiesDisponibles, setEnergiesDisponibles] = useState<Vehicule["energie"][] | null>(null);
|
||||
const [champK, setChampK] = useState("");
|
||||
const [champKEtat, setChampKEtat] = useState<"recherche" | "trouve" | "inconnu" | null>(null);
|
||||
/** Repli sur la recherche par modèle : ouvert à la demande, ou d'office si le champ K échoue. */
|
||||
const [rechercheModele, setRechercheModele] = useState(false);
|
||||
const [phase, setPhase] = useState<"quiz" | "calcul">("quiz");
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const vehiculeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const champKDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const restaure = useRef(false);
|
||||
|
||||
useEffect(() => { track("quiz_start"); }, []);
|
||||
|
||||
/** Un rafraîchissement accidentel ne doit jamais coûter les deux minutes déjà investies. */
|
||||
useEffect(() => {
|
||||
try {
|
||||
const brut = sessionStorage.getItem(BROUILLON);
|
||||
if (brut) {
|
||||
const d = JSON.parse(brut) as { answers: Answers; screen: number; adresseQuery?: string };
|
||||
setAnswers({ ...ANSWERS_VIDES, ...d.answers });
|
||||
setScreen(Math.min(d.screen ?? 0, TOTAL_SCREENS - 1));
|
||||
if (d.adresseQuery) setAdresseQuery(d.adresseQuery);
|
||||
}
|
||||
} catch { /* brouillon illisible : on repart à zéro, sans le dire */ }
|
||||
restaure.current = true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restaure.current) return;
|
||||
try {
|
||||
sessionStorage.setItem(BROUILLON, JSON.stringify({ answers, screen, adresseQuery }));
|
||||
} catch { /* quota ou navigation privée : la persistance est un confort, pas une dépendance */ }
|
||||
}, [answers, screen, adresseQuery]);
|
||||
|
||||
const goTo = useCallback((n: number) => {
|
||||
setScreen(n);
|
||||
track("quiz_screen", { screen: n });
|
||||
window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior });
|
||||
}, []);
|
||||
|
||||
function searchAdresse(q: string) {
|
||||
setAdresseQuery(q);
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
if (q.length < 8) { setAdresseResults([]); return; }
|
||||
debounce.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/adresse?q=${encodeURIComponent(q)}`).catch(() => null);
|
||||
if (res?.ok) setAdresseResults((await res.json()).suggestions ?? []);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function pickAdresse(a: Adresse) {
|
||||
// Un changement d'adresse invalide toute suggestion et toute confirmation précédentes —
|
||||
// sinon le DPE d'un ancien logement pourrait rester confirmé sur le nouveau.
|
||||
setAnswers((s) => ({
|
||||
...s,
|
||||
adresse: a,
|
||||
logement: { ...s.logement, dpe_etiquette: undefined, dpe_fin_validite: undefined },
|
||||
}));
|
||||
setSuggestion(null);
|
||||
setDpeReponse(undefined);
|
||||
setAdresseResults([]);
|
||||
setAdresseQuery(a.label);
|
||||
const banId = a.banId ? `&banId=${encodeURIComponent(a.banId)}` : "";
|
||||
const res = await fetch(`/api/adresse?q=${encodeURIComponent(a.label)}&logement=1${banId}`).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const data = await res.json();
|
||||
if (data.logement) setSuggestion(data.logement);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmerDpe(reponse: DpeConfirmationReponse) {
|
||||
setDpeReponse(reponse);
|
||||
const confirme = resoudreConfirmationDpe(suggestion?.dpe, reponse);
|
||||
setAnswers((s) => ({ ...s, logement: { ...s.logement, ...confirme } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le chip de type de logement affiche la suggestion comme sélectionnée avant tout clic —
|
||||
* sans ce report, quitter l'écran sans y toucher laissait la réponse déclarée sur le défaut
|
||||
* ("maison"), pas sur ce qui était visiblement affiché. Un no-op si l'utilisateur a déjà cliqué.
|
||||
*/
|
||||
function adopterTypeSuggere() {
|
||||
const effectif = logementChoisi.type ?? suggestion?.type ?? answers.logement.type;
|
||||
if (effectif !== answers.logement.type) {
|
||||
setAnswers((s) => ({ ...s, logement: { ...s.logement, type: effectif as "maison" | "appartement" } }));
|
||||
}
|
||||
}
|
||||
|
||||
function searchVehicule(q: string) {
|
||||
setVehiculeQuery(q);
|
||||
setDecl((s) => ({ ...s, pick: null }));
|
||||
setEnergiesDisponibles(null);
|
||||
if (vehiculeDebounce.current) clearTimeout(vehiculeDebounce.current);
|
||||
if (q.length < 2) { setVehiculeResults([]); return; }
|
||||
vehiculeDebounce.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/vehicule/taxonomie?q=${encodeURIComponent(q)}`).catch(() => null);
|
||||
if (res?.ok) setVehiculeResults((await res.json()).suggestions ?? []);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function pickVehicule(s: SuggestionVehicule) {
|
||||
const energies = energiesValides(s.energies);
|
||||
setDecl((d) => ({ ...d, pick: { marque: s.marque, modele: s.modele }, energie: energies[0] ?? d.energie }));
|
||||
setEnergiesDisponibles(energies.length ? energies : null);
|
||||
setVehiculeQuery(`${s.marque} ${s.modele}`);
|
||||
setVehiculeResults([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Champ K de la carte grise (numéro de réception) : un seul champ recopié et le véhicule
|
||||
* est identifié exactement dans le référentiel RDW, sans API payante.
|
||||
* Silencieux si la réception est inconnue : on retombe simplement sur la recherche.
|
||||
*/
|
||||
async function chercherParChampK(k: string) {
|
||||
setChampK(k);
|
||||
if (champKDebounce.current) clearTimeout(champKDebounce.current);
|
||||
setChampKEtat(null);
|
||||
if (k.trim().length < 8) return;
|
||||
champKDebounce.current = setTimeout(async () => {
|
||||
setChampKEtat("recherche");
|
||||
const res = await fetch(`/api/vehicule/taxonomie?k=${encodeURIComponent(k.trim())}`).catch(() => null);
|
||||
const v = res?.ok ? (await res.json()).vehicule : null;
|
||||
if (!v?.marque || !v?.modele) {
|
||||
// Réception inconnue : on ouvre le repli sans le faire demander (§ 4.1, ≤ 2 min).
|
||||
setChampKEtat("inconnu");
|
||||
setRechercheModele(true);
|
||||
return;
|
||||
}
|
||||
const energies = energiesValides(v.energies ?? []);
|
||||
setDecl((d) => ({
|
||||
...d,
|
||||
pick: {
|
||||
marque: v.marque,
|
||||
modele: v.modele,
|
||||
// On garde le numéro tel que le référentiel l'écrit, pas tel qu'il a été tapé.
|
||||
reception: { numero: v.receptionNumero, variante: v.variante ?? undefined, version: v.version ?? undefined },
|
||||
},
|
||||
annee: v.annee ? String(v.annee) : d.annee,
|
||||
energie: energies.length === 1 ? energies[0] : d.energie,
|
||||
}));
|
||||
setEnergiesDisponibles(energies.length ? energies : null);
|
||||
setVehiculeQuery(`${v.marque} ${v.modele}`);
|
||||
setVehiculeResults([]);
|
||||
setChampKEtat("trouve");
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function resetVehiculeForm() {
|
||||
setDecl({ pick: null, annee: "", energie: "essence" });
|
||||
setVehiculeQuery("");
|
||||
setVehiculeResults([]);
|
||||
setEnergiesDisponibles(null);
|
||||
setChampK("");
|
||||
setChampKEtat(null);
|
||||
setRechercheModele(false);
|
||||
}
|
||||
|
||||
function addDeclaratif() {
|
||||
if (!decl.pick) return;
|
||||
setAnswers((s) => ({
|
||||
...s,
|
||||
vehicules: [...s.vehicules, {
|
||||
marque: decl.pick!.marque,
|
||||
modele: decl.pick!.modele,
|
||||
annee: decl.annee ? Number(decl.annee) : undefined,
|
||||
energie: decl.energie,
|
||||
reception_numero: decl.pick!.reception?.numero,
|
||||
variante: decl.pick!.reception?.variante,
|
||||
version: decl.pick!.reception?.version,
|
||||
source: decl.pick!.reception ? "carte_grise" : "declaratif",
|
||||
}],
|
||||
}));
|
||||
resetVehiculeForm();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setErreur(null);
|
||||
setPhase("calcul");
|
||||
// Durée plancher : le résultat doit avoir le poids d'un travail, pas d'un clignotement.
|
||||
const plancher = new Promise((r) => setTimeout(r, 1700));
|
||||
const appel = fetch("/api/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(answers),
|
||||
}).catch(() => null);
|
||||
|
||||
const [res] = await Promise.all([appel, plancher]);
|
||||
if (!res?.ok) {
|
||||
setPhase("quiz");
|
||||
setErreur("On n'a pas réussi à monter ton calendrier — c'est de notre côté, pas du tien. Réessaie dans un instant : tes réponses sont conservées.");
|
||||
return;
|
||||
}
|
||||
const { id } = await res.json();
|
||||
track("quiz_complete", { householdId: id });
|
||||
try { sessionStorage.removeItem(BROUILLON); } catch { /* sans conséquence */ }
|
||||
router.push(`/foyer/${id}`);
|
||||
}
|
||||
|
||||
const toggle = (list: string[], v: string) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
|
||||
|
||||
if (phase === "calcul") {
|
||||
return (
|
||||
<main>
|
||||
<Container width="narrow">
|
||||
<Fabrication nbFiches={nbFiches} />
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pb-24">
|
||||
<Container width="narrow">
|
||||
<QuizHud
|
||||
etape={screen}
|
||||
total={TOTAL_SCREENS}
|
||||
elements={compterElements(answers, screen)}
|
||||
piliersActifs={piliersConcernes(answers, screen)}
|
||||
/>
|
||||
|
||||
<div className="pt-10">
|
||||
{/* ── 0. Adresse ───────────────────────────────────────────── */}
|
||||
{screen === 0 && (
|
||||
<Ecran
|
||||
titre="On commence par ton adresse ?"
|
||||
aide="Elle sert à pré-remplir ce qu'on peut depuis les données publiques — type de logement, chauffage, règles propres à ton département. Rien n'est obligatoire."
|
||||
>
|
||||
<FieldLabel htmlFor="adresse">Adresse</FieldLabel>
|
||||
<div className="relative">
|
||||
<Search size={17} className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-faint" aria-hidden />
|
||||
<Input
|
||||
id="adresse"
|
||||
type="text"
|
||||
autoComplete="street-address"
|
||||
placeholder="12 rue de la Paix, Lyon"
|
||||
className="pl-11"
|
||||
value={adresseQuery}
|
||||
onChange={(e) => searchAdresse(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{adresseResults.length > 0 && (
|
||||
<ul className="surface-card animate-fade mt-2 divide-y divide-line overflow-hidden p-0">
|
||||
{adresseResults.map((a) => (
|
||||
<li key={a.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickAdresse(a)}
|
||||
className="w-full px-4 py-3 text-left text-[0.95rem] text-ink-soft transition-colors hover:bg-accent-soft hover:text-accent-soft-ink"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{answers.adresse && (
|
||||
<div className="mt-4">
|
||||
<Retenu onRemove={() => {
|
||||
setAnswers((s) => ({
|
||||
...s,
|
||||
adresse: null,
|
||||
logement: { ...s.logement, dpe_etiquette: undefined, dpe_fin_validite: undefined },
|
||||
}));
|
||||
setAdresseQuery("");
|
||||
setSuggestion(null);
|
||||
setDpeReponse(undefined);
|
||||
}}>
|
||||
{answers.adresse.label}
|
||||
</Retenu>
|
||||
{answers.adresse.loi_montagne && (
|
||||
<Deduction>
|
||||
Ton département applique une règle saisonnière qui ne vaut pas partout en
|
||||
France. On en tiendra compte — tu n'as rien à retenir.
|
||||
</Deduction>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Actions
|
||||
onNext={() => goTo(1)}
|
||||
onSkip={() => { setAnswers((s) => ({ ...s, adresse: null })); goTo(1); }}
|
||||
labelSkip="Je préfère ne pas la donner"
|
||||
/>
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 1. Logement ──────────────────────────────────────────── */}
|
||||
{screen === 1 && (
|
||||
<Ecran titre="Ton logement, c'est plutôt :">
|
||||
{suggestion?.type && !logementChoisi.type && (
|
||||
<Deduction>
|
||||
D'après les données publiques :{" "}
|
||||
{suggestion.type === "maison" ? "une maison" : "un appartement"}
|
||||
{suggestion.anneeConstruction
|
||||
? ` construit${suggestion.type === "maison" ? "e" : ""} en ${suggestion.anneeConstruction}`
|
||||
: suggestion.periodeConstruction
|
||||
? ` construit${suggestion.type === "maison" ? "e" : ""} ${suggestion.periodeConstruction}`
|
||||
: ""}
|
||||
. Corrige si besoin.
|
||||
</Deduction>
|
||||
)}
|
||||
<ChipGroup legend="Type de logement">
|
||||
{(["maison", "appartement"] as const).map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
on={(logementChoisi.type ?? suggestion?.type ?? answers.logement.type) === t}
|
||||
onClick={() => {
|
||||
setLogementChoisi((s) => ({ ...s, type: t }));
|
||||
setAnswers((s) => ({ ...s, logement: { ...s.logement, type: t } }));
|
||||
}}
|
||||
>
|
||||
{t === "maison" ? "Une maison" : "Un appartement"}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
<ChipGroup legend="Tu y es">
|
||||
{(["proprietaire", "locataire"] as const).map((st) => (
|
||||
<Chip
|
||||
key={st}
|
||||
on={answers.logement.statut === st}
|
||||
onClick={() => setAnswers((s) => ({ ...s, logement: { ...s.logement, statut: st } }))}
|
||||
>
|
||||
{st === "proprietaire" ? "Propriétaire" : "Locataire"}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
<Actions
|
||||
onNext={() => { adopterTypeSuggere(); goTo(2); }}
|
||||
onSkip={() => { adopterTypeSuggere(); goTo(2); }}
|
||||
/>
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 2. Chauffage ─────────────────────────────────────────── */}
|
||||
{screen === 2 && (
|
||||
<Ecran titre="Tu te chauffes comment ?" aide="Plusieurs réponses possibles.">
|
||||
{suggestion?.chauffage && answers.logement.chauffage.length === 0 && (
|
||||
<Deduction>
|
||||
D'après les données publiques : chauffage {suggestion.chauffage}. C'est
|
||||
toujours ça ?
|
||||
</Deduction>
|
||||
)}
|
||||
<ChipGroup legend="Mode de chauffage">
|
||||
{([["gaz", "Gaz"], ["fioul", "Fioul"], ["electrique", "Électrique"], ["pac", "Pompe à chaleur / clim"], ["bois", "Bois, poêle, cheminée"], ["nsp", "Je ne sais pas"]] as const).map(([v, l]) => (
|
||||
<Chip
|
||||
key={v}
|
||||
on={answers.logement.chauffage.includes(v)}
|
||||
onClick={() => setAnswers((s) => ({ ...s, logement: { ...s.logement, chauffage: toggle(s.logement.chauffage, v) } }))}
|
||||
>
|
||||
{l}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
|
||||
{/* Confirmation DPE (§ 1, docs/pilier-maison.md) : une donnée dérivée ne devient
|
||||
jamais une échéance sans ce geste explicite — "travaux" et "je ne sais pas"
|
||||
laissent le logement sans DPE connu, comme s'il n'y en avait aucun. */}
|
||||
{suggestion?.dpe && (
|
||||
<div className="mb-5">
|
||||
{dpeReponse === undefined && (
|
||||
<>
|
||||
<Deduction>
|
||||
Le diagnostic de performance énergétique de ce logement affiche
|
||||
l'étiquette <strong>{suggestion.dpe.etiquette}</strong>, valable
|
||||
jusqu'en {new Date(suggestion.dpe.dateFinValidite).getFullYear()}.
|
||||
Toujours d'actualité ?
|
||||
</Deduction>
|
||||
<div className="-mt-2 flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => confirmerDpe("confirme")}>
|
||||
Oui, toujours ça
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => confirmerDpe("travaux")}>
|
||||
J'ai fait des travaux depuis
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => confirmerDpe("ne_sait_pas")}>
|
||||
Je ne sais pas
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dpeReponse === "confirme" && (
|
||||
<Deduction>Noté — ce diagnostic reste la référence tant qu'il est valable.</Deduction>
|
||||
)}
|
||||
{(dpeReponse === "travaux" || dpeReponse === "ne_sait_pas") && (
|
||||
<Deduction>Pas de souci — on ne s'appuiera que sur un diagnostic confirmé.</Deduction>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Actions onNext={() => goTo(3)} onSkip={() => goTo(3)} />
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 3. Équipements ───────────────────────────────────────── */}
|
||||
{screen === 3 && (
|
||||
<Ecran titre="Coche ce que tu as :" aide="Chaque élément coché a ses propres obligations — c'est à nous de les connaître.">
|
||||
<ChipGroup legend="Équipements du logement">
|
||||
{([["jardin", "Jardin, haies"], ["piscine", "Piscine"], ["fosse_septique", "Fosse septique"], ["adoucisseur", "Adoucisseur d'eau"], ["chauffe_eau", "Chauffe-eau électrique"]] as const).map(([v, l]) => (
|
||||
<Chip
|
||||
key={v}
|
||||
on={answers.logement.equipements.includes(v)}
|
||||
onClick={() => setAnswers((s) => ({ ...s, logement: { ...s.logement, equipements: toggle(s.logement.equipements, v) } }))}
|
||||
>
|
||||
{l}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
<Actions onNext={() => goTo(4)} onSkip={() => goTo(4)} labelSkip="Rien de tout ça" />
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 4. Véhicules ─────────────────────────────────────────── */}
|
||||
{screen === 4 && (
|
||||
<Ecran
|
||||
titre="Une voiture au foyer ?"
|
||||
aide="Un seul champ de ta carte grise suffit : on remplit le reste. Pas de carte grise sous la main ? La recherche par modèle marche aussi."
|
||||
>
|
||||
{answers.vehicules.map((v, i) => (
|
||||
<Retenu
|
||||
key={i}
|
||||
onRemove={() => setAnswers((s) => ({ ...s, vehicules: s.vehicules.filter((_, j) => j !== i) }))}
|
||||
>
|
||||
{v.marque} {v.modele ?? ""} {v.annee ? `(${v.annee})` : ""}
|
||||
{v.source === "carte_grise" && (
|
||||
<span className="ml-2 text-xs text-faint">identifié depuis la carte grise</span>
|
||||
)}
|
||||
</Retenu>
|
||||
))}
|
||||
|
||||
<div className="surface-card mt-4 p-5">
|
||||
{/* Voie 1 — le champ K identifie exactement le véhicule, gratuitement (§ 7.3). */}
|
||||
<FieldLabel htmlFor="champ-k">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CreditCard size={14} aria-hidden />
|
||||
Champ K de la carte grise
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="champ-k"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
placeholder="ex. e2*2001/116*0362*00"
|
||||
value={champK}
|
||||
onChange={(e) => chercherParChampK(e.target.value)}
|
||||
/>
|
||||
{champKEtat === "recherche" && <Hint>On regarde…</Hint>}
|
||||
{champKEtat === "trouve" && (
|
||||
<p className="mt-2 flex items-center gap-2 text-sm font-medium text-accent">
|
||||
<Check size={15} strokeWidth={2.5} aria-hidden />
|
||||
Identifié depuis ta carte grise
|
||||
</p>
|
||||
)}
|
||||
{champKEtat === "inconnu" && (
|
||||
<Hint>Pas trouvé dans le référentiel — la recherche ci-dessous prend le relais.</Hint>
|
||||
)}
|
||||
|
||||
{/* Voie 2 — repli assumé : « je ne sais pas » reste une réponse de première classe (§ 4.3). */}
|
||||
{!rechercheModele && champKEtat !== "trouve" && (
|
||||
<Button variant="ghost" size="sm" className="mt-3 -ml-3" onClick={() => setRechercheModele(true)}>
|
||||
Je n'ai pas ma carte grise
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(rechercheModele || champKEtat === "trouve") && (
|
||||
<div className="mt-5 border-t border-line pt-5">
|
||||
<FieldLabel htmlFor="modele">Marque et modèle</FieldLabel>
|
||||
<Input
|
||||
id="modele"
|
||||
type="text"
|
||||
placeholder="ex. Peugeot 308"
|
||||
value={vehiculeQuery}
|
||||
onChange={(e) => searchVehicule(e.target.value)}
|
||||
/>
|
||||
{vehiculeResults.length > 0 && (
|
||||
<ul className="surface-card animate-fade mt-2 divide-y divide-line overflow-hidden p-0">
|
||||
{vehiculeResults.map((s) => (
|
||||
<li key={`${s.marque}-${s.modele}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickVehicule(s)}
|
||||
className="w-full px-4 py-2.5 text-left text-[0.95rem] text-ink-soft transition-colors hover:bg-accent-soft hover:text-accent-soft-ink"
|
||||
>
|
||||
{s.marque} {s.modele}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{decl.pick && (
|
||||
<div className="animate-fade mt-5 border-t border-line pt-5">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<Check size={16} className="text-accent" strokeWidth={2.5} aria-hidden />
|
||||
{decl.pick.marque} {decl.pick.modele}
|
||||
</p>
|
||||
<div className="mt-4 max-w-[9rem]">
|
||||
<FieldLabel htmlFor="annee">Année</FieldLabel>
|
||||
<Input
|
||||
id="annee"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="2019"
|
||||
value={decl.annee}
|
||||
onChange={(e) => setDecl((s) => ({ ...s, annee: e.target.value.replace(/\D/g, "").slice(0, 4) }))}
|
||||
/>
|
||||
</div>
|
||||
<ChipGroup legend="Énergie">
|
||||
{(energiesDisponibles ?? ENERGIES_VALIDES).map((c) => (
|
||||
<Chip key={c} on={decl.energie === c} onClick={() => setDecl((s) => ({ ...s, energie: c }))}>
|
||||
{ENERGIE_LABEL[c as NonNullable<Vehicule["energie"]>]}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" className="mt-5" disabled={!decl.pick} onClick={addDeclaratif}>
|
||||
Ajouter cette voiture
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Actions
|
||||
onNext={() => goTo(5)}
|
||||
labelNext={answers.vehicules.length > 0 ? "Continuer" : "Passer cette étape"}
|
||||
onSkip={answers.vehicules.length > 0 ? undefined : () => goTo(5)}
|
||||
labelSkip="Pas de voiture"
|
||||
/>
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 5. Composition du foyer ──────────────────────────────── */}
|
||||
{screen === 5 && (
|
||||
<Ecran titre="Le foyer, c'est :">
|
||||
<ChipGroup legend="Adultes">
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Chip key={n} on={answers.adultes === n} onClick={() => setAnswers((s) => ({ ...s, adultes: n }))}>
|
||||
{n === 3 ? "3 ou plus" : n}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
<ChipGroup legend="Enfants" hint="Par tranche d'âge — plusieurs réponses possibles.">
|
||||
{["0-5", "6-11", "12-15", "16-17"].map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
on={answers.enfants.includes(t)}
|
||||
onClick={() => setAnswers((s) => ({ ...s, enfants: toggle(s.enfants, t) }))}
|
||||
>
|
||||
{t} ans
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
<p className="mt-5 text-sm text-muted">
|
||||
Pas de prénoms, pas de dates de naissance — on n'en a pas besoin pour monter la garde.
|
||||
</p>
|
||||
<Actions onNext={() => goTo(6)} onSkip={() => goTo(6)} />
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 6. Animaux ───────────────────────────────────────────── */}
|
||||
{screen === 6 && (
|
||||
<Ecran titre="Des animaux ?">
|
||||
{answers.animaux.map((a, i) => (
|
||||
<Retenu
|
||||
key={i}
|
||||
onRemove={() => setAnswers((s) => ({ ...s, animaux: s.animaux.filter((_, j) => j !== i) }))}
|
||||
>
|
||||
{a.espece} · {a.age === "nsp" ? "âge inconnu" : a.age}
|
||||
</Retenu>
|
||||
))}
|
||||
<ChipGroup legend="Ajouter un animal">
|
||||
{(["chien", "chat", "autre"] as const).map((e) => (
|
||||
<Chip
|
||||
key={e}
|
||||
on={false}
|
||||
onClick={() => setAnswers((s) => ({ ...s, animaux: [...s.animaux, { espece: e, age: "adulte" }] }))}
|
||||
>
|
||||
+ {e === "autre" ? "Autre animal" : e === "chien" ? "Un chien" : "Un chat"}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
{answers.animaux.length > 0 && (
|
||||
<ChipGroup legend="Âge du dernier ajouté">
|
||||
{(["jeune", "adulte", "senior", "nsp"] as const).map((age) => (
|
||||
<Chip
|
||||
key={age}
|
||||
on={answers.animaux[answers.animaux.length - 1]?.age === age}
|
||||
onClick={() =>
|
||||
setAnswers((s) => {
|
||||
const animaux = [...s.animaux];
|
||||
animaux[animaux.length - 1] = { ...animaux[animaux.length - 1], age };
|
||||
return { ...s, animaux };
|
||||
})
|
||||
}
|
||||
>
|
||||
{age === "nsp" ? "Je ne sais pas" : age}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
)}
|
||||
<Actions
|
||||
onNext={() => goTo(7)}
|
||||
labelNext={answers.animaux.length > 0 ? "Continuer" : "Passer cette étape"}
|
||||
onSkip={answers.animaux.length > 0 ? undefined : () => goTo(7)}
|
||||
labelSkip="Aucun animal"
|
||||
/>
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 7. Papiers et emploi à domicile ──────────────────────── */}
|
||||
{screen === 7 && (
|
||||
<Ecran
|
||||
titre="Tes papiers d'identité ont plus de 10 ans ?"
|
||||
aide="CNI, passeport… Réponse au pif acceptée : on t'aidera à vérifier sans rien recopier."
|
||||
>
|
||||
<ChipGroup legend="Ancienneté des papiers">
|
||||
{([["oui", "Oui, certains"], ["non", "Non, refaits récemment"], ["nsp", "Aucune idée"]] as const).map(([v, l]) => (
|
||||
<Chip key={v} on={answers.papiers_anciens === v} onClick={() => setAnswers((s) => ({ ...s, papiers_anciens: v }))}>
|
||||
{l}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
|
||||
<ChipGroup legend="Tu emploies quelqu'un à domicile ?" hint="Ménage, garde d'enfants, jardin…">
|
||||
<Chip on={answers.cesu} onClick={() => setAnswers((s) => ({ ...s, cesu: true }))}>Oui</Chip>
|
||||
<Chip on={!answers.cesu} onClick={() => setAnswers((s) => ({ ...s, cesu: false }))}>Non</Chip>
|
||||
</ChipGroup>
|
||||
|
||||
{erreur && <ErrorNote>{erreur}</ErrorNote>}
|
||||
|
||||
<div className="mt-9">
|
||||
<Button size="lg" onClick={submit}>
|
||||
Voir les échéances de mon foyer
|
||||
<ArrowRight size={17} aria-hidden />
|
||||
</Button>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
Aucune adresse mail demandée. Le résultat s'affiche directement.
|
||||
</p>
|
||||
</div>
|
||||
</Ecran>
|
||||
)}
|
||||
|
||||
{screen > 0 && (
|
||||
<div className={cn("mt-10 border-t border-line pt-5")}>
|
||||
<Button variant="ghost" size="sm" className="-ml-3" onClick={() => goTo(screen - 1)}>
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
Question précédente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,39 @@
|
|||
import Link from "next/link";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
/** Signature de marque (§ 2.5, § 11.3) : sœur de Kankwa, transparence sur la posture. */
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer-inner">
|
||||
<p className="muted small">
|
||||
<strong>fokan</strong> — par le créateur de{" "}
|
||||
<a href="https://kankwa.fr" target="_blank" rel="noopener noreferrer">Kankwa</a>.
|
||||
{" "}Hébergé en France · pas de pub · pas de revente de données.
|
||||
</p>
|
||||
<p className="muted small site-footer-links">
|
||||
<Link href="/quiz">Le quiz</Link>
|
||||
<Link href="/connexion">Se connecter</Link>
|
||||
</p>
|
||||
<footer className="mt-24 border-t border-line bg-canvas-alt">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-10 sm:flex-row sm:items-start sm:justify-between sm:px-6">
|
||||
<div className="max-w-md">
|
||||
<p className="inline-flex items-center gap-2 font-display text-base font-semibold text-accent">
|
||||
<ShieldCheck size={17} strokeWidth={2.25} aria-hidden />
|
||||
fokan
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
La veille du foyer, par le créateur de{" "}
|
||||
<a
|
||||
href="https://kankwa.fr"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline underline-offset-2"
|
||||
>
|
||||
Kankwa
|
||||
</a>
|
||||
. Hébergé en France · pas de publicité · aucune revente de données · aucun document
|
||||
stocké.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-2 text-sm sm:text-right">
|
||||
<Link href="/quiz" className="text-ink-soft no-underline hover:text-accent">
|
||||
Le quiz
|
||||
</Link>
|
||||
<Link href="/connexion" className="text-ink-soft no-underline hover:text-accent">
|
||||
Se connecter
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
|
|
|||
64
src/components/landing/mur-echeances.tsx
Normal file
64
src/components/landing/mur-echeances.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { loadTemplates } from "@/knowledge/loader";
|
||||
import { PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
import type { Pilier } from "@/knowledge/schema";
|
||||
|
||||
/**
|
||||
* Le mur d'échéances : un champ dense de pastilles, une par fiche suivie, colorée par pilier.
|
||||
*
|
||||
* Volontairement sans un seul mot d'intitulé. Une première version affichait les vrais
|
||||
* titres de la base (« Ramonage », « Passeport enfant »…) en défilement : cela revenait à
|
||||
* publier gratuitement le catalogue complet, exactement ce que le § 10.2 réserve aux
|
||||
* abonnés — n'importe qui aurait pu lire la liste entière sans jamais faire le quiz.
|
||||
*
|
||||
* Ici, seul le VOLUME et la RÉPARTITION par pilier sont montrés — jamais un sujet nommé,
|
||||
* jamais rien de cherchable sur un moteur de recherche. Le nombre exact et la ventilation
|
||||
* par pilier sont calculés depuis la base réelle : c'est une vraie photographie de son
|
||||
* étendue, pas une illustration.
|
||||
*/
|
||||
|
||||
function melangeStable<T>(items: T[]): T[] {
|
||||
// Mélange déterministe (pas de Math.random) : le rendu ne doit jamais changer entre le
|
||||
// serveur et le client, ni d'un rechargement à l'autre.
|
||||
return items
|
||||
.map((item, i) => ({ item, cle: (i * 2654435761) % 2147483647 }))
|
||||
.sort((a, b) => a.cle - b.cle)
|
||||
.map((x) => x.item);
|
||||
}
|
||||
|
||||
export function MurEcheances() {
|
||||
const templates = loadTemplates();
|
||||
const pastilles = melangeStable(templates.map((t) => t.pilier));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden py-2"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, transparent, black 12%, black 88%, transparent)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent, black 12%, black 88%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-wrap justify-center gap-2.5 px-6">
|
||||
{pastilles.map((pilier, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className="animate-fade block size-2.5 shrink-0 rounded-full sm:size-3"
|
||||
style={{
|
||||
backgroundColor: PILIER_META[pilier].color,
|
||||
animationDelay: `${(i % 24) * 22}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compte réel de fiches et répartition par pilier — la seule donnée révélée ici. */
|
||||
export function compteEcheances(): { fiches: number; parPilier: Record<Pilier, number> } {
|
||||
const templates = loadTemplates();
|
||||
const parPilier = Object.fromEntries(
|
||||
PILIER_ORDER.map((p) => [p, templates.filter((t) => t.pilier === p).length]),
|
||||
) as Record<Pilier, number>;
|
||||
return { fiches: templates.length, parPilier };
|
||||
}
|
||||
|
|
@ -1,41 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
|
||||
/**
|
||||
* Navigation globale (§ UI/UX). Volontairement minimale pour un visiteur anonyme (logo +
|
||||
* CTA quiz) : pas de "Guides" (§ 10.2 : catalogue jamais navigable pour qui n'est pas
|
||||
* abonné) ni "Mon foyer" (§ 6.1 : résidence secondaire, on y atterrit depuis un mail — un
|
||||
* visiteur sans compte n'a de toute façon aucun foyer à retrouver).
|
||||
* Une fois connecté, les deux réapparaissent : c'est le cœur du produit, il doit être
|
||||
* trouvable depuis n'importe où sur le site pour qui a déjà un compte. /guide et
|
||||
* /mes-foyers gèrent eux-mêmes leur propre porte (teaser verrouillé, redirection connexion).
|
||||
* Sur le quiz lui-même, le CTA disparaît — jamais distraire de l'onboarding ≤ 2 min (§ 4.1).
|
||||
* Foyer mémorisé sur l'appareil (voir foyer/[id]/memoriser-foyer.tsx). Lu après le montage
|
||||
* uniquement : le serveur ne peut pas le connaître, et un rendu divergent casserait
|
||||
* l'hydratation.
|
||||
*/
|
||||
function useFoyerLocal(): string | null {
|
||||
const [id, setId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const brut = localStorage.getItem("fokan.foyer");
|
||||
if (brut) setId((JSON.parse(brut) as { id?: string }).id ?? null);
|
||||
} catch { /* illisible : on n'affiche simplement pas le raccourci */ }
|
||||
}, []);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation globale. Volontairement minimale pour un visiteur anonyme (logo + CTA quiz) :
|
||||
* pas de "Guides" (§ 10.2 : catalogue jamais navigable pour qui n'est pas abonné) ni
|
||||
* "Mon foyer" (§ 6.1 : on y atterrit depuis un mail — un visiteur sans compte n'a de toute
|
||||
* façon aucun foyer à retrouver). Une fois connecté, les deux réapparaissent.
|
||||
* Sur le quiz, le CTA disparaît — jamais distraire de l'onboarding ≤ 2 min (§ 4.1).
|
||||
*/
|
||||
export function SiteNav() {
|
||||
const pathname = usePathname();
|
||||
const onQuiz = pathname?.startsWith("/quiz");
|
||||
const onFoyer = pathname?.startsWith("/foyer");
|
||||
const { data: session } = authClient.useSession();
|
||||
const foyerLocal = useFoyerLocal();
|
||||
|
||||
return (
|
||||
<header className="site-nav">
|
||||
<div className="site-nav-inner">
|
||||
<Link href="/" className="site-logo">
|
||||
<ShieldCheck size={20} strokeWidth={2.25} />
|
||||
<header className="sticky top-0 z-30 border-b border-line bg-canvas/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-5 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 font-display text-lg font-semibold tracking-tight text-accent no-underline"
|
||||
>
|
||||
<ShieldCheck size={20} strokeWidth={2.25} aria-hidden />
|
||||
fokan
|
||||
</Link>
|
||||
{session ? (
|
||||
<nav className="site-nav-links">
|
||||
<Link href="/guide">Guides</Link>
|
||||
<Link href="/mes-foyers">Mon foyer</Link>
|
||||
</nav>
|
||||
) : (
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<nav className="flex flex-1 items-center gap-5 text-[0.925rem] font-medium">
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/guide" className="text-ink-soft no-underline transition-colors hover:text-accent">
|
||||
Guides
|
||||
</Link>
|
||||
<Link href="/mes-foyers" className="text-ink-soft no-underline transition-colors hover:text-accent">
|
||||
Mon foyer
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
// Sans compte, le seul chemin de retour vers un foyer est l'appareil lui-même.
|
||||
foyerLocal && !onFoyer && (
|
||||
<Link
|
||||
href={`/foyer/${foyerLocal}`}
|
||||
className="text-ink-soft no-underline transition-colors hover:text-accent"
|
||||
>
|
||||
Reprendre mon foyer
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{!onQuiz && (
|
||||
<Link href="/quiz" className={buttonClass("primary", "sm")}>
|
||||
Faire le quiz
|
||||
</Link>
|
||||
)}
|
||||
{!onQuiz && <Link href="/quiz" className="btn btn-primary btn-sm">Faire le quiz</Link>}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
117
src/components/timeline-annee.tsx
Normal file
117
src/components/timeline-annee.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { cn } from "@/lib/cn";
|
||||
import { PILIER_META } from "@/lib/pilier-meta";
|
||||
import type { Pilier } from "@/knowledge/schema";
|
||||
|
||||
/**
|
||||
* Les douze mois à venir, une pastille par échéance, couleur = pilier.
|
||||
*
|
||||
* C'est l'objet qui fait *ressentir* la valeur sans la livrer (§ 10.2) : on montre le
|
||||
* volume, la répartition et le rythme de l'année — jamais quelle échéance tombe quand.
|
||||
* Les pastilles sont délibérément anonymes : associer un titre à un mois, c'est déjà
|
||||
* donner la règle, et la règle est ce qui est vendu.
|
||||
*
|
||||
* Conséquence : ce composant ne reçoit jamais de titre, seulement (mois, pilier).
|
||||
*/
|
||||
|
||||
export type PointAnnee = { mois: number; pilier: Pilier };
|
||||
|
||||
const MOIS_INITIALE = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
|
||||
const MOIS_LABEL = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
];
|
||||
|
||||
const MAX_PASTILLES = 7;
|
||||
|
||||
export function TimelineAnnee({
|
||||
points,
|
||||
moisCourant,
|
||||
className,
|
||||
}: {
|
||||
points: PointAnnee[];
|
||||
/** Index 0-11 du mois en cours — sert au repère « on est ici », pas au tri. */
|
||||
moisCourant?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const colonnes = Array.from({ length: 12 }, (_, mois) => ({
|
||||
mois,
|
||||
points: points.filter((p) => p.mois === mois),
|
||||
}));
|
||||
const pic = Math.max(1, ...colonnes.map((c) => Math.min(c.points.length, MAX_PASTILLES)));
|
||||
|
||||
return (
|
||||
<div className={cn("surface-card overflow-hidden p-4 sm:p-5", className)}>
|
||||
<div className="flex items-end gap-1 sm:gap-1.5" style={{ height: `${pic * 14 + 18}px` }}>
|
||||
{colonnes.map(({ mois, points: pts }) => {
|
||||
const visibles = pts.slice(0, MAX_PASTILLES);
|
||||
const reste = pts.length - visibles.length;
|
||||
const ici = mois === moisCourant;
|
||||
return (
|
||||
<div
|
||||
key={mois}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-end gap-1 rounded-md pb-1 transition-colors",
|
||||
ici && "bg-accent-soft/60",
|
||||
)}
|
||||
>
|
||||
{reste > 0 && (
|
||||
<span className="tnum text-[0.6rem] leading-none font-semibold text-faint">
|
||||
+{reste}
|
||||
</span>
|
||||
)}
|
||||
{visibles.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="block size-2 shrink-0 rounded-full sm:size-2.5"
|
||||
style={{ backgroundColor: PILIER_META[p.pilier].color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1 border-t border-line pt-2 sm:gap-1.5">
|
||||
{colonnes.map(({ mois, points: pts }) => (
|
||||
<div key={mois} className="flex-1 text-center">
|
||||
<abbr
|
||||
title={`${pts.length} échéance${pts.length > 1 ? "s" : ""} en ${MOIS_LABEL[mois]}`}
|
||||
className={cn(
|
||||
"text-[0.7rem] font-semibold no-underline",
|
||||
mois === moisCourant ? "text-accent" : "text-faint",
|
||||
)}
|
||||
>
|
||||
{MOIS_INITIALE[mois]}
|
||||
</abbr>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Légende des piliers — accompagne toujours la timeline, la couleur seule ne suffit pas (a11y). */
|
||||
export function LegendePiliers({
|
||||
compte,
|
||||
className,
|
||||
}: {
|
||||
compte: Partial<Record<Pilier, number>>;
|
||||
className?: string;
|
||||
}) {
|
||||
const entries = (Object.keys(PILIER_META) as Pilier[]).filter((p) => (compte[p] ?? 0) > 0);
|
||||
return (
|
||||
<ul className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5", className)}>
|
||||
{entries.map((p) => (
|
||||
<li key={p} className="flex items-center gap-1.5 text-sm text-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: PILIER_META[p].color }}
|
||||
/>
|
||||
{PILIER_META[p].label}
|
||||
<span className="tnum font-semibold text-ink-soft">{compte[p]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/button-styles.ts
Normal file
36
src/components/ui/button-styles.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { cn } from "@/lib/cn";
|
||||
|
||||
/**
|
||||
* Générateur de classes du bouton, isolé dans un module sans JSX : il est consommé
|
||||
* aussi bien par `<Button>` que par des `<Link>` situés dans des composants client,
|
||||
* et un module purement TS traverse la frontière serveur/client sans ambiguïté.
|
||||
*/
|
||||
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-2 rounded-xl font-semibold no-underline " +
|
||||
"transition-[background-color,color,border-color,box-shadow,transform] duration-150 " +
|
||||
"active:translate-y-px disabled:pointer-events-none disabled:opacity-45 " +
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "bg-accent text-accent-ink shadow-soft hover:bg-accent-hover hover:shadow-lift",
|
||||
secondary: "bg-accent-soft text-accent-soft-ink hover:brightness-95",
|
||||
outline: "border border-line-strong bg-surface text-ink hover:border-accent hover:text-accent",
|
||||
ghost: "text-muted hover:text-accent hover:bg-accent-soft",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "h-9 px-3.5 text-sm",
|
||||
md: "h-11 px-5 text-[0.975rem]",
|
||||
lg: "h-13 px-7 text-base",
|
||||
};
|
||||
|
||||
export function buttonClass(
|
||||
variant: ButtonVariant = "primary",
|
||||
size: ButtonSize = "md",
|
||||
extra?: string,
|
||||
): string {
|
||||
return cn(BASE, VARIANTS[variant], SIZES[size], extra);
|
||||
}
|
||||
13
src/components/ui/button.tsx
Normal file
13
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { buttonClass, type ButtonSize, type ButtonVariant } from "./button-styles";
|
||||
|
||||
export { buttonClass };
|
||||
export type { ButtonSize, ButtonVariant };
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
|
||||
export function Button({ variant = "primary", size = "md", className, type = "button", ...rest }: Props) {
|
||||
return <button type={type} className={buttonClass(variant, size, className)} {...rest} />;
|
||||
}
|
||||
92
src/components/ui/form.tsx
Normal file
92
src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
/* ── Champs ────────────────────────────────────────────────────────────── */
|
||||
|
||||
const FIELD =
|
||||
"w-full rounded-xl border border-line-strong bg-surface px-4 text-ink placeholder:text-faint " +
|
||||
"transition-colors duration-150 focus:border-accent focus:outline-2 focus:outline-offset-1 focus:outline-accent";
|
||||
|
||||
export function Input({ className, ...rest }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={cn(FIELD, "h-12 text-base", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function Select({ className, ...rest }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select className={cn(FIELD, "h-12 text-base", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function FieldLabel({ className, ...rest }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn("mb-1.5 block text-sm font-medium text-ink-soft", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function Hint({ className, ...rest }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("mt-1.5 text-sm text-muted", className)} {...rest} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erreur de formulaire. `role="alert"` : l'échec du quiz est le pire moment du
|
||||
* parcours pour laisser un lecteur d'écran dans le silence.
|
||||
*/
|
||||
export function ErrorNote({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border border-warn-line bg-warn-soft px-3.5 py-2.5 text-sm text-warn",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Chips ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Bouton à bascule du quiz. `aria-pressed` (et non une classe seule) : sans lui,
|
||||
* un lecteur d'écran ne distingue pas une réponse cochée d'une réponse libre.
|
||||
*/
|
||||
export function Chip({
|
||||
on,
|
||||
className,
|
||||
...rest
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { on: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-4 py-2.5 text-[0.95rem]",
|
||||
"transition-[background-color,border-color,color] duration-120",
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||
on
|
||||
? "border-accent bg-accent-soft font-semibold text-accent-soft-ink"
|
||||
: "border-line-strong bg-surface text-ink-soft hover:border-accent hover:text-accent",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChipGroup({
|
||||
legend,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
legend: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={cn("mt-6", className)}>
|
||||
<legend className="mb-1 text-sm font-medium text-ink-soft">{legend}</legend>
|
||||
{hint && <p className="mb-3 text-sm text-muted">{hint}</p>}
|
||||
<div className={cn("flex flex-wrap gap-2.5", !hint && "mt-3")}>{children}</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
128
src/components/ui/primitives.tsx
Normal file
128
src/components/ui/primitives.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { cn } from "@/lib/cn";
|
||||
|
||||
/* ── Surfaces ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
as: As = "div",
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLElement> & { as?: "div" | "section" | "article" | "li" }) {
|
||||
return <As className={cn("surface-card p-5 sm:p-6", className)} {...rest} />;
|
||||
}
|
||||
|
||||
/** Bloc pleine largeur qui rompt la colonne de lecture — sert à rythmer les pages longues. */
|
||||
export function Band({
|
||||
className,
|
||||
tone = "alt",
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLElement> & { tone?: "alt" | "accent" | "plain" }) {
|
||||
const tones = {
|
||||
alt: "bg-canvas-alt border-y border-line",
|
||||
accent: "bg-accent-soft border-y border-accent-line",
|
||||
plain: "",
|
||||
} as const;
|
||||
return (
|
||||
<section className={cn("py-16 sm:py-24", tones[tone], className)} {...rest}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Colonne de contenu. `narrow` = lecture, `default` = hub, `wide` = grilles. */
|
||||
export function Container({
|
||||
width = "default",
|
||||
className,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { width?: "narrow" | "default" | "wide" }) {
|
||||
const widths = { narrow: "max-w-2xl", default: "max-w-3xl", wide: "max-w-6xl" } as const;
|
||||
return <div className={cn("mx-auto w-full px-5 sm:px-6", widths[width], className)} {...rest} />;
|
||||
}
|
||||
|
||||
/* ── Texte ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Eyebrow({ className, ...rest }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full bg-accent-soft px-3 py-1",
|
||||
"font-sans text-[0.7rem] font-bold tracking-[0.09em] text-accent-soft-ink uppercase",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionTitle({
|
||||
className,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("text-section text-ink", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function Lede({ className, ...rest }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-lg leading-relaxed text-ink-soft text-pretty", className)} {...rest} />;
|
||||
}
|
||||
|
||||
/* ── Signalétique ──────────────────────────────────────────────────────── */
|
||||
|
||||
export type BadgeTone = "neutral" | "accent" | "warn" | "locked";
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
className,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
neutral: "bg-surface-inset text-muted border-line",
|
||||
accent: "bg-accent-soft text-accent-soft-ink border-accent-line",
|
||||
warn: "bg-warn-soft text-warn border-warn-line",
|
||||
locked: "bg-transparent text-faint border-line border-dashed",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5",
|
||||
"text-[0.7rem] font-semibold tracking-wide uppercase",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pastille colorée d'un pilier. La couleur est passée en variable, jamais en classe. */
|
||||
export function PilierDot({ color, className }: { color: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block size-2 shrink-0 rounded-full", className)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cartouche d'icône teintée par le pilier — repère de tri sur le hub et les guides. */
|
||||
export function IconTile({
|
||||
color,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
color: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex size-10 shrink-0 items-center justify-center rounded-xl", className)}
|
||||
style={{
|
||||
color,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 13%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
293
src/db/schema.ts
293
src/db/schema.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, uuid, text, jsonb, timestamp, boolean, integer, index } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, jsonb, timestamp, boolean, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Comptes légers (§ 6.3, § 25) — schéma généré par `npx @better-auth/cli generate`, ne pas
|
||||
|
|
@ -59,7 +59,7 @@ export const households = pgTable("households", {
|
|||
quizAnswers: jsonb("quiz_answers").notNull(),
|
||||
/**
|
||||
* Abonnement (§ 21, § 10) — 19,99 €/an, rattaché au compte payeur (§ 6.3).
|
||||
* "free" = preview verrouillée ; "active" = full reveal + veille + plaque réelle.
|
||||
* "free" = preview verrouillée ; "active" = full reveal + veille + rappels constructeur.
|
||||
*/
|
||||
subscriptionStatus: text("subscription_status").notNull().default("free"), // free | active | expired | canceled
|
||||
subscriptionPeriodEnd: timestamp("subscription_period_end"),
|
||||
|
|
@ -97,7 +97,7 @@ export const assets = pgTable("assets", {
|
|||
assetType: text("asset_type").notNull(),
|
||||
label: text("label").notNull(),
|
||||
attributes: jsonb("attributes").notNull(),
|
||||
provenance: text("provenance").notNull(), // quiz | detection_plaque | detection_adresse
|
||||
provenance: text("provenance").notNull(), // quiz | carte_grise | detection_adresse | opt-in
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -140,6 +140,293 @@ export const leads = pgTable("leads", {
|
|||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Référentiel véhicules — réceptions CE par type, source RDW Open Data (domaine public,
|
||||
* mise à jour quotidienne côté source). Remplace le catalogue ADEME, qui n'était qu'un
|
||||
* instantané du marché neuf (trou 2016-2025, aucune couverture occasion, pas d'identifiant).
|
||||
*
|
||||
* Grain : une ligne par (réception × variante × version) — exactement la granularité des
|
||||
* champs K et D.2 de la carte grise française :
|
||||
* - champ K (numéro de réception communautaire) → receptionNumero
|
||||
* - champ D.2 (Type-Variante-Version) → variante + version
|
||||
* Un foyer qui saisit ces deux champs identifie son véhicule exactement, sans API payante.
|
||||
*
|
||||
* Données de référence externes rafraîchies par cron (§ 22) — ce n'est PAS la couche
|
||||
* Connaissance (§ 20.1, templates YAML versionnés en git) : ni éditorial, ni règle métier.
|
||||
* Volumétrie assumée (~2,8 M lignes) : batch hebdomadaire, jamais de lecture temps réel lourde.
|
||||
*/
|
||||
export const vehiculesReference = pgTable("vehicules_reference", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
// — Identité (champs de la carte grise) —
|
||||
receptionNumero: text("reception_numero").notNull(), // champ K, ex. "e2*2001/116*0362*00"
|
||||
variante: text("variante").notNull(), // champ D.2 (partie variante)
|
||||
version: text("version").notNull(), // champ D.2 (partie version)
|
||||
revision: integer("revision").notNull().default(0),
|
||||
/** Autorité émettrice dérivée du champ K : e2 = France, e1 = Allemagne, e19 = Roumanie… */
|
||||
receptionPays: text("reception_pays"),
|
||||
dateReception: text("date_reception"), // ISO court (AAAA-MM-JJ), tel que publié
|
||||
|
||||
// — Identification —
|
||||
marqueCode: text("marque_code"), // code RDW, ex. "PEUP"
|
||||
marque: text("marque"), // libellé résolu, ex. "PEUGEOT"
|
||||
modele: text("modele"), // dénomination commerciale, ex. "308"
|
||||
typeFabricant: text("type_fabricant"),
|
||||
|
||||
// — Catégorie & carrosserie —
|
||||
categorie: text("categorie"), // M1 = voiture particulière
|
||||
carrosserie: text("carrosserie"),
|
||||
nbRoues: integer("nb_roues"),
|
||||
conduite: text("conduite"),
|
||||
/**
|
||||
* Une réception couvre une PLAGE de variantes : la source publie systématiquement une borne
|
||||
* basse et une borne haute. On conserve les deux — n'en garder qu'une perdait l'amplitude
|
||||
* réelle du type homologué.
|
||||
*/
|
||||
nbPortesMin: integer("nb_portes_min"),
|
||||
nbPortesMax: integer("nb_portes_max"),
|
||||
nbPlacesMin: integer("nb_places_min"),
|
||||
nbPlacesMax: integer("nb_places_max"),
|
||||
|
||||
// — Motorisation —
|
||||
/**
|
||||
* Une version peut déclarer PLUSIEURS sources d'énergie : c'est ainsi (et seulement ainsi)
|
||||
* qu'un hybride se reconnaît chez RDW — aucun code ne dit « hybride ». On conserve la liste
|
||||
* complète des codes, faute de quoi un Yaris Hybrid ressortirait en simple « essence ».
|
||||
*/
|
||||
energieCode: text("energie_code"), // code de la source principale (thermique si elle existe)
|
||||
energiesCodes: jsonb("energies_codes"), // toutes les sources déclarées, ex. ["B","E"]
|
||||
energie: text("energie"), // normalisé : essence | diesel | electrique | hybride | autre
|
||||
seqMotorisation: integer("seq_motorisation"),
|
||||
seqEnergie: integer("seq_energie"),
|
||||
puissanceKwMin: text("puissance_kw_min"),
|
||||
puissanceKwMax: text("puissance_kw_max"),
|
||||
regimePuissanceMin: integer("regime_puissance_min"),
|
||||
regimePuissanceMax: integer("regime_puissance_max"),
|
||||
cylindree: integer("cylindree"),
|
||||
maxBiocarburant: text("max_biocarburant"), // % max de biocarburant admis (compatibilité E85)
|
||||
/** Source électrique — renseignée pour les hybrides et les électriques. */
|
||||
puissanceElecMin: text("puissance_elec_min"), // régime 30 min, convention RDW
|
||||
puissanceElecMax: text("puissance_elec_max"),
|
||||
autonomieElecKm: integer("autonomie_elec_km"),
|
||||
consoElec: text("conso_elec"),
|
||||
|
||||
// — Normes d'émission —
|
||||
normeEmission: text("norme_emission"), // ex. "EURO 3"
|
||||
reglementEmission: text("reglement_emission"),
|
||||
|
||||
// — Consommation & CO2, cycle NEDC (véhicules antérieurs à ~2018) —
|
||||
consoNedcMin: text("conso_nedc_min"),
|
||||
consoNedcMax: text("conso_nedc_max"),
|
||||
co2NedcMin: text("co2_nedc_min"),
|
||||
co2NedcMax: text("co2_nedc_max"),
|
||||
co2UrbainMin: text("co2_urbain_min"),
|
||||
co2UrbainMax: text("co2_urbain_max"),
|
||||
co2RouteMin: text("co2_route_min"),
|
||||
co2RouteMax: text("co2_route_max"),
|
||||
|
||||
// — Consommation & CO2, cycle WLTP (véhicules récents) —
|
||||
consoWltpMin: text("conso_wltp_min"),
|
||||
consoWltpMax: text("conso_wltp_max"),
|
||||
co2WltpMin: text("co2_wltp_min"),
|
||||
co2WltpMax: text("co2_wltp_max"),
|
||||
|
||||
// — Émissions polluantes (essai type 1) —
|
||||
emCo: text("em_co"),
|
||||
emHc: text("em_hc"),
|
||||
emNox: text("em_nox"),
|
||||
emHcNox: text("em_hc_nox"),
|
||||
emParticules: text("em_particules"),
|
||||
emNbParticules: text("em_nb_particules"),
|
||||
emCoWltp: text("em_co_wltp"),
|
||||
emNoxWltp: text("em_nox_wltp"),
|
||||
emParticulesWltp: text("em_particules_wltp"),
|
||||
emNbParticulesWltp: text("em_nb_particules_wltp"),
|
||||
|
||||
// — Bruit —
|
||||
niveauSonoreMin: text("niveau_sonore_min"),
|
||||
niveauSonoreMax: text("niveau_sonore_max"),
|
||||
niveauSonoreRoulant: text("niveau_sonore_roulant"),
|
||||
regimeSonoreMin: integer("regime_sonore_min"),
|
||||
regimeSonoreMax: integer("regime_sonore_max"),
|
||||
|
||||
// — Gabarit & masses (bornes basse/haute de la réception) —
|
||||
longueurMin: integer("longueur_min"),
|
||||
longueurMax: integer("longueur_max"),
|
||||
largeurMin: integer("largeur_min"),
|
||||
largeurMax: integer("largeur_max"),
|
||||
hauteurMin: integer("hauteur_min"),
|
||||
hauteurMax: integer("hauteur_max"),
|
||||
empattementMin: integer("empattement_min"),
|
||||
empattementMax: integer("empattement_max"),
|
||||
masseOrdreMarcheMin: integer("masse_ordre_marche_min"),
|
||||
masseOrdreMarcheMax: integer("masse_ordre_marche_max"),
|
||||
masseMaxMin: integer("masse_max_min"),
|
||||
masseMaxMax: integer("masse_max_max"),
|
||||
vitesseMaxMin: text("vitesse_max_min"),
|
||||
vitesseMaxMax: text("vitesse_max_max"),
|
||||
vitesseConstrMin: text("vitesse_constr_min"),
|
||||
vitesseConstrMax: text("vitesse_constr_max"),
|
||||
|
||||
// — Coefficients de résistance au roulement (homologation WLTP) —
|
||||
resistanceF0Min: text("resistance_f0_min"),
|
||||
resistanceF0Max: text("resistance_f0_max"),
|
||||
resistanceF1Min: text("resistance_f1_min"),
|
||||
resistanceF1Max: text("resistance_f1_max"),
|
||||
resistanceF2Min: text("resistance_f2_min"),
|
||||
resistanceF2Max: text("resistance_f2_max"),
|
||||
|
||||
/**
|
||||
* Longue traîne : les champs publiés par la source mais renseignés sur moins de 5 % des
|
||||
* voitures particulières (cycles d'essai poids lourds, places debout, valeurs pondérées…).
|
||||
* Mesuré : ils ne pèsent que 0,8 % du volume, là où les champs typés ci-dessus en faisaient
|
||||
* 99,2 % — d'où le partage. Rien n'est perdu, tout reste interrogeable.
|
||||
*/
|
||||
brut: jsonb("brut"),
|
||||
|
||||
/** Marqueur de passage du cron : les lignes non revues au dernier run sont purgées. */
|
||||
syncedAt: timestamp("synced_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("vehicules_reference_cle_idx").on(
|
||||
table.receptionNumero, table.variante, table.version, table.revision,
|
||||
),
|
||||
index("vehicules_reference_reception_idx").on(table.receptionNumero),
|
||||
index("vehicules_reference_modele_idx").on(table.marque, table.modele),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Catalogue agrégé marque/modèle — dérivé de vehicules_reference à chaque synchronisation.
|
||||
* Sert l'autocomplétion du quiz (§ 4.1, aucune saisie libre) : quelques milliers de lignes
|
||||
* au lieu de 2,8 M, pour que la frappe reste instantanée.
|
||||
*/
|
||||
export const vehiculesModeles = pgTable("vehicules_modeles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
marque: text("marque").notNull(),
|
||||
modele: text("modele").notNull(),
|
||||
/**
|
||||
* Clés de rapprochement sans accents ni casse, calculées à la reconstruction du catalogue.
|
||||
* Stockées et non calculées à la volée : c'est ce qui permet à la résolution
|
||||
* (src/knowledge/vehicules-resolution.ts) d'être un simple index scan, et surtout d'être
|
||||
* la MÊME comparaison que celle des rappels — un « Citroën » du foyer doit retrouver le
|
||||
* « CITROEN » de la source, ici comme là-bas.
|
||||
*/
|
||||
marqueNorm: text("marque_norm").notNull(),
|
||||
modeleNorm: text("modele_norm").notNull(),
|
||||
/** Énergies réellement commercialisées pour ce modèle — restreint les choix du quiz. */
|
||||
energies: jsonb("energies").notNull(),
|
||||
/** Nb de configurations couvertes : sert à trier les suggestions par pertinence. */
|
||||
configurations: integer("configurations").notNull().default(0),
|
||||
anneeMin: integer("annee_min"),
|
||||
anneeMax: integer("annee_max"),
|
||||
syncedAt: timestamp("synced_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("vehicules_modeles_cle_idx").on(table.marque, table.modele),
|
||||
index("vehicules_modeles_modele_idx").on(table.modele),
|
||||
index("vehicules_modeles_norm_idx").on(table.marqueNorm, table.modeleNorm),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Entretien constructeur — intervalles de vidange et de distribution par modèle.
|
||||
*
|
||||
* Vivait en YAML (knowledge/vehicules/entretien-constructeur.yaml) du temps où le référentiel
|
||||
* véhicules venait d'ADEME. La bascule en base répond à trois choses qui rendaient le fichier
|
||||
* intenable : il ne pouvait pas joindre le catalogue, il inventait donc son propre vocabulaire
|
||||
* de clés (groupe_id, famille_moteur) qu'aucun producteur ne définissait plus, et il forçait
|
||||
* une lecture fs au cœur du moteur de fenêtres.
|
||||
*
|
||||
* Ce n'est pas la couche Connaissance (§ 20.1) malgré son origine humaine : aucun éditorial,
|
||||
* aucun ID stable à vie, ~13 000 lignes cibles — une relecture de diff git y serait une fiction.
|
||||
* La relecture devient une colonne (`confiance`, `revu_par`, `revu_le`), donc requêtable :
|
||||
* « combien d'entrées non vérifiées servent des foyers aujourd'hui » est une requête SQL.
|
||||
*/
|
||||
export const vehiculesEntretien = pgTable("vehicules_entretien", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
/** Libellés lisibles, repris du catalogue (vehicules_modeles). */
|
||||
marque: text("marque").notNull(),
|
||||
modele: text("modele").notNull(),
|
||||
/** Clés de rapprochement — les mêmes que la résolution et les rappels, jamais d'autres. */
|
||||
marqueNorm: text("marque_norm").notNull(),
|
||||
modeleNorm: text("modele_norm").notNull(),
|
||||
/**
|
||||
* Énergie visée. Chaîne vide = la préconisation vaut pour toutes les motorisations du modèle.
|
||||
* Vide plutôt que NULL : PostgreSQL considère deux NULL comme distincts dans un index unique,
|
||||
* ce qui laisserait passer des doublons silencieux sur la clé.
|
||||
*/
|
||||
energie: text("energie").notNull().default(""),
|
||||
/** Motorisation précise quand la notice la distingue (ex. « 1.5 BlueHDi 130 »). */
|
||||
motorisation: text("motorisation").notNull().default(""),
|
||||
|
||||
/** Vidange moteur — au moins l'un des deux, sinon l'entrée n'apporte rien. */
|
||||
vidangeKm: integer("vidange_km"),
|
||||
vidangeMois: integer("vidange_mois"),
|
||||
/** Distribution : une chaîne n'a pas d'échéance de remplacement programmée, une courroie si. */
|
||||
distributionType: text("distribution_type"), // courroie | chaine
|
||||
distributionKm: integer("distribution_km"),
|
||||
distributionMois: integer("distribution_mois"),
|
||||
|
||||
/** Traçabilité — ce qui remplace la revue de diff : d'où ça vient, qui l'a vérifié. */
|
||||
source: text("source").notNull(),
|
||||
confiance: text("confiance").notNull().default("a_verifier"), // haute | moyenne | a_verifier
|
||||
revuPar: text("revu_par"),
|
||||
revuLe: timestamp("revu_le"),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("vehicules_entretien_cle_idx").on(
|
||||
table.marqueNorm, table.modeleNorm, table.energie, table.motorisation,
|
||||
),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Rappels constructeur (campagnes de sécurité), source RDW Open Data.
|
||||
*
|
||||
* L'échéance dormante par excellence (§ 5.3) : le courrier du constructeur est parti il y a
|
||||
* des années et s'est perdu, la réparation est GRATUITE, l'enjeu peut être vital — et personne
|
||||
* ne surveille. C'est exactement la promesse de fokan.
|
||||
*
|
||||
* Limite structurelle assumée : la source ne précise NI les millésimes NI les VIN concernés.
|
||||
* On ne peut donc jamais affirmer « ta voiture est rappelée », seulement « ce modèle a fait
|
||||
* l'objet d'un rappel » — la restitution doit porter cette réserve (cf. src/knowledge/rappels.ts).
|
||||
*/
|
||||
export const rappels = pgTable("rappels", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
reference: text("reference").notNull(), // referentiecode_rdw
|
||||
datePublication: text("date_publication"), // ISO court — borne haute des millésimes concernés
|
||||
producteur: text("producteur"),
|
||||
/** Libellés déjà traduits à l'ingestion : la source publie en néerlandais. */
|
||||
categorie: text("categorie"),
|
||||
gravite: text("gravite"), // grave | moyenne | legere | non_precisee
|
||||
dangers: jsonb("dangers"), // ex. ["Accident avec blessures"]
|
||||
/** Textes libres conservés tels quels (néerlandais) — traçabilité, jamais affichés. */
|
||||
defautSource: text("defaut_source"),
|
||||
reparationSource: text("reparation_source"),
|
||||
nbVehicules: integer("nb_vehicules"),
|
||||
infoUrl: text("info_url"),
|
||||
infoTel: text("info_tel"),
|
||||
syncedAt: timestamp("synced_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("rappels_reference_idx").on(table.reference),
|
||||
]);
|
||||
|
||||
/** Modèles visés par un rappel — c'est la table de jointure vers le référentiel véhicules. */
|
||||
export const rappelsModeles = pgTable("rappels_modeles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
reference: text("reference").notNull(),
|
||||
marque: text("marque").notNull(),
|
||||
modele: text("modele").notNull(),
|
||||
/**
|
||||
* Clés de rapprochement sans accents ni casse, calculées à l'ingestion.
|
||||
* Indispensable : la source écrit « CITROEN », un foyer peut avoir « Citroën ». Comparer les
|
||||
* libellés bruts renvoyait zéro rappel à tout propriétaire de marque accentuée — en silence.
|
||||
*/
|
||||
marqueNorm: text("marque_norm").notNull(),
|
||||
modeleNorm: text("modele_norm").notNull(),
|
||||
syncedAt: timestamp("synced_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("rappels_modeles_cle_idx").on(table.reference, table.marque, table.modele),
|
||||
index("rappels_modeles_vehicule_idx").on(table.marqueNorm, table.modeleNorm),
|
||||
]);
|
||||
|
||||
/** Funnel Phase 0 (métriques § 13) — sobre, pas de tracking tiers. */
|
||||
export const events = pgTable("events", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
|
|
|||
69
src/db/upsert.test.ts
Normal file
69
src/db/upsert.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getTableColumns } from "drizzle-orm";
|
||||
import {
|
||||
vehiculesReference, vehiculesModeles, vehiculesEntretien, rappels, rappelsModeles,
|
||||
} from "@/db/schema";
|
||||
import { clauseMiseAJour, tailleLot } from "./upsert";
|
||||
|
||||
/**
|
||||
* Toutes ces tables sont rechargées par upsert. Le risque n'est pas le premier passage — qui
|
||||
* insère tout — mais les passages SUIVANTS : une colonne absente de la clause SET ne serait
|
||||
* jamais rafraîchie et conserverait indéfiniment sa valeur d'origine, sans qu'aucune erreur ne
|
||||
* le signale. Ces tests verrouillent la garantie pour chaque table, pas seulement le référentiel :
|
||||
* la clause de `rappels` était encore écrite à la main, exposée au même défaut.
|
||||
*/
|
||||
|
||||
const TABLES = [
|
||||
{ nom: "vehicules_reference", table: vehiculesReference, cle: ["receptionNumero", "variante", "version", "revision"] },
|
||||
{ nom: "vehicules_modeles", table: vehiculesModeles, cle: ["marque", "modele"] },
|
||||
{ nom: "vehicules_entretien", table: vehiculesEntretien, cle: ["marqueNorm", "modeleNorm", "energie", "motorisation"] },
|
||||
{ nom: "rappels", table: rappels, cle: ["reference"] },
|
||||
{ nom: "rappels_modeles", table: rappelsModeles, cle: ["reference", "marque", "modele"] },
|
||||
] as const;
|
||||
|
||||
describe.each(TABLES)("clauseMiseAJour — $nom", ({ table, cle }) => {
|
||||
it("rafraîchit chaque colonne de la table, sans exception oubliée", () => {
|
||||
const colonnes = Object.keys(getTableColumns(table));
|
||||
const attendues = colonnes.filter((c) => c !== "id" && !cle.includes(c as never));
|
||||
expect(Object.keys(clauseMiseAJour(table, cle)).sort()).toEqual(attendues.sort());
|
||||
});
|
||||
|
||||
it("ne réécrit jamais la clé d'unicité ni l'identifiant technique", () => {
|
||||
const misesAJour = Object.keys(clauseMiseAJour(table, cle));
|
||||
for (const protegee of ["id", ...cle]) expect(misesAJour).not.toContain(protegee);
|
||||
});
|
||||
|
||||
it("garde l'INSERT sous le plafond de 65 535 paramètres de PostgreSQL", () => {
|
||||
// Vécu : 87 colonnes × 1 000 lignes = 87 000 paramètres, le compteur 16 bits déborde et
|
||||
// PostgreSQL rejette tout le passage avec un message trompeur. La taille du lot doit donc
|
||||
// suivre le nombre de colonnes, pas rester figée.
|
||||
const colonnes = Object.keys(getTableColumns(table)).length;
|
||||
expect(tailleLot(table)).toBeGreaterThan(0);
|
||||
expect(tailleLot(table) * colonnes).toBeLessThan(65_535);
|
||||
});
|
||||
});
|
||||
|
||||
describe("garanties spécifiques héritées de bugs vécus", () => {
|
||||
it("couvre les colonnes du référentiel ajoutées après coup (hybride, données électriques)", () => {
|
||||
const misesAJour = Object.keys(clauseMiseAJour(vehiculesReference, ["receptionNumero", "variante", "version", "revision"]));
|
||||
for (const recente of ["energiesCodes", "puissanceElecMax", "autonomieElecKm", "consoElec"]) {
|
||||
expect(misesAJour).toContain(recente);
|
||||
}
|
||||
});
|
||||
|
||||
it("propage le marqueur de passage, sans quoi la purge effacerait des lignes valides", () => {
|
||||
// syncedAt sert à repérer les lignes non revues : s'il n'était pas mis à jour, une ligne
|
||||
// pourtant présente dans la source serait considérée comme disparue et supprimée.
|
||||
for (const { table, cle } of TABLES) {
|
||||
const colonnes = Object.keys(getTableColumns(table));
|
||||
const marqueur = colonnes.find((c) => c === "syncedAt" || c === "updatedAt");
|
||||
if (marqueur) expect(Object.keys(clauseMiseAJour(table, cle))).toContain(marqueur);
|
||||
}
|
||||
});
|
||||
|
||||
it("maintient les clés normalisées des rappels, sans quoi un renommage de marque serait ignoré", () => {
|
||||
const misesAJour = Object.keys(clauseMiseAJour(rappelsModeles, ["reference", "marque", "modele"]));
|
||||
expect(misesAJour).toContain("marqueNorm");
|
||||
expect(misesAJour).toContain("modeleNorm");
|
||||
});
|
||||
});
|
||||
44
src/db/upsert.ts
Normal file
44
src/db/upsert.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Les deux garanties communes à toute table rechargée par upsert (référentiel véhicules,
|
||||
* catalogue, rappels, entretien constructeur).
|
||||
*
|
||||
* Elles sont dérivées du schéma et non écrites à la main, parce que les deux défauts qu'elles
|
||||
* empêchent sont silencieux : ni l'un ni l'autre ne produit d'erreur, on ne les découvre qu'en
|
||||
* constatant, des semaines plus tard, que la donnée est fausse.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nb de lignes par INSERT, déduit du nombre de colonnes.
|
||||
*
|
||||
* PostgreSQL plafonne une requête à 65 535 paramètres (compteur 16 bits) : au-delà, le compteur
|
||||
* déborde et le serveur rejette la requête avec un message trompeur (« bind message has N
|
||||
* parameter formats but 0 parameters »). Un lot figé se casse au prochain ajout de colonnes —
|
||||
* c'est exactement ce qui est arrivé en passant de 46 à 87 colonnes. On garde 20 % de marge.
|
||||
*/
|
||||
export function tailleLot(table: PgTable): number {
|
||||
const nbColonnes = Object.keys(getTableColumns(table)).length;
|
||||
return Math.max(1, Math.floor((65_535 * 0.8) / nbColonnes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clause SET de l'upsert : toutes les colonnes sauf l'identifiant technique et la clé d'unicité.
|
||||
*
|
||||
* C'est le point critique des synchronisations RÉCURRENTES. Une colonne oubliée ici ne serait
|
||||
* jamais rafraîchie et garderait indéfiniment sa valeur du premier passage, sans erreur visible.
|
||||
* Générer la liste depuis la table rend cette dérive impossible : ajouter une colonne suffit à
|
||||
* ce qu'elle soit maintenue.
|
||||
*/
|
||||
export function clauseMiseAJour<T extends PgTable>(
|
||||
table: T,
|
||||
cleUnicite: readonly string[],
|
||||
): Record<string, SQL> {
|
||||
const colonnes = getTableColumns(table);
|
||||
return Object.fromEntries(
|
||||
Object.entries(colonnes)
|
||||
.filter(([nom]) => nom !== "id" && !cleUnicite.includes(nom))
|
||||
.map(([nom, col]) => [nom, sql.raw(`excluded.${col.name}`)]),
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ function matchCondition(value: unknown, cond: Condition): boolean {
|
|||
return value === cond;
|
||||
}
|
||||
if ("in" in cond) return cond.in.includes(value as string | number);
|
||||
if ("not_in" in cond) return !cond.not_in.includes(value as string | number);
|
||||
if ("contains" in cond) return Array.isArray(value) && value.includes(cond.contains);
|
||||
if ("contains_any" in cond) return Array.isArray(value) && cond.contains_any.some((v) => value.includes(v));
|
||||
if ("exists" in cond) return cond.exists ? value !== undefined && value !== null : value == null;
|
||||
|
|
|
|||
58
src/engine/quiz-to-assets.test.ts
Normal file
58
src/engine/quiz-to-assets.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { QuizAnswers, quizToAssets } from "./quiz-to-assets";
|
||||
|
||||
/**
|
||||
* Le champ K (numéro de réception) est notre seule clé exacte vers le référentiel véhicules.
|
||||
* Le quiz s'en servait pour pré-remplir marque/modèle puis le jetait : un foyer qui l'avait
|
||||
* recopié devenait indiscernable d'un foyer ayant tapé n'importe quoi, et tout enrichissement
|
||||
* ultérieur aurait dû le redemander — ce que la règle d'or interdit.
|
||||
*/
|
||||
|
||||
function reponses(vehicule: Record<string, unknown>) {
|
||||
return QuizAnswers.parse({
|
||||
logement: { type: "maison", statut: "proprietaire" },
|
||||
vehicules: [vehicule],
|
||||
});
|
||||
}
|
||||
|
||||
describe("quizToAssets — la carte grise ne se perd pas en route", () => {
|
||||
it("porte le champ K et le champ D.2 jusqu'aux attributs de l'asset", () => {
|
||||
const assets = quizToAssets(reponses({
|
||||
marque: "CITROEN", modele: "C5 AIRCROSS",
|
||||
reception_numero: "e2*2007/46*0623*11", variante: "SUV", version: "AB01",
|
||||
source: "carte_grise",
|
||||
}));
|
||||
const vehicule = assets.find((a) => a.type === "vehicule");
|
||||
expect(vehicule?.attributes).toMatchObject({
|
||||
reception_numero: "e2*2007/46*0623*11",
|
||||
variante: "SUV",
|
||||
version: "AB01",
|
||||
source: "carte_grise",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepte une identification par champ K sans champ D.2 déterminé", () => {
|
||||
// Une réception couvre souvent plusieurs variantes : le D.2 reste alors inconnu, et
|
||||
// c'est une réponse de première classe — le numéro de réception suffit à raccrocher.
|
||||
const assets = quizToAssets(reponses({
|
||||
marque: "PEUGEOT", modele: "308", reception_numero: "e2*2001/116*0362*00", source: "carte_grise",
|
||||
}));
|
||||
const vehicule = assets.find((a) => a.type === "vehicule");
|
||||
expect(vehicule?.attributes.reception_numero).toBe("e2*2001/116*0362*00");
|
||||
expect(vehicule?.attributes.variante).toBeUndefined();
|
||||
});
|
||||
|
||||
it("laisse un véhicule purement déclaratif sans clé de réception", () => {
|
||||
const assets = quizToAssets(reponses({ marque: "RENAULT", modele: "CLIO", source: "declaratif" }));
|
||||
const vehicule = assets.find((a) => a.type === "vehicule");
|
||||
expect(vehicule?.attributes.reception_numero).toBeUndefined();
|
||||
expect(vehicule?.attributes.source).toBe("declaratif");
|
||||
});
|
||||
|
||||
it("refuse une source inconnue plutôt que de la laisser filer en base", () => {
|
||||
expect(QuizAnswers.safeParse({
|
||||
logement: { type: "maison", statut: "proprietaire" },
|
||||
vehicules: [{ source: "inventee" }],
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,17 @@ export const QuizAnswers = z.object({
|
|||
label: z.string(),
|
||||
departement: z.string(),
|
||||
loi_montagne: z.boolean(),
|
||||
/**
|
||||
* Identifiants géographiques de l'adresse (§ 0.b, docs/pilier-maison.md) : jusqu'ici
|
||||
* calculés puis jetés, alors que banId est la seule clé de jointure exacte vers l'observatoire
|
||||
* DPE de l'ADEME et que lat/lon/citycode ouvrent tout le reste (Géorisques, VigiEau, Hub'Eau).
|
||||
* Les perdre obligerait à refaire géocoder l'adresse pour le moindre enrichissement futur —
|
||||
* même leçon que le champ K de la carte grise pour les véhicules.
|
||||
*/
|
||||
citycode: z.string().optional(),
|
||||
banId: z.string().optional(),
|
||||
lat: z.number().optional(),
|
||||
lon: z.number().optional(),
|
||||
}).nullable().optional(),
|
||||
logement: z.object({
|
||||
type: z.enum(["maison", "appartement"]),
|
||||
|
|
@ -14,16 +25,30 @@ export const QuizAnswers = z.object({
|
|||
chauffage: z.array(z.enum(["gaz", "fioul", "electrique", "pac", "bois", "nsp"])).default([]),
|
||||
equipements: z.array(z.enum(["jardin", "piscine", "fosse_septique", "adoucisseur", "chauffe_eau"])).default([]),
|
||||
annee_construction: z.number().optional(),
|
||||
/**
|
||||
* DPE — jamais rempli automatiquement depuis la donnée publique : uniquement sur confirmation
|
||||
* explicite de l'utilisateur (src/knowledge/dpe-confirmation.ts). Un DPE peut décrire un
|
||||
* logement qui n'existe plus (travaux faits sans nouveau diagnostic).
|
||||
*/
|
||||
dpe_etiquette: z.enum(["A", "B", "C", "D", "E", "F", "G"]).optional(),
|
||||
dpe_fin_validite: z.string().optional(),
|
||||
}),
|
||||
vehicules: z.array(z.object({
|
||||
plaque: z.string().optional(),
|
||||
/**
|
||||
* Champs K et D.2 de la carte grise, conservés quand l'identification est passée par eux.
|
||||
* C'est notre seule clé exacte vers le référentiel (vehicules_reference) : la perdre
|
||||
* obligerait à la redemander pour tout enrichissement ultérieur.
|
||||
*/
|
||||
reception_numero: z.string().optional(),
|
||||
variante: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
marque: z.string().optional(),
|
||||
modele: z.string().optional(),
|
||||
annee: z.number().optional(),
|
||||
carburant: z.enum(["essence", "diesel", "electrique", "hybride"]).optional(),
|
||||
energie: z.enum(["essence", "diesel", "electrique", "hybride"]).optional(),
|
||||
/** Date apprise (§ 4.3) : personne ne la fournit aujourd'hui, ctWindow s'en sert si elle arrive. */
|
||||
date_premiere_immat: z.string().optional(),
|
||||
critair: z.string().optional(),
|
||||
source: z.enum(["api", "mock", "declaratif"]).default("declaratif"),
|
||||
source: z.enum(["carte_grise", "declaratif"]).default("declaratif"),
|
||||
})).default([]),
|
||||
adultes: z.number().min(1).max(6).default(1),
|
||||
enfants: z.array(z.enum(["0-5", "6-11", "12-15", "16-17"])).default([]),
|
||||
|
|
@ -56,6 +81,12 @@ export function quizToAssets(answers: QuizAnswers): Asset[] {
|
|||
...answers.logement,
|
||||
chauffage: answers.logement.chauffage.filter((c) => c !== "nsp"),
|
||||
departement: answers.adresse?.departement,
|
||||
// Conservés pour l'enrichissement post-quiz (Géorisques, VigiEau, Hub'Eau — Phase 1+ du
|
||||
// pilier maison) : la jointure exacte n'est possible qu'avec ces identifiants.
|
||||
citycode: answers.adresse?.citycode,
|
||||
banId: answers.adresse?.banId,
|
||||
lat: answers.adresse?.lat,
|
||||
lon: answers.adresse?.lon,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
households as householdsTable,
|
||||
} from "@/db";
|
||||
import { loadTemplates } from "@/knowledge/loader";
|
||||
import { entretienPourVehicules, type EntretienVehicule } from "@/knowledge/entretien-vehicules";
|
||||
import { resoudreVehicules } from "@/knowledge/vehicules-resolution";
|
||||
import type { Asset } from "@/knowledge/schema";
|
||||
import { applicableDeadlines } from "./applicability";
|
||||
import { computeWindow, type Window } from "./windows";
|
||||
|
|
@ -17,17 +19,38 @@ function sourceDateFor(confidence: Window["confidence"]): string {
|
|||
return confidence === "connue" ? "apprise" : "inferee";
|
||||
}
|
||||
|
||||
/** Construit l'état désiré du foyer à partir de ses assets — le calcul est pur (§ 20.1). */
|
||||
export function buildDesiredState(assets: (Asset & { id: string })[], now = new Date()): DesiredDeadline[] {
|
||||
/**
|
||||
* Construit l'état désiré du foyer à partir de ses assets — le calcul est pur (§ 20.1).
|
||||
*
|
||||
* `entretiens` (assetId → intervalles constructeur) est chargé en amont par `chargerEntretiens`
|
||||
* plutôt que lu depuis le moteur : c'est ce qui garde cette fonction déterministe et testable
|
||||
* sans base ni disque, comme l'exige l'invariant n° 5.
|
||||
*/
|
||||
export function buildDesiredState(
|
||||
assets: (Asset & { id: string })[],
|
||||
now = new Date(),
|
||||
entretiens: Map<string, EntretienVehicule> = new Map(),
|
||||
): DesiredDeadline[] {
|
||||
const matches = applicableDeadlines(loadTemplates(), assets);
|
||||
return matches.map(({ template, asset }) => ({
|
||||
templateId: template.id,
|
||||
templateVersion: template.version,
|
||||
assetId: asset.id,
|
||||
window: computeWindow(template, asset, now),
|
||||
window: computeWindow(template, asset, now, entretiens.get(asset.id)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge l'entretien constructeur des véhicules du foyer — la frontière I/O.
|
||||
* Dégradation douce : si le référentiel est indisponible, on rend une carte vide et le moteur
|
||||
* retombe sur ses libellés génériques. Une échéance imprécise vaut mieux qu'une page en erreur.
|
||||
*/
|
||||
async function chargerEntretiens(assets: (Asset & { id: string })[]): Promise<Map<string, EntretienVehicule>> {
|
||||
const resolus = await resoudreVehicules(assets).catch(() => []);
|
||||
const cibles = resolus.map(({ asset, resolu }) => ({ id: (asset as Asset & { id: string }).id, resolu }));
|
||||
return entretienPourVehicules(cibles).catch(() => new Map<string, EntretienVehicule>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Réconcilie un foyer : calcule l'état désiré, le diffe avec l'état existant, applique le plan.
|
||||
* Idempotente et rejouable à volonté (§ 20.4) — déclenchée à chaque mutation du foyer (quiz, contrat).
|
||||
|
|
@ -43,7 +66,7 @@ export async function runReconciliation(householdId: string) {
|
|||
attributes: r.attributes as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
const desired = buildDesiredState(assets);
|
||||
const desired = buildDesiredState(assets, new Date(), await chargerEntretiens(assets));
|
||||
|
||||
const existingRows = await database.select().from(deadlinesTable).where(eq(deadlinesTable.householdId, householdId));
|
||||
const existing: ExistingDeadline[] = existingRows.map((r) => ({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { planReconciliation, type DesiredDeadline, type ExistingDeadline, type R
|
|||
|
||||
const FENETRE_TEST = { start: null, label: "fenêtre test", confidence: "estimee" as const };
|
||||
|
||||
function desired(templateId: string, assetId: string, templateVersion = 1, window = FENETRE_TEST): DesiredDeadline {
|
||||
function desired(templateId: string, assetId: string, templateVersion = 1, window: DesiredDeadline["window"] = FENETRE_TEST): DesiredDeadline {
|
||||
return { templateId, assetId, templateVersion, window };
|
||||
}
|
||||
|
||||
|
|
@ -60,8 +60,8 @@ describe("planReconciliation", () => {
|
|||
});
|
||||
|
||||
it("met à jour la fenêtre quand elle change SANS bump de version (ex. asset enrichi par une détection)", () => {
|
||||
// Cas réel trouvé en prod : ajout de la plaque réelle après abonnement change la fenêtre
|
||||
// désirée du contrôle technique (estimée → connue) sans que le template n'ait bougé.
|
||||
// Cas réel trouvé en prod : un asset enrichi après coup (date de 1re immatriculation
|
||||
// apprise) change la fenêtre du contrôle technique (estimée → connue) sans bump de version.
|
||||
const ancienneFenetre = { start: null, label: "courant 2027", confidence: "estimee" as const };
|
||||
const nouvelleFenetre = { start: new Date("2027-01-30"), label: "avant janvier 2027", confidence: "connue" as const };
|
||||
const rows = [existing({ id: "dl-1", templateId: "controle-technique", assetId: "vehicule-1", window: ancienneFenetre })];
|
||||
|
|
|
|||
98
src/engine/windows.test.ts
Normal file
98
src/engine/windows.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { DeadlineTemplate, Asset } from "@/knowledge/schema";
|
||||
import type { EntretienVehicule } from "@/knowledge/entretien-vehicules";
|
||||
|
||||
// Fixture explicitement fictive — aucune donnée constructeur réelle n'est encore sourcée.
|
||||
// Plus aucun mock ici : `computeWindow` reçoit l'entretien en argument au lieu d'aller le
|
||||
// chercher sur disque. C'est la raison d'être de ce changement — un moteur qui lit des
|
||||
// fichiers n'est pas testable, il est simulable.
|
||||
const FIXTURE: EntretienVehicule = {
|
||||
marque: "TESTMARQUE",
|
||||
modele: "TESTMODELE",
|
||||
energie: "essence",
|
||||
motorisation: "",
|
||||
vidange: { km: 15000, mois: 12 },
|
||||
distribution: { type: "courroie", km: 120000, mois: 84 },
|
||||
confiance: "haute",
|
||||
source: "fixture de test",
|
||||
};
|
||||
|
||||
import { computeWindow } from "./windows";
|
||||
|
||||
function template(overrides: Partial<DeadlineTemplate>): DeadlineTemplate {
|
||||
return {
|
||||
id: "test-fiche",
|
||||
version: 1,
|
||||
pilier: "vehicules",
|
||||
applicabilite: { asset_type: "vehicule" },
|
||||
recurrence: { freq: "yearly" },
|
||||
enjeu: { type: "argent", resume: "test" },
|
||||
contenus: { hub: { titre: "Test", description: "Test" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vehicule(attributes: Record<string, unknown> = {}): Asset {
|
||||
return { type: "vehicule", label: "Véhicule test", attributes };
|
||||
}
|
||||
|
||||
describe("computeWindow — stratégies véhicule nourries par l'entretien constructeur", () => {
|
||||
it("courroie_distribution : donnée constructeur fournie → label précis, confiance estimée", () => {
|
||||
const w = computeWindow(template({ strategy: "courroie_distribution" }), vehicule(), new Date(), FIXTURE);
|
||||
expect(w.confidence).toBe("estimee");
|
||||
expect(w.label).toMatch(/120.000 km/);
|
||||
expect(w.label).toContain("TESTMARQUE TESTMODELE");
|
||||
});
|
||||
|
||||
it("courroie_distribution : pas de donnée constructeur → repli générique inchangé", () => {
|
||||
const w = computeWindow(template({ strategy: "courroie_distribution" }), vehicule());
|
||||
expect(w).toEqual({ start: null, label: "selon le kilométrage — préconisation du modèle", confidence: "libre" });
|
||||
});
|
||||
|
||||
it("courroie_distribution : une chaîne ne devient jamais une échéance de remplacement", () => {
|
||||
// Annoncer un remplacement de distribution sur un moteur à chaîne, c'est inventer une
|
||||
// dépense. On le dit explicitement plutôt que de laisser le repli générique le suggérer.
|
||||
const chaine = { ...FIXTURE, distribution: { type: "chaine" as const } };
|
||||
const w = computeWindow(template({ strategy: "courroie_distribution" }), vehicule(), new Date(), chaine);
|
||||
expect(w.confidence).toBe("libre");
|
||||
expect(w.label).toContain("pas de remplacement programmé");
|
||||
});
|
||||
|
||||
it("revision_constructeur : donnée constructeur fournie → label précis, confiance estimée", () => {
|
||||
const w = computeWindow(template({ strategy: "revision_constructeur" }), vehicule(), new Date(), FIXTURE);
|
||||
expect(w.confidence).toBe("estimee");
|
||||
expect(w.label).toMatch(/15.000 km/);
|
||||
});
|
||||
|
||||
it("revision_constructeur : pas de donnée constructeur → repli sur le calcul générique par recurrence.freq (non-régression)", () => {
|
||||
const w = computeWindow(template({ strategy: "revision_constructeur", recurrence: { freq: "yearly" } }), vehicule());
|
||||
expect(w).toEqual({ start: null, label: "1 fois par an — date apprise à l'usage", confidence: "libre" });
|
||||
});
|
||||
});
|
||||
|
||||
function logement(attributes: Record<string, unknown> = {}): Asset {
|
||||
return { type: "logement", label: "Logement test", attributes };
|
||||
}
|
||||
|
||||
describe("computeWindow — dpe_validite (pilier maison, docs/pilier-maison.md § 1)", () => {
|
||||
it("dpe_fin_validite confirmé → fenêtre exacte, confiance connue", () => {
|
||||
const w = computeWindow(
|
||||
template({ pilier: "maison", strategy: "dpe_validite", applicabilite: { asset_type: "logement" } }),
|
||||
logement({ dpe_fin_validite: "2033-10-15" }),
|
||||
);
|
||||
expect(w.confidence).toBe("connue");
|
||||
expect(w.start).toEqual(new Date("2033-10-15"));
|
||||
expect(w.label).toContain("15/10/2033");
|
||||
});
|
||||
|
||||
it("dpe_fin_validite absent (jamais confirmé) → repli générique, jamais de date inventée", () => {
|
||||
// Cas normal : la fiche elle-même n'est applicable que si dpe_fin_validite existe
|
||||
// (applicability.ts), mais le moteur ne doit pas planter ni fabriquer de date si on
|
||||
// l'appelle quand même — même filet de sécurité que ctWindow sans date connue.
|
||||
const w = computeWindow(
|
||||
template({ pilier: "maison", strategy: "dpe_validite", applicabilite: { asset_type: "logement" } }),
|
||||
logement(),
|
||||
);
|
||||
expect(w).toEqual({ start: null, label: "dès que le diagnostic est confirmé", confidence: "libre" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import type { Asset, DeadlineTemplate } from "@/knowledge/schema";
|
||||
import type { EntretienVehicule } from "@/knowledge/entretien-vehicules";
|
||||
|
||||
/**
|
||||
* Calcul des fenêtres (§ 4.3) : des fenêtres avec niveau de confiance, jamais des dates exigées.
|
||||
* Phase 0 : calcul à la volée pour le hub figé et l'export .ics.
|
||||
*
|
||||
* Ce module est PUR (§ 20.1, invariant n° 5) : aucune lecture disque, aucune requête. L'entretien
|
||||
* constructeur lui est passé en argument par la couche I/O (src/engine/reconcile-db.ts). Il a
|
||||
* un temps lu lui-même un fichier YAML, ce qui rendait le moteur non testable sans mock et
|
||||
* faisait dépendre un calcul déterministe de l'état du disque.
|
||||
*/
|
||||
|
||||
export type Window = {
|
||||
|
|
@ -54,15 +60,69 @@ function ctWindow(asset: Asset, now: Date): Window {
|
|||
};
|
||||
}
|
||||
|
||||
export function computeWindow(template: DeadlineTemplate, asset: Asset, now = new Date()): Window {
|
||||
/** Fenêtre libre par défaut (aucune donnée constructeur trouvée pour ce véhicule). */
|
||||
function repliGenerique(texte: string): Window {
|
||||
return { start: null, label: texte, confidence: "libre" };
|
||||
}
|
||||
|
||||
/** Formate l'intervalle km/mois d'une entrée d'entretien constructeur. */
|
||||
function labelPreconisation(intervalle: { km?: number; mois?: number }, entretien: EntretienVehicule): string {
|
||||
const parts: string[] = [];
|
||||
if (intervalle.km) parts.push(`${intervalle.km.toLocaleString("fr-FR")} km`);
|
||||
if (intervalle.mois) {
|
||||
parts.push(intervalle.mois % 12 === 0 ? `${intervalle.mois / 12} an${intervalle.mois > 12 ? "s" : ""}` : `${intervalle.mois} mois`);
|
||||
}
|
||||
const modele = [entretien.marque, entretien.modele, entretien.motorisation].filter(Boolean).join(" ");
|
||||
return `tous les ${parts.join(" ou ")} — préconisation ${modele}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stratégie DPE (Phase 0, pilier maison) : la fenêtre se cale sur la date de fin de validité
|
||||
* réelle, confirmée par l'utilisateur — jamais sur la seule donnée publique (§ 1 du plan pilier
|
||||
* maison, docs/pilier-maison.md). `dpe_fin_validite` n'existe sur l'asset que si l'utilisateur
|
||||
* a confirmé le diagnostic ; sinon la fiche n'est simplement pas applicable (cf. applicability.ts).
|
||||
*/
|
||||
function dpeValiditeWindow(asset: Asset): Window {
|
||||
const finValidite = asset.attributes.dpe_fin_validite
|
||||
? new Date(String(asset.attributes.dpe_fin_validite))
|
||||
: null;
|
||||
if (!finValidite || isNaN(finValidite.getTime())) {
|
||||
return repliGenerique("dès que le diagnostic est confirmé");
|
||||
}
|
||||
return { start: finValidite, label: `avant le ${finValidite.toLocaleDateString("fr-FR")}`, confidence: "connue" };
|
||||
}
|
||||
|
||||
export function computeWindow(
|
||||
template: DeadlineTemplate,
|
||||
asset: Asset,
|
||||
now = new Date(),
|
||||
entretien?: EntretienVehicule,
|
||||
): Window {
|
||||
if (template.strategy === "ct_vehicule") return ctWindow(asset, now);
|
||||
if (template.strategy === "dpe_validite") return dpeValiditeWindow(asset);
|
||||
if (template.strategy === "passeport_enfant") {
|
||||
// Vérification calée avant l'été : mars (délais mairie 2-4 mois avant les vacances).
|
||||
const start = nextSeasonStart([3], now);
|
||||
return { start, label: `${labelFor(start)} — avant les vacances d'été`, confidence: "estimee" };
|
||||
}
|
||||
if (template.strategy === "courroie_distribution") {
|
||||
return { start: null, label: "selon le kilométrage — préconisation du modèle", confidence: "libre" };
|
||||
// Une chaîne ne se remplace pas à échéance : on le dit, plutôt que de laisser croire
|
||||
// à une dépense à venir sur un véhicule qui n'en aura pas.
|
||||
if (entretien?.distribution?.type === "chaine") {
|
||||
return repliGenerique("chaîne de distribution — pas de remplacement programmé");
|
||||
}
|
||||
const d = entretien?.distribution;
|
||||
if (d && (d.km || d.mois)) {
|
||||
return { start: null, label: labelPreconisation(d, entretien!), confidence: "estimee" };
|
||||
}
|
||||
return repliGenerique("selon le kilométrage — préconisation du modèle");
|
||||
}
|
||||
if (template.strategy === "revision_constructeur") {
|
||||
if (entretien?.vidange) {
|
||||
return { start: null, label: labelPreconisation(entretien.vidange, entretien), confidence: "estimee" };
|
||||
}
|
||||
// Pas de donnée constructeur trouvée : repli sur le calcul générique ci-dessous (comportement
|
||||
// inchangé — recurrence.freq de la fiche, cf. knowledge/templates/vehicules.yaml).
|
||||
}
|
||||
// Mois d'échéance déclaré par l'utilisateur (opt-in contrat, § 5.6) : prime sur la fenêtre par défaut.
|
||||
const echeanceMois = asset.attributes.echeance_mois;
|
||||
|
|
|
|||
|
|
@ -30,5 +30,44 @@ export async function register() {
|
|||
console.log("[cron] réconciliation quotidienne", summary);
|
||||
});
|
||||
|
||||
console.log("[pg-boss] démarré — réconciliation planifiée tous les jours à 4h (Europe/Paris)");
|
||||
/**
|
||||
* Rafraîchissement du référentiel véhicules (RDW). Hebdomadaire et non quotidien : la source
|
||||
* ne bouge qu'à la marge (nouvelles homologations), alors que le passage complet dure ~2 h
|
||||
* (31 500 réceptions, ~2,8 M de configurations).
|
||||
*
|
||||
* Deux contraintes de calendrier, apprises à la mise au point :
|
||||
* - départ à 0h30 le dimanche pour que le passage soit FINI avant la réconciliation de 4h ;
|
||||
* sinon le moteur travaille sur un référentiel à moitié rafraîchi.
|
||||
* - expiration portée à 4h : pg-boss tue un job à 15 min par défaut (QUEUE_DEFAULTS), ce qui
|
||||
* aurait haché ce passage en le rejouant sans fin, sans erreur visible.
|
||||
*/
|
||||
const QUEUE_VEHICULES = "referentiel-vehicules-hebdo";
|
||||
await boss.createQueue(QUEUE_VEHICULES, {
|
||||
policy: "standard",
|
||||
expireInSeconds: 4 * 60 * 60,
|
||||
// Une reprise suffit : le job est idempotent, mais s'acharner sur 2h de travail qui échoue
|
||||
// ne ferait que retarder d'autant le passage suivant.
|
||||
retryLimit: 1,
|
||||
retryDelay: 60 * 60,
|
||||
});
|
||||
await boss.schedule(QUEUE_VEHICULES, "30 0 * * 0", null, { tz: "Europe/Paris" });
|
||||
|
||||
await boss.work(QUEUE_VEHICULES, async () => {
|
||||
const { syncVehiculesRdw } = await import("@/knowledge/sync-vehicules");
|
||||
const { syncRappels } = await import("@/knowledge/rappels");
|
||||
const summary = await syncVehiculesRdw();
|
||||
// Les rappels constructeur viennent de la même source et pèsent quelques secondes :
|
||||
// ils suivent le même passage plutôt que d'ouvrir une file pour si peu. En cas d'échec
|
||||
// ici, le référentiel véhicules reste acquis — on ne rejoue pas 30 min pour autant.
|
||||
const rappels = await syncRappels().catch((e) => {
|
||||
console.error("[cron] rappels — échec, référentiel véhicules conservé :", String(e));
|
||||
return null;
|
||||
});
|
||||
await db().insert(events).values({ name: "cron_vehicules_rdw", props: { ...summary, rappels } });
|
||||
console.log("[cron] référentiel véhicules", summary, "rappels", rappels);
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[pg-boss] démarré — réconciliation quotidienne 4h, référentiel véhicules dimanche 0h30 (Europe/Paris)",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
src/knowledge/dpe-confirmation.test.ts
Normal file
29
src/knowledge/dpe-confirmation.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { resoudreConfirmationDpe, type DpeSuggestion } from "./dpe-confirmation";
|
||||
|
||||
const suggestion: DpeSuggestion = { etiquette: "F", dateFinValidite: "2033-10-15" };
|
||||
|
||||
describe("resoudreConfirmationDpe — le dérivé ne crée jamais d'échéance seul", () => {
|
||||
it("ne renvoie rien tant que rien n'a été confirmé", () => {
|
||||
expect(resoudreConfirmationDpe(suggestion, undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("adopte l'étiquette et la fin de validité sur confirmation explicite", () => {
|
||||
expect(resoudreConfirmationDpe(suggestion, "confirme")).toEqual({
|
||||
dpe_etiquette: "F",
|
||||
dpe_fin_validite: "2033-10-15",
|
||||
});
|
||||
});
|
||||
|
||||
it("laisse le logement sans DPE connu si des travaux ont eu lieu depuis", () => {
|
||||
expect(resoudreConfirmationDpe(suggestion, "travaux")).toEqual({});
|
||||
});
|
||||
|
||||
it("laisse le logement sans DPE connu sur « je ne sais pas »", () => {
|
||||
expect(resoudreConfirmationDpe(suggestion, "ne_sait_pas")).toEqual({});
|
||||
});
|
||||
|
||||
it("ne peut rien confirmer en l'absence de suggestion", () => {
|
||||
expect(resoudreConfirmationDpe(undefined, "confirme")).toEqual({});
|
||||
});
|
||||
});
|
||||
23
src/knowledge/dpe-confirmation.ts
Normal file
23
src/knowledge/dpe-confirmation.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Résolution déclaré > confirmé > dérivé pour le DPE (docs/pilier-maison.md § 1) — invariant
|
||||
* du pilier maison : une valeur dérivée (DPE public) ne crée jamais d'échéance seule, elle doit
|
||||
* être confirmée explicitement. Un propriétaire peut avoir isolé ou changé de chauffage sans
|
||||
* refaire son diagnostic : "j'ai fait des travaux depuis" et "je ne sais pas" laissent le
|
||||
* logement sans DPE connu, exactement comme s'il n'y en avait aucun.
|
||||
*
|
||||
* Pur, sans I/O — appelé par le quiz au clic sur la confirmation.
|
||||
*/
|
||||
|
||||
export type DpeConfirmationReponse = "confirme" | "travaux" | "ne_sait_pas";
|
||||
|
||||
export type DpeSuggestion = { etiquette: string; dateFinValidite: string };
|
||||
|
||||
export type DpeConfirme = { dpe_etiquette?: string; dpe_fin_validite?: string };
|
||||
|
||||
export function resoudreConfirmationDpe(
|
||||
suggestion: DpeSuggestion | undefined,
|
||||
reponse: DpeConfirmationReponse | undefined,
|
||||
): DpeConfirme {
|
||||
if (!suggestion || reponse !== "confirme") return {};
|
||||
return { dpe_etiquette: suggestion.etiquette, dpe_fin_validite: suggestion.dateFinValidite };
|
||||
}
|
||||
77
src/knowledge/entretien-vehicules.test.ts
Normal file
77
src/knowledge/entretien-vehicules.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { choisirEntretien, EntreeEntretien, type EntretienVehicule } from "./entretien-vehicules";
|
||||
|
||||
/**
|
||||
* Fixtures explicitement fictives (marque TESTMARQUE) : aucune donnée constructeur réelle n'a
|
||||
* encore été sourcée, et rien ici ne doit pouvoir passer pour telle.
|
||||
*/
|
||||
function entree(o: Partial<EntretienVehicule> = {}): EntretienVehicule {
|
||||
return {
|
||||
marque: "TESTMARQUE", modele: "TESTMODELE", energie: "", motorisation: "",
|
||||
vidange: { km: 15000, mois: 12 }, distribution: null,
|
||||
confiance: "haute", source: "fixture de test",
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
describe("choisirEntretien — la plus spécifique gagne, jamais la plus fausse", () => {
|
||||
it("préfère l'entrée de l'énergie du véhicule à l'entrée générique du modèle", () => {
|
||||
const generique = entree({ vidange: { km: 15000 } });
|
||||
const diesel = entree({ energie: "diesel", vidange: { km: 25000 } });
|
||||
expect(choisirEntretien([generique, diesel], { energie: "diesel" })).toBe(diesel);
|
||||
});
|
||||
|
||||
it("préfère une motorisation nommée à une simple correspondance d'énergie", () => {
|
||||
const parEnergie = entree({ energie: "diesel" });
|
||||
const parMotorisation = entree({ energie: "diesel", motorisation: "1.5 BlueHDi 130" });
|
||||
expect(choisirEntretien([parEnergie, parMotorisation], { energie: "diesel" })).toBe(parMotorisation);
|
||||
});
|
||||
|
||||
it("retombe sur l'entrée générique quand aucune entrée ne vise l'énergie du véhicule", () => {
|
||||
const generique = entree();
|
||||
const diesel = entree({ energie: "diesel" });
|
||||
expect(choisirEntretien([generique, diesel], { energie: "essence" })).toBe(generique);
|
||||
});
|
||||
|
||||
it("n'attribue JAMAIS l'intervalle d'une autre énergie", () => {
|
||||
// Un intervalle diesel appliqué à une essence serait une préconisation fausse, affichée
|
||||
// avec le même aplomb qu'une vraie. Mieux vaut ne rien dire.
|
||||
const diesel = entree({ energie: "diesel", vidange: { km: 25000 } });
|
||||
expect(choisirEntretien([diesel], { energie: "essence" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ne lève pas d'erreur quand rien n'est connu pour ce modèle", () => {
|
||||
expect(choisirEntretien([], { energie: "essence" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EntreeEntretien — ce que le sourcing a le droit d'écrire", () => {
|
||||
const base = { marque: "TESTMARQUE", modele: "TESTMODELE", source: "notice fictive" };
|
||||
|
||||
it("accepte une entrée avec le seul intervalle de vidange", () => {
|
||||
expect(EntreeEntretien.safeParse({ ...base, vidange_km: 15000 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuse une entrée qui n'apporte aucun intervalle", () => {
|
||||
expect(EntreeEntretien.safeParse(base).success).toBe(false);
|
||||
});
|
||||
|
||||
it("refuse un intervalle de remplacement sur une chaîne de distribution", () => {
|
||||
// Une chaîne se contrôle mais ne se remplace pas à échéance : lui inventer un intervalle
|
||||
// ferait afficher au foyer une dépense programmée qui n'existe pas.
|
||||
expect(EntreeEntretien.safeParse({
|
||||
...base, distribution_type: "chaine", distribution_km: 120000,
|
||||
}).success).toBe(false);
|
||||
expect(EntreeEntretien.safeParse({ ...base, distribution_type: "chaine" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuse des kilométrages absurdes plutôt que de les stocker", () => {
|
||||
expect(EntreeEntretien.safeParse({ ...base, vidange_km: -5 }).success).toBe(false);
|
||||
expect(EntreeEntretien.safeParse({ ...base, vidange_km: 15000.5 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("marque une entrée non relue « à vérifier » par défaut", () => {
|
||||
const parsed = EntreeEntretien.parse({ ...base, vidange_km: 15000 });
|
||||
expect(parsed.confiance).toBe("a_verifier");
|
||||
});
|
||||
});
|
||||
182
src/knowledge/entretien-vehicules.ts
Normal file
182
src/knowledge/entretien-vehicules.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, vehiculesEntretien } from "@/db";
|
||||
import { clauseMiseAJour, tailleLot } from "@/db/upsert";
|
||||
import { cleVehicule } from "./vehicules-cle";
|
||||
import type { VehiculeResolu } from "./vehicules-resolution";
|
||||
|
||||
/**
|
||||
* Entretien constructeur — intervalles de vidange et de distribution par modèle.
|
||||
*
|
||||
* Table de référence (src/db/schema.ts), pas une fiche de connaissance : elle ne produit aucune
|
||||
* échéance à elle seule, elle précise la fenêtre de fiches qui existent déjà
|
||||
* (`revision-constructeur`, `courroie-distribution` dans knowledge/templates/vehicules.yaml).
|
||||
* Sans donnée pour un véhicule, le moteur retombe sur son libellé générique — jamais d'erreur.
|
||||
*
|
||||
* Peuplée par le sourcing des notices constructeur (extraction + relecture humaine), via
|
||||
* `importerEntretien()`. La relecture est tracée en colonnes, pas en historique git.
|
||||
*/
|
||||
|
||||
/** Une entrée telle que le pipeline de sourcing la produit, avant écriture. */
|
||||
export const EntreeEntretien = z.object({
|
||||
marque: z.string().min(1),
|
||||
modele: z.string().min(1),
|
||||
/** Vide = vaut pour toutes les motorisations du modèle. */
|
||||
energie: z.enum(["essence", "diesel", "electrique", "hybride", "autre", ""]).default(""),
|
||||
motorisation: z.string().default(""),
|
||||
vidange_km: z.number().int().positive().optional(),
|
||||
vidange_mois: z.number().int().positive().optional(),
|
||||
distribution_type: z.enum(["courroie", "chaine"]).optional(),
|
||||
distribution_km: z.number().int().positive().optional(),
|
||||
distribution_mois: z.number().int().positive().optional(),
|
||||
source: z.string().min(1),
|
||||
confiance: z.enum(["haute", "moyenne", "a_verifier"]).default("a_verifier"),
|
||||
revu_par: z.string().optional(),
|
||||
}).refine(
|
||||
(v) => v.vidange_km || v.vidange_mois || v.distribution_type,
|
||||
"une entrée sans intervalle de vidange ni distribution n'apporte rien",
|
||||
).refine(
|
||||
// Une chaîne se contrôle mais ne se remplace pas à échéance : lui donner un intervalle
|
||||
// ferait inventer une échéance au moteur.
|
||||
(v) => v.distribution_type !== "chaine" || (!v.distribution_km && !v.distribution_mois),
|
||||
"une chaîne de distribution ne porte pas d'intervalle de remplacement",
|
||||
);
|
||||
export type EntreeEntretien = z.infer<typeof EntreeEntretien>;
|
||||
|
||||
/** Clé d'unicité de la table — jamais réécrite lors d'un conflit. */
|
||||
const CLE_ENTRETIEN = ["marqueNorm", "modeleNorm", "energie", "motorisation"];
|
||||
|
||||
export type EntretienVehicule = {
|
||||
marque: string;
|
||||
modele: string;
|
||||
energie: string;
|
||||
motorisation: string;
|
||||
vidange: { km?: number; mois?: number } | null;
|
||||
distribution: { type: "courroie" | "chaine"; km?: number; mois?: number } | null;
|
||||
confiance: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
function intervalle(km: number | null, mois: number | null): { km?: number; mois?: number } | null {
|
||||
if (!km && !mois) return null;
|
||||
return { ...(km ? { km } : {}), ...(mois ? { mois } : {}) };
|
||||
}
|
||||
|
||||
type Ligne = typeof vehiculesEntretien.$inferSelect;
|
||||
|
||||
function versEntretien(r: Ligne): EntretienVehicule {
|
||||
return {
|
||||
marque: r.marque,
|
||||
modele: r.modele,
|
||||
energie: r.energie,
|
||||
motorisation: r.motorisation,
|
||||
vidange: intervalle(r.vidangeKm, r.vidangeMois),
|
||||
distribution: r.distributionType
|
||||
? { type: r.distributionType as "courroie" | "chaine", ...intervalle(r.distributionKm, r.distributionMois) }
|
||||
: null,
|
||||
confiance: r.confiance,
|
||||
source: r.source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Choisit l'entrée la plus spécifique parmi celles d'un même modèle — pure, testable sans base.
|
||||
*
|
||||
* Ordre de préférence : motorisation exacte > énergie du véhicule > entrée générique du modèle.
|
||||
* On ne retient jamais une entrée d'une AUTRE énergie : préconiser un intervalle diesel à une
|
||||
* essence serait faux, et un faux intervalle est pire que pas d'intervalle du tout.
|
||||
*/
|
||||
export function choisirEntretien(
|
||||
candidats: EntretienVehicule[],
|
||||
vehicule: Pick<VehiculeResolu, "energie">,
|
||||
): EntretienVehicule | undefined {
|
||||
const applicables = candidats.filter((c) => c.energie === "" || c.energie === vehicule.energie);
|
||||
if (!applicables.length) return undefined;
|
||||
const score = (c: EntretienVehicule) =>
|
||||
(c.motorisation ? 2 : 0) + (c.energie && c.energie === vehicule.energie ? 1 : 0);
|
||||
return [...applicables].sort((a, b) => score(b) - score(a))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Entretien connu pour un lot de véhicules déjà résolus. Une seule requête pour tout le foyer :
|
||||
* appelée par la couche I/O de la réconciliation, jamais depuis le moteur (qui reste pur).
|
||||
*/
|
||||
export async function entretienPourVehicules(
|
||||
vehicules: Array<{ id: string; resolu: VehiculeResolu }>,
|
||||
): Promise<Map<string, EntretienVehicule>> {
|
||||
const out = new Map<string, EntretienVehicule>();
|
||||
if (!vehicules.length) return out;
|
||||
|
||||
const cles = [...new Set(vehicules.map((v) => `${v.resolu.marqueNorm}|${v.resolu.modeleNorm}`))];
|
||||
const rows = await db()
|
||||
.select()
|
||||
.from(vehiculesEntretien)
|
||||
.where(inArray(
|
||||
sql`${vehiculesEntretien.marqueNorm} || '|' || ${vehiculesEntretien.modeleNorm}`,
|
||||
cles,
|
||||
));
|
||||
if (!rows.length) return out;
|
||||
|
||||
const parCle = new Map<string, EntretienVehicule[]>();
|
||||
for (const r of rows) {
|
||||
const cle = `${r.marqueNorm}|${r.modeleNorm}`;
|
||||
(parCle.get(cle) ?? parCle.set(cle, []).get(cle)!).push(versEntretien(r));
|
||||
}
|
||||
|
||||
for (const { id, resolu } of vehicules) {
|
||||
const candidats = parCle.get(`${resolu.marqueNorm}|${resolu.modeleNorm}`);
|
||||
const choix = candidats && choisirEntretien(candidats, resolu);
|
||||
if (choix) out.set(id, choix);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit un lot d'entrées issues du sourcing. Idempotent : rejouer le même lot ne crée pas de
|
||||
* doublon, il rafraîchit. Une entrée invalide fait échouer tout le lot avant la moindre
|
||||
* écriture — même philosophie que « une fiche invalide casse le build, jamais la prod ».
|
||||
*/
|
||||
export async function importerEntretien(entrees: unknown[]): Promise<{ ecrites: number }> {
|
||||
const lignes = entrees.map((e, i) => {
|
||||
const parsed = EntreeEntretien.safeParse(e);
|
||||
if (!parsed.success) throw new Error(`Entrée d'entretien n°${i + 1} invalide : ${parsed.error.message}`);
|
||||
const v = parsed.data;
|
||||
const cle = cleVehicule(v.marque, v.modele);
|
||||
if (!cle) throw new Error(`Marque/modèle inexploitables : « ${v.marque} » / « ${v.modele} »`);
|
||||
return {
|
||||
marque: v.marque, modele: v.modele, ...cle,
|
||||
energie: v.energie, motorisation: v.motorisation,
|
||||
vidangeKm: v.vidange_km ?? null, vidangeMois: v.vidange_mois ?? null,
|
||||
distributionType: v.distribution_type ?? null,
|
||||
distributionKm: v.distribution_km ?? null, distributionMois: v.distribution_mois ?? null,
|
||||
source: v.source, confiance: v.confiance,
|
||||
revuPar: v.revu_par ?? null, revuLe: v.revu_par ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
const database = db();
|
||||
const parLot = tailleLot(vehiculesEntretien);
|
||||
for (let i = 0; i < lignes.length; i += parLot) {
|
||||
await database.insert(vehiculesEntretien).values(lignes.slice(i, i + parLot)).onConflictDoUpdate({
|
||||
target: [
|
||||
vehiculesEntretien.marqueNorm, vehiculesEntretien.modeleNorm,
|
||||
vehiculesEntretien.energie, vehiculesEntretien.motorisation,
|
||||
],
|
||||
set: clauseMiseAJour(vehiculesEntretien, CLE_ENTRETIEN),
|
||||
});
|
||||
}
|
||||
return { ecrites: lignes.length };
|
||||
}
|
||||
|
||||
/** Couverture du référentiel — la métrique qui dit si le sourcing avance, et ce qui reste à relire. */
|
||||
export async function couvertureEntretien() {
|
||||
const [row] = await db()
|
||||
.select({
|
||||
entrees: sql<number>`count(*)::int`,
|
||||
modeles: sql<number>`count(distinct (${vehiculesEntretien.marqueNorm}, ${vehiculesEntretien.modeleNorm}))::int`,
|
||||
aVerifier: sql<number>`count(*) filter (where ${vehiculesEntretien.confiance} = 'a_verifier')::int`,
|
||||
})
|
||||
.from(vehiculesEntretien);
|
||||
return row ?? { entrees: 0, modeles: 0, aVerifier: 0 };
|
||||
}
|
||||
93
src/knowledge/rappels.test.ts
Normal file
93
src/knowledge/rappels.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { traduireCategorie, traduireDanger, traduireGravite, apercuRappels, type RappelVehicule } from "./rappels";
|
||||
import { normText } from "./vehicules-cle";
|
||||
|
||||
function rappel(overrides: Partial<RappelVehicule> = {}): RappelVehicule {
|
||||
return {
|
||||
reference: "MGP000001",
|
||||
categorie: "Freinage",
|
||||
gravite: "grave",
|
||||
dangers: ["Accident avec blessures"],
|
||||
datePublication: "2020-05-04",
|
||||
nbVehicules: 1200,
|
||||
infoUrl: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("traductions — la source publie en néerlandais", () => {
|
||||
it("traduit les catégories réellement rencontrées", () => {
|
||||
expect(traduireCategorie("Motorrijtuigen en aanhangwagens - reminrichting")).toBe("Freinage");
|
||||
expect(traduireCategorie("Motorrijtuigen - carrosserie (beschermingsmiddelen inzittenden)"))
|
||||
.toBe("Protection des occupants (airbags, ceintures)");
|
||||
});
|
||||
|
||||
it("retombe sur « Non précisé » plutôt que d'afficher du néerlandais à l'utilisateur", () => {
|
||||
expect(traduireCategorie("Categorie inconnue van de bron")).toBe("Non précisé");
|
||||
expect(traduireCategorie(undefined)).toBe("Non précisé");
|
||||
});
|
||||
|
||||
it("traduit les dangers par code, pas par libellé (le code est stable)", () => {
|
||||
expect(traduireDanger("ONG")).toBe("Accident avec blessures");
|
||||
expect(traduireDanger("BRA")).toBe("Incendie avec blessures");
|
||||
expect(traduireDanger("ong")).toBe("Accident avec blessures");
|
||||
expect(traduireDanger("XXX")).toBe("Non précisé");
|
||||
});
|
||||
|
||||
it("normalise la gravité de l'autorité", () => {
|
||||
expect(traduireGravite("ERN")).toBe("grave");
|
||||
expect(traduireGravite("GEM")).toBe("moyenne");
|
||||
expect(traduireGravite("LG")).toBe("legere");
|
||||
expect(traduireGravite(undefined)).toBe("non_precisee");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clés de rapprochement — la source écrit sans accents", () => {
|
||||
it("fait correspondre « Citroën » saisi par le foyer et « CITROEN » publié par la source", () => {
|
||||
// Bug vécu : la comparaison sur les libellés bruts renvoyait 0 rappel à tout propriétaire
|
||||
// de Citroën, sans erreur — les rappels étaient simplement invisibles pour eux.
|
||||
expect(normText("Citroën")).toBe(normText("CITROEN"));
|
||||
expect(normText("citroën")).toBe(normText("Citroen"));
|
||||
});
|
||||
|
||||
it("reste insensible à la casse et aux espaces superflus", () => {
|
||||
expect(normText(" peugeot ")).toBe(normText("PEUGEOT"));
|
||||
expect(normText("C3 Aircross")).toBe(normText("c3 aircross"));
|
||||
});
|
||||
|
||||
it("ne confond pas deux modèles réellement différents", () => {
|
||||
expect(normText("308")).not.toBe(normText("3008"));
|
||||
expect(normText("C3")).not.toBe(normText("C4"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("apercuRappels — ce que voit un foyer NON abonné", () => {
|
||||
it("expose le décompte et la nature, jamais le détail d'une campagne", () => {
|
||||
const apercu = apercuRappels([
|
||||
rappel({ categorie: "Freinage", gravite: "grave" }),
|
||||
rappel({ reference: "M2", categorie: "Direction", gravite: "moyenne" }),
|
||||
]);
|
||||
expect(apercu).toEqual({ total: 2, graves: 1, categories: ["Freinage", "Direction"] });
|
||||
// Aucune référence ni date ne doit fuiter dans l'aperçu — c'est le débloquant payant (§ 10.2).
|
||||
expect(JSON.stringify(apercu)).not.toContain("MGP000001");
|
||||
expect(JSON.stringify(apercu)).not.toContain("2020-05-04");
|
||||
});
|
||||
|
||||
it("dédoublonne les catégories et en montre au plus trois", () => {
|
||||
const apercu = apercuRappels([
|
||||
rappel({ categorie: "Freinage" }), rappel({ reference: "b", categorie: "Freinage" }),
|
||||
rappel({ reference: "c", categorie: "Direction" }), rappel({ reference: "d", categorie: "Suspension" }),
|
||||
rappel({ reference: "e", categorie: "Sièges" }),
|
||||
]);
|
||||
expect(apercu.total).toBe(5);
|
||||
expect(apercu.categories).toEqual(["Freinage", "Direction", "Suspension"]);
|
||||
});
|
||||
|
||||
it("ne compte comme graves que les campagnes ainsi jugées par l'autorité", () => {
|
||||
expect(apercuRappels([rappel({ gravite: "moyenne" }), rappel({ reference: "b", gravite: "legere" })]).graves).toBe(0);
|
||||
});
|
||||
|
||||
it("gère l'absence de rappel sans lever d'erreur", () => {
|
||||
expect(apercuRappels([])).toEqual({ total: 0, graves: 0, categories: [] });
|
||||
});
|
||||
});
|
||||
231
src/knowledge/rappels.ts
Normal file
231
src/knowledge/rappels.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { db, rappels, rappelsModeles } from "@/db";
|
||||
import { clauseMiseAJour, tailleLot } from "@/db/upsert";
|
||||
import { rdwQueryAll, retirerMarqueRedondante } from "./rdw";
|
||||
import { normText } from "./vehicules-cle";
|
||||
import type { VehiculeResolu } from "./vehicules-resolution";
|
||||
|
||||
/**
|
||||
* Rappels constructeur — import, traduction et restitution.
|
||||
*
|
||||
* Source RDW Open Data (domaine public) : 5 242 campagnes, rattachées aux modèles par
|
||||
* marque + dénomination. Volume négligeable, rafraîchi avec le cron véhicules hebdomadaire.
|
||||
*
|
||||
* Réserve qui gouverne toute la restitution : la source ne dit NI les millésimes NI les VIN
|
||||
* concernés. Un rappel désigne un MODÈLE, jamais un exemplaire. On ne peut donc que suggérer
|
||||
* une vérification, jamais annoncer que la voiture est concernée.
|
||||
*/
|
||||
|
||||
const DATASETS = {
|
||||
action: "j9yg-7rg9", // la campagne : défaut, catégorie, gravité, réparation
|
||||
modeles: "mu2x-mu5e", // campagne → marque + type
|
||||
risques: "9ihi-jgpf", // campagne → danger encouru
|
||||
} as const;
|
||||
|
||||
/** Les 19 catégories publiées par la source (énumération fermée, vérifiée exhaustivement). */
|
||||
const CATEGORIES: Record<string, string> = {
|
||||
"Motorrijtuigen - carrosserie (beschermingsmiddelen inzittenden)": "Protection des occupants (airbags, ceintures)",
|
||||
"Motorrijtuigen - motor inclusief brandstof-, smeer- en koelsysteem": "Moteur, carburant, refroidissement",
|
||||
"Motorrijtuigen en aanhangwagens - elektrische installatie": "Installation électrique",
|
||||
"Motorrijtuigen en aanhangwagens - reminrichting": "Freinage",
|
||||
"Motorrijtuigen en aanhangwagens - assen, wielen, velgen, banden": "Essieux, roues et pneus",
|
||||
"Motorrijtuigen en aanhangwagens - stuurinrichting": "Direction",
|
||||
"Motorrijtuigen en aanhangwagens - ophanging": "Suspension",
|
||||
"Motorrijtuigen - krachtoverbrenging": "Transmission",
|
||||
"Motorrijtuigen en aanhangwagens - carrosserie (diverse)": "Carrosserie",
|
||||
"Motorrijtuigen en aanhangwagens - carrosserie (deuren, motorkap, laadkleppen)": "Portes, capot, hayon",
|
||||
"Motorrijtuigen - carrosserie (zitplaatsen)": "Sièges",
|
||||
"Motorrijtuigen en aanhangwagens - carrosserie (ruiten, ruitenwissers, ruitensproeiers)": "Vitrage et essuie-glaces",
|
||||
"Motorrijtuigen en aanhangwagens - lichten, lichtsignalen en retroreflectie": "Éclairage et signalisation",
|
||||
"Motorrijtuigen en aanhangwagens - algemene bouwwijze": "Conception générale",
|
||||
"Motorrijtuigen en aanhangwagens - verbinding tussen motorvoertuig en aanhangwagen": "Attelage",
|
||||
"Motorrijtuigen en aanhangwagens - diversen": "Divers",
|
||||
"Product voldoet niet aan de typegoedkeuringseisen": "Non-conformité à l'homologation",
|
||||
"Onderdelen van motorrijtuigen en aanhangwagens": "Pièces détachées",
|
||||
"(Nog) niet bekend": "Non précisé",
|
||||
};
|
||||
|
||||
/** Les 5 dangers publiés, indexés par code (stable, contrairement au libellé). */
|
||||
const DANGERS: Record<string, string> = {
|
||||
ONG: "Accident avec blessures",
|
||||
TEL: "Risque accru de blessure en cas d'accident",
|
||||
BRA: "Incendie avec blessures",
|
||||
MIL: "Impact sur l'environnement",
|
||||
NNB: "Non précisé",
|
||||
};
|
||||
|
||||
/** Évaluation de gravité de l'autorité : ernstig / gemiddeld / licht. */
|
||||
const GRAVITES: Record<string, string> = {
|
||||
ERN: "grave", GEM: "moyenne", LG: "legere", NNB: "non_precisee", GNR: "non_precisee",
|
||||
};
|
||||
|
||||
export function traduireCategorie(v: string | undefined): string {
|
||||
return CATEGORIES[(v ?? "").trim()] ?? "Non précisé";
|
||||
}
|
||||
export function traduireDanger(code: string | undefined): string {
|
||||
return DANGERS[(code ?? "").trim().toUpperCase()] ?? "Non précisé";
|
||||
}
|
||||
export function traduireGravite(code: string | undefined): string {
|
||||
return GRAVITES[(code ?? "").trim().toUpperCase()] ?? "non_precisee";
|
||||
}
|
||||
|
||||
/** AAAAMMJJ → AAAA-MM-JJ ; la source mélange les deux formes. */
|
||||
function dateIso(v: string | undefined): string | null {
|
||||
const s = (v ?? "").trim();
|
||||
if (/^\d{8}$/.test(s)) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** Rafraîchit les deux tables. Idempotent, purge ce qui a disparu de la source. */
|
||||
export async function syncRappels() {
|
||||
const debut = Date.now();
|
||||
const syncedAt = new Date();
|
||||
const database = db();
|
||||
|
||||
const [actions, modeles, risques] = await Promise.all([
|
||||
rdwQueryAll(DATASETS.action, {}),
|
||||
rdwQueryAll(DATASETS.modeles, {}),
|
||||
rdwQueryAll(DATASETS.risques, {}),
|
||||
]);
|
||||
|
||||
// Un rappel peut présenter plusieurs dangers : on les regroupe avant insertion.
|
||||
const dangersParRappel = new Map<string, string[]>();
|
||||
for (const r of risques) {
|
||||
const ref = r.referentiecode_rdw;
|
||||
const libelle = traduireDanger(r.code_mogelijk_gevaar);
|
||||
const liste = dangersParRappel.get(ref) ?? [];
|
||||
if (!liste.includes(libelle)) liste.push(libelle);
|
||||
dangersParRappel.set(ref, liste);
|
||||
}
|
||||
|
||||
const lignes = actions.map((a) => ({
|
||||
reference: a.referentiecode_rdw,
|
||||
datePublication: dateIso(a.publicatiedatum_rdw) ?? dateIso(a.datum_melding_bij_rdw),
|
||||
producteur: a.meldende_producent_distributeur ?? null,
|
||||
categorie: traduireCategorie(a.categorie_defect),
|
||||
gravite: traduireGravite(a.risicobeoordeling_rdw),
|
||||
dangers: dangersParRappel.get(a.referentiecode_rdw) ?? [],
|
||||
defautSource: a.omschrijving_defect ?? null,
|
||||
reparationSource: a.beschrijving_van_het_herstel ?? null,
|
||||
nbVehicules: Number(a.totaal_aantal_voertuigen_terugroepactie) || null,
|
||||
infoUrl: /^https?:/i.test(a.meer_informatie_op_internet ?? "") ? a.meer_informatie_op_internet : null,
|
||||
infoTel: a.meer_informatie_via_telefoonnummer ?? null,
|
||||
syncedAt,
|
||||
})).filter((l) => l.reference);
|
||||
|
||||
const parLotRappels = tailleLot(rappels);
|
||||
for (let i = 0; i < lignes.length; i += parLotRappels) {
|
||||
await database.insert(rappels).values(lignes.slice(i, i + parLotRappels)).onConflictDoUpdate({
|
||||
target: rappels.reference,
|
||||
set: clauseMiseAJour(rappels, ["reference"]),
|
||||
});
|
||||
}
|
||||
|
||||
// Dédoublonnage : la source répète le même couple pour plusieurs variantes d'une campagne.
|
||||
type Lien = typeof rappelsModeles.$inferInsert;
|
||||
const couples = new Map<string, Lien>();
|
||||
for (const m of modeles) {
|
||||
if (!m.referentiecode_rdw || !m.merk || !m.type) continue;
|
||||
const marque = m.merk.trim();
|
||||
// La source préfixe parfois la dénomination par la marque, comme le référentiel véhicules.
|
||||
const modele = retirerMarqueRedondante(m.type.trim(), marque) ?? m.type.trim();
|
||||
couples.set(`${m.referentiecode_rdw}|${marque}|${modele}`, {
|
||||
reference: m.referentiecode_rdw, marque, modele,
|
||||
marqueNorm: normText(marque), modeleNorm: normText(modele), syncedAt,
|
||||
});
|
||||
}
|
||||
const liens = [...couples.values()];
|
||||
const parLotLiens = tailleLot(rappelsModeles);
|
||||
for (let i = 0; i < liens.length; i += parLotLiens) {
|
||||
await database.insert(rappelsModeles).values(liens.slice(i, i + parLotLiens)).onConflictDoUpdate({
|
||||
target: [rappelsModeles.reference, rappelsModeles.marque, rappelsModeles.modele],
|
||||
set: clauseMiseAJour(rappelsModeles, ["reference", "marque", "modele"]),
|
||||
});
|
||||
}
|
||||
|
||||
await database.delete(rappelsModeles).where(sql`synced_at < ${syncedAt}`);
|
||||
await database.delete(rappels).where(sql`synced_at < ${syncedAt}`);
|
||||
|
||||
const resume = {
|
||||
rappels: lignes.length,
|
||||
liensModeles: liens.length,
|
||||
secondes: Math.round((Date.now() - debut) / 1000),
|
||||
};
|
||||
console.log("[rappels] ✓", resume);
|
||||
return resume;
|
||||
}
|
||||
|
||||
export type RappelVehicule = {
|
||||
reference: string;
|
||||
categorie: string;
|
||||
gravite: string;
|
||||
dangers: string[];
|
||||
datePublication: string | null;
|
||||
nbVehicules: number | null;
|
||||
infoUrl: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rappels visant le modèle d'un véhicule du foyer.
|
||||
*
|
||||
* Prend un véhicule déjà résolu (src/knowledge/vehicules-resolution.ts) plutôt que des
|
||||
* libellés bruts : la clé de rapprochement est ainsi la même pour tous les enrichissements,
|
||||
* qu'un foyer ait été identifié au champ K ou par saisie.
|
||||
*
|
||||
* L'année sert de garde-fou : une campagne publiée en 2015 ne peut pas concerner une voiture
|
||||
* mise en circulation en 2020. Ça n'élimine pas tous les faux positifs (faute de millésimes
|
||||
* dans la source) mais ça écarte les plus absurdes.
|
||||
*/
|
||||
export async function rappelsPourVehicule(vehicule: VehiculeResolu): Promise<RappelVehicule[]> {
|
||||
const { marqueNorm, modeleNorm, annee } = vehicule;
|
||||
if (!marqueNorm || !modeleNorm) return [];
|
||||
|
||||
const rows = await db()
|
||||
.select({
|
||||
reference: rappels.reference,
|
||||
categorie: rappels.categorie,
|
||||
gravite: rappels.gravite,
|
||||
dangers: rappels.dangers,
|
||||
datePublication: rappels.datePublication,
|
||||
nbVehicules: rappels.nbVehicules,
|
||||
infoUrl: rappels.infoUrl,
|
||||
})
|
||||
.from(rappelsModeles)
|
||||
.innerJoin(rappels, eq(rappels.reference, rappelsModeles.reference))
|
||||
.where(and(
|
||||
// Comparaison sur les clés normalisées : « Citroën » doit retrouver « CITROEN ».
|
||||
eq(rappelsModeles.marqueNorm, marqueNorm),
|
||||
eq(rappelsModeles.modeleNorm, modeleNorm),
|
||||
annee
|
||||
// Campagne postérieure (ou de la même année) à la mise en circulation, ou date inconnue.
|
||||
? or(sql`left(${rappels.datePublication}, 4)::int >= ${annee}`, sql`${rappels.datePublication} is null`)
|
||||
: undefined,
|
||||
))
|
||||
// Les campagnes graves d'abord, puis les plus récentes.
|
||||
.orderBy(sql`case ${rappels.gravite} when 'grave' then 0 when 'moyenne' then 1 else 2 end`,
|
||||
desc(rappels.datePublication))
|
||||
.limit(20);
|
||||
|
||||
// Les colonnes sont nullables en base ; l'affichage exige un libellé — on retombe sur
|
||||
// « Non précisé » plutôt que de laisser un trou dans la page.
|
||||
return rows.map((r) => ({
|
||||
reference: r.reference,
|
||||
categorie: r.categorie ?? "Non précisé",
|
||||
gravite: r.gravite ?? "non_precisee",
|
||||
dangers: Array.isArray(r.dangers) ? (r.dangers as string[]) : [],
|
||||
datePublication: r.datePublication,
|
||||
nbVehicules: r.nbVehicules,
|
||||
infoUrl: r.infoUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Version allégée pour le teaser gratuit : on révèle l'existence, jamais le contenu. */
|
||||
export type ApercuRappels = { total: number; graves: number; categories: string[] };
|
||||
|
||||
export function apercuRappels(liste: RappelVehicule[]): ApercuRappels {
|
||||
return {
|
||||
total: liste.length,
|
||||
graves: liste.filter((r) => r.gravite === "grave").length,
|
||||
categories: [...new Set(liste.map((r) => r.categorie))].slice(0, 3),
|
||||
};
|
||||
}
|
||||
206
src/knowledge/rdw.test.ts
Normal file
206
src/knowledge/rdw.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
cleLigne, paysReception, normaliserEnergie, entier, rdwQuery,
|
||||
nettoyerModele, retirerMarqueRedondante, determinerEnergie,
|
||||
} from "./rdw";
|
||||
|
||||
/**
|
||||
* Le risque logique du référentiel véhicule s'est déplacé du matching texte vers la
|
||||
* transformation des données RDW : clé de jointure entre 4 jeux de données, normalisation
|
||||
* des énergies, extraction de l'autorité de réception. C'est ce qui est couvert ici.
|
||||
*/
|
||||
|
||||
describe("cleLigne — jointure entre les 4 jeux de données RDW", () => {
|
||||
it("absorbe la coquille de la source : `codevariantgk` (Handelsbenaming) vaut `codevarianttgk`", () => {
|
||||
// Sans cela, la dénomination commerciale ne se rattacherait jamais au reste : tous les
|
||||
// véhicules ressortiraient sans modèle.
|
||||
const basis = { typegoedkeuringsnummer: "e2*98/14*0237*04", codevarianttgk: "A", codeuitvoeringtgk: "F", volgnummerrevisieuitvoering: "0" };
|
||||
const handelsbenaming = { typegoedkeuringsnummer: "e2*98/14*0237*04", codevariantgk: "A", codeuitvoeringtgk: "F", volgnummerrevisieuitvoering: "0" };
|
||||
expect(cleLigne(handelsbenaming)).toBe(cleLigne(basis));
|
||||
});
|
||||
|
||||
it("distingue deux versions d'une même variante", () => {
|
||||
const base = { typegoedkeuringsnummer: "e2*98/14*0237*04", codevarianttgk: "A", volgnummerrevisieuitvoering: "0" };
|
||||
expect(cleLigne({ ...base, codeuitvoeringtgk: "F" }))
|
||||
.not.toBe(cleLigne({ ...base, codeuitvoeringtgk: "R" }));
|
||||
});
|
||||
|
||||
it("distingue deux révisions d'une même version", () => {
|
||||
const base = { typegoedkeuringsnummer: "e2*98/14*0237*04", codevarianttgk: "A", codeuitvoeringtgk: "F" };
|
||||
expect(cleLigne({ ...base, volgnummerrevisieuitvoering: "0" }))
|
||||
.not.toBe(cleLigne({ ...base, volgnummerrevisieuitvoering: "1" }));
|
||||
});
|
||||
|
||||
it("traite une révision absente comme la révision 0 (valeur par défaut de la source)", () => {
|
||||
const base = { typegoedkeuringsnummer: "e2*1*1*1", codevarianttgk: "A", codeuitvoeringtgk: "F" };
|
||||
expect(cleLigne(base)).toBe(cleLigne({ ...base, volgnummerrevisieuitvoering: "0" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("paysReception — autorité ayant délivré la réception (champ K)", () => {
|
||||
it("extrait le code pays d'un numéro de réception français", () => {
|
||||
expect(paysReception("e2*2001/116*0362*00")).toBe("e2");
|
||||
});
|
||||
|
||||
it("gère les codes à deux chiffres sans les tronquer", () => {
|
||||
// e19 (Roumanie, Dacia) ne doit pas être lu comme e1 (Allemagne).
|
||||
expect(paysReception("e19*2007/46*0026*00")).toBe("e19");
|
||||
});
|
||||
|
||||
it("renvoie null sur une réception nationale hors format communautaire", () => {
|
||||
expect(paysReception("RECEPTION ISOLEE")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliserEnergie — un code isolé", () => {
|
||||
it("associe les codes courants", () => {
|
||||
expect(normaliserEnergie("B")).toBe("essence");
|
||||
expect(normaliserEnergie("D")).toBe("diesel");
|
||||
expect(normaliserEnergie("E")).toBe("electrique");
|
||||
});
|
||||
|
||||
it("ne prend PAS H pour un hybride : c'est le gaz naturel comprimé", () => {
|
||||
// Erreur corrigée : H était mappé en « hybride », ce qui étiquetait des véhicules GNC
|
||||
// comme hybrides tout en laissant les vrais hybrides passer pour de l'essence.
|
||||
expect(normaliserEnergie("H")).toBe("autre");
|
||||
});
|
||||
|
||||
it("bascule en « autre » sur gaz, hydrogène ou code inconnu", () => {
|
||||
expect(normaliserEnergie("G")).toBe("autre");
|
||||
expect(normaliserEnergie("W")).toBe("autre");
|
||||
expect(normaliserEnergie("Z")).toBe("autre");
|
||||
expect(normaliserEnergie(undefined)).toBe("autre");
|
||||
});
|
||||
});
|
||||
|
||||
describe("determinerEnergie — l'hybride se lit dans la combinaison de sources", () => {
|
||||
it("détecte un hybride essence : cas réel Toyota Yaris Hybrid (seq 1 « B » + seq 2 « E »)", () => {
|
||||
expect(determinerEnergie(["B", "E"])).toBe("hybride");
|
||||
});
|
||||
|
||||
it("détecte un hybride diesel", () => {
|
||||
expect(determinerEnergie(["D", "E"])).toBe("hybride");
|
||||
});
|
||||
|
||||
it("ne confond pas un 100 % électrique avec un hybride", () => {
|
||||
expect(determinerEnergie(["E"])).toBe("electrique");
|
||||
});
|
||||
|
||||
it("reste sur le thermique quand il n'y a pas d'électricité", () => {
|
||||
expect(determinerEnergie(["B"])).toBe("essence");
|
||||
expect(determinerEnergie(["D"])).toBe("diesel");
|
||||
});
|
||||
|
||||
it("retient le carburant principal d'une bicarburation sans électricité (essence + GPL)", () => {
|
||||
expect(determinerEnergie(["B", "G"])).toBe("essence");
|
||||
});
|
||||
|
||||
it("ignore les doublons et les valeurs vides", () => {
|
||||
expect(determinerEnergie(["B", "B", "E", ""])).toBe("hybride");
|
||||
expect(determinerEnergie([])).toBe("autre");
|
||||
expect(determinerEnergie([undefined])).toBe("autre");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nettoyerModele — dénominations bouchons de la source", () => {
|
||||
it("écarte les suites de tirets, égaux et points relevées en base", () => {
|
||||
// Vus tels quels dans les données RDW : sans filtre, ils remonteraient dans l'autocomplétion.
|
||||
for (const bouchon of ["--------", "----", "=========", "====", "-", "..."]) {
|
||||
expect(nettoyerModele(bouchon)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("écarte les marqueurs d'inconnu explicites, quelle que soit la casse", () => {
|
||||
for (const bouchon of ["N/A", "n/a", "NA", "ONBEKEND", "Unknown"]) {
|
||||
expect(nettoyerModele(bouchon)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("conserve les vraies dénominations, y compris celles à ponctuation ou purement numériques", () => {
|
||||
expect(nettoyerModele("308")).toBe("308");
|
||||
expect(nettoyerModele("C3 AIRCROSS")).toBe("C3 AIRCROSS");
|
||||
expect(nettoyerModele("CLIO (E-TECH HYBRID)")).toBe("CLIO (E-TECH HYBRID)");
|
||||
expect(nettoyerModele("MX-30")).toBe("MX-30");
|
||||
});
|
||||
|
||||
it("normalise l'absence et les espaces en null", () => {
|
||||
expect(nettoyerModele(undefined)).toBeNull();
|
||||
expect(nettoyerModele(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("retirerMarqueRedondante — préfixage incohérent de la source", () => {
|
||||
it("retire la marque quand la source la répète (cas vu en base : « TOYOTA TOYOTA YARIS »)", () => {
|
||||
expect(retirerMarqueRedondante("TOYOTA YARIS", "TOYOTA")).toBe("YARIS");
|
||||
expect(retirerMarqueRedondante("TOYOTA YARIS CROSS", "TOYOTA")).toBe("YARIS CROSS");
|
||||
});
|
||||
|
||||
it("laisse intact un modèle qui commence par un mot différent de la marque", () => {
|
||||
// Une Mercedes réellement nommée « 308 CDI » ne doit pas être amputée.
|
||||
expect(retirerMarqueRedondante("308 CDI", "MERCEDES-BENZ")).toBe("308 CDI");
|
||||
expect(retirerMarqueRedondante("CLIO", "RENAULT")).toBe("CLIO");
|
||||
});
|
||||
|
||||
it("ne vide jamais la dénomination quand elle est égale à la marque", () => {
|
||||
expect(retirerMarqueRedondante("SMART", "SMART")).toBe("SMART");
|
||||
});
|
||||
|
||||
it("tolère casse et accents, et supporte l'absence de valeur", () => {
|
||||
expect(retirerMarqueRedondante("Citroën C3", "CITROEN")).toBe("C3");
|
||||
expect(retirerMarqueRedondante(null, "TOYOTA")).toBeNull();
|
||||
expect(retirerMarqueRedondante("YARIS", null)).toBe("YARIS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rdwQuery — résistance aux refus de quota (429)", () => {
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); });
|
||||
|
||||
const reponse = (body: unknown) =>
|
||||
({ ok: true, json: async () => body }) as Response;
|
||||
// `retry-after` à quelques millisecondes : on teste la mécanique de rejeu, pas l'attente réelle.
|
||||
const refus = (status: number, retryAfter?: string) =>
|
||||
({
|
||||
ok: false, status,
|
||||
text: async () => "throttled",
|
||||
headers: { get: (h: string) => (h === "retry-after" ? retryAfter ?? null : null) },
|
||||
}) as unknown as Response;
|
||||
|
||||
it("rejoue après un 429 et finit par réussir — un refus ne perd pas 2 h de synchronisation", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(refus(429, "0.002"))
|
||||
.mockResolvedValueOnce(reponse([{ a: "1" }]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(rdwQuery("test", {})).resolves.toEqual([{ a: "1" }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("abandonne proprement après épuisement des tentatives, sans boucler indéfiniment", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(refus(429, "0.002"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(rdwQuery("test", {})).rejects.toThrow(/abandon après 5 tentatives/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("ne rejoue pas une erreur définitive (400) : insister ne la corrigerait pas", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(refus(400));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(rdwQuery("test", {})).rejects.toThrow(/400/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entier — la source ne renvoie que des chaînes", () => {
|
||||
it("convertit et arrondit", () => {
|
||||
expect(entier("1450")).toBe(1450);
|
||||
expect(entier("55.00")).toBe(55);
|
||||
});
|
||||
|
||||
it("renvoie null sur une valeur absente ou non numérique, jamais NaN", () => {
|
||||
expect(entier("")).toBeNull();
|
||||
expect(entier(undefined)).toBeNull();
|
||||
expect(entier("n/a")).toBeNull();
|
||||
});
|
||||
});
|
||||
189
src/knowledge/rdw.ts
Normal file
189
src/knowledge/rdw.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Client RDW Open Data (registre néerlandais, domaine public, sans authentification).
|
||||
*
|
||||
* On y puise le référentiel européen des réceptions CE par type : il contient les
|
||||
* homologations de TOUTES les autorités de l'UE (e2 = France, e1 = Allemagne, e19 = Roumanie…),
|
||||
* pas seulement néerlandaises — un Peugeot immatriculé aux Pays-Bas porte sa réception
|
||||
* française. C'est ce qui permet de résoudre une carte grise française sans API payante.
|
||||
*
|
||||
* Pur réseau + transformation : aucun accès DB ici, pour rester testable (§ 20.1).
|
||||
*/
|
||||
|
||||
import { type Energie, normText } from "./vehicules-cle";
|
||||
|
||||
const BASE = "https://opendata.rdw.nl/resource";
|
||||
|
||||
/** Jeux de données RDW utilisés (identifiants Socrata stables). */
|
||||
export const DATASETS = {
|
||||
/** TGK Basis Uitvoering — gabarit, masses, catégorie, portes, places. */
|
||||
basis: "byxc-wwua",
|
||||
/** TGK Handelsbenaming Fabrikant — dénomination commerciale (le « modèle »). */
|
||||
handelsbenaming: "x5v3-sewk",
|
||||
/** TGK Merk Uitvoering — code marque. */
|
||||
merk: "kyri-nuah",
|
||||
/** TGK Energiebron Uitvoering — énergie, puissance, émissions, consommation. */
|
||||
energiebron: "gr7t-qfnb",
|
||||
} as const;
|
||||
|
||||
export type RdwRow = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Plafond de la plateforme Socrata : 1 000 requêtes/heure glissante avec un jeton applicatif,
|
||||
* bien moins sans (quota partagé par IP, refus fréquents). Un passage complet en consomme ~560
|
||||
* réparties sur ~2 h, ce qui tient dans les deux cas — mais un refus ponctuel reste possible,
|
||||
* d'où le rejeu ci-dessous. Le jeton est gratuit (opendata.rdw.nl) et purement optionnel :
|
||||
* renseigner RDW_APP_TOKEN fait basculer le quota de l'IP vers l'application.
|
||||
*/
|
||||
const APP_TOKEN = process.env.RDW_APP_TOKEN;
|
||||
|
||||
/** Refus temporaires : quota atteint (429) ou incident côté plateforme (5xx). */
|
||||
const STATUTS_REJOUABLES = new Set([429, 500, 502, 503, 504]);
|
||||
const TENTATIVES_MAX = 5;
|
||||
|
||||
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Une requête Socrata, avec rejeu à délai croissant. `params` est passé tel quel
|
||||
* (clés $select/$where/$group…).
|
||||
*
|
||||
* Sans ce rejeu, un unique 429 au bout d'une heure de synchronisation ferait tout perdre :
|
||||
* le coût d'un échec est ici très asymétrique.
|
||||
*/
|
||||
export async function rdwQuery(dataset: string, params: Record<string, string>): Promise<RdwRow[]> {
|
||||
const url = `${BASE}/${dataset}.json?${new URLSearchParams(params)}`;
|
||||
const headers: Record<string, string> = APP_TOKEN ? { "X-App-Token": APP_TOKEN } : {};
|
||||
|
||||
let dernierEchec = "";
|
||||
for (let tentative = 1; tentative <= TENTATIVES_MAX; tentative++) {
|
||||
try {
|
||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(120_000) });
|
||||
if (res.ok) return await res.json();
|
||||
|
||||
dernierEchec = `${res.status} ${await res.text().catch(() => "")}`.slice(0, 200);
|
||||
if (!STATUTS_REJOUABLES.has(res.status)) {
|
||||
throw new Error(`RDW ${dataset} a répondu ${dernierEchec}`);
|
||||
}
|
||||
// La plateforme peut indiquer elle-même le délai d'attente : on le respecte s'il est là.
|
||||
const retryAfter = Number(res.headers.get("retry-after"));
|
||||
const attente = Number.isFinite(retryAfter) && retryAfter > 0
|
||||
? retryAfter * 1000
|
||||
: Math.min(2 ** tentative * 1000, 60_000);
|
||||
if (tentative < TENTATIVES_MAX) {
|
||||
console.warn(`[rdw] ${res.status} sur ${dataset} — nouvelle tentative dans ${Math.round(attente / 1000)}s (${tentative}/${TENTATIVES_MAX})`);
|
||||
await pause(attente);
|
||||
}
|
||||
} catch (e) {
|
||||
// Coupure réseau ou dépassement de délai : même traitement qu'un refus temporaire.
|
||||
if (e instanceof Error && e.message.startsWith("RDW ")) throw e;
|
||||
dernierEchec = String(e);
|
||||
if (tentative < TENTATIVES_MAX) await pause(Math.min(2 ** tentative * 1000, 60_000));
|
||||
}
|
||||
}
|
||||
throw new Error(`RDW ${dataset} : abandon après ${TENTATIVES_MAX} tentatives — ${dernierEchec}`);
|
||||
}
|
||||
|
||||
/** Pagination Socrata jusqu'à épuisement (l'API n'a pas de curseur : offset + limit). */
|
||||
export async function rdwQueryAll(
|
||||
dataset: string,
|
||||
params: Record<string, string>,
|
||||
pageSize = 50_000,
|
||||
): Promise<RdwRow[]> {
|
||||
const out: RdwRow[] = [];
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const page = await rdwQuery(dataset, { ...params, $limit: String(pageSize), $offset: String(offset) });
|
||||
out.push(...page);
|
||||
if (page.length < pageSize) return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clé de jointure entre les 4 jeux de données.
|
||||
* Attention : le jeu Handelsbenaming nomme la colonne `codevariantgk` (sans le second « t »)
|
||||
* là où les autres utilisent `codevarianttgk` — coquille présente dans la source, à absorber ici.
|
||||
*/
|
||||
export function cleLigne(row: RdwRow): string {
|
||||
const variante = row.codevarianttgk ?? row.codevariantgk ?? "";
|
||||
return `${row.typegoedkeuringsnummer}|${variante}|${row.codeuitvoeringtgk ?? ""}|${row.volgnummerrevisieuitvoering ?? "0"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autorité ayant délivré la réception, extraite du champ K (« e2*2001/116*0362*00 » → « e2 »).
|
||||
* Utile pour la couverture (proportion de réceptions françaises) et le diagnostic.
|
||||
*/
|
||||
export function paysReception(numero: string): string | null {
|
||||
const m = /^(e\d+)/i.exec(numero.trim());
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes source d'énergie RDW (nomenclature officielle) :
|
||||
* B = benzine (essence) · D = diesel · E = électricité · G = GPL
|
||||
* H = GNC (gaz naturel comprimé) · C = cryogénique (GNL) · W = hydrogène · A = alcool
|
||||
*
|
||||
* Piège vérifié dans les données : **l'hybride n'a pas de code propre**. Il se reconnaît à une
|
||||
* VERSION portant plusieurs sources (Toyota Yaris Hybrid = seq 1 « B » + seq 2 « E »). Un mapping
|
||||
* code par code ne peut donc jamais détecter un hybride — c'est le rôle de determinerEnergie().
|
||||
*
|
||||
* Le vocabulaire de fokan ne distingue que essence/diesel/électrique/hybride : gaz, hydrogène et
|
||||
* alcool tombent en « autre » plutôt que d'être rattachés de force à une famille voisine.
|
||||
*/
|
||||
export function normaliserEnergie(code: string | undefined): Energie {
|
||||
switch ((code ?? "").trim().toUpperCase()) {
|
||||
case "B": return "essence";
|
||||
case "D": return "diesel";
|
||||
case "E": return "electrique";
|
||||
default: return "autre";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Énergie d'une version à partir de TOUTES ses sources déclarées.
|
||||
* Électricité + thermique = hybride (rechargeable ou non).
|
||||
*/
|
||||
export function determinerEnergie(codes: Array<string | undefined>): Energie {
|
||||
const vus = new Set(codes.map((c) => (c ?? "").trim().toUpperCase()).filter(Boolean));
|
||||
if (vus.size === 0) return "autre";
|
||||
|
||||
const thermiques = [...vus].filter((c) => c !== "E");
|
||||
if (vus.has("E")) return thermiques.length > 0 ? "hybride" : "electrique";
|
||||
// Bicarburation sans électricité (essence + GPL/GNC) : on retient le carburant principal,
|
||||
// c'est lui qui porte les échéances d'entretien.
|
||||
return normaliserEnergie(thermiques[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* La source utilise des dénominations bouchons quand le constructeur n'en a pas déclaré :
|
||||
* « -------- », « ==== », « N/A »… (~7 400 lignes relevées). Les laisser passer les ferait
|
||||
* remonter telles quelles dans l'autocomplétion du quiz. On les ramène à null : « inconnu »
|
||||
* est une réponse acceptable (§ 4.3), « ---- » ne l'est pas.
|
||||
*/
|
||||
export function nettoyerModele(valeur: string | undefined): string | null {
|
||||
const v = (valeur ?? "").trim();
|
||||
if (!v) return null;
|
||||
if (!/[A-Za-z0-9]/.test(v)) return null; // uniquement des tirets, égaux, points…
|
||||
if (/^(n\/?a|onbekend|inconnu|unknown|nb)$/i.test(v)) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* La source n'est pas cohérente sur le préfixage : « YARIS » chez certains constructeurs,
|
||||
* « TOYOTA YARIS » chez d'autres. Comme on réaffiche « marque + modèle », le second cas
|
||||
* produisait « TOYOTA TOYOTA YARIS » dans l'autocomplétion du quiz. On retire le préfixe
|
||||
* redondant, sans jamais vider la dénomination (une Mercedes « 308 CDI » reste intacte :
|
||||
* seul un préfixe égal à la marque est retiré).
|
||||
*/
|
||||
export function retirerMarqueRedondante(modele: string | null, marque: string | null): string | null {
|
||||
if (!modele || !marque) return modele;
|
||||
const m = normText(marque);
|
||||
const mo = normText(modele);
|
||||
if (!mo.startsWith(m + " ")) return modele;
|
||||
const reste = modele.slice(marque.length).trim();
|
||||
return reste.length > 0 ? reste : modele;
|
||||
}
|
||||
|
||||
/** Entier ou null — la source renvoie des chaînes, parfois vides. */
|
||||
export function entier(v: string | undefined): number | null {
|
||||
if (v === undefined || v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.round(n) : null;
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ const Condition = z.union([
|
|||
z.number(),
|
||||
z.boolean(),
|
||||
z.object({ in: z.array(z.union([z.string(), z.number()])) }).strict(),
|
||||
/** Exclusion : absent = passe (une valeur dérivée non confirmée n'exclut jamais une fiche). */
|
||||
z.object({ not_in: z.array(z.union([z.string(), z.number()])) }).strict(),
|
||||
z.object({ contains: z.union([z.string(), z.number()]) }).strict(),
|
||||
z.object({ contains_any: z.array(z.union([z.string(), z.number()])) }).strict(),
|
||||
z.object({ exists: z.boolean() }).strict(),
|
||||
|
|
@ -44,7 +46,7 @@ export const DeadlineTemplate = z.object({
|
|||
applicabilite: Applicabilite,
|
||||
recurrence: Recurrence,
|
||||
/** Soupape des stratégies nommées pour les règles complexes (§ 20.1). */
|
||||
strategy: z.enum(["ct_vehicule", "passeport_enfant", "courroie_distribution"]).optional(),
|
||||
strategy: z.enum(["ct_vehicule", "passeport_enfant", "courroie_distribution", "revision_constructeur", "dpe_validite"]).optional(),
|
||||
enjeu: z.object({
|
||||
type: z.enum(["legal", "legal+assurance", "securite", "argent", "confort", "administratif"]),
|
||||
resume: z.string().max(200),
|
||||
|
|
|
|||
418
src/knowledge/sync-vehicules.ts
Normal file
418
src/knowledge/sync-vehicules.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/**
|
||||
* Synchronisation du référentiel véhicules depuis RDW Open Data (domaine public).
|
||||
* Remplace l'ancien catalogue ADEME : celui-ci n'offrait qu'un instantané du marché neuf
|
||||
* (trou 2016-2025, aucune couverture occasion, aucun identifiant carte grise).
|
||||
*
|
||||
* Alimente deux tables (§ src/db/schema.ts) :
|
||||
* - vehicules_reference : une ligne par (réception × variante × version) = champs K et D.2
|
||||
* - vehicules_modeles : catalogue marque/modèle agrégé, pour l'autocomplétion du quiz
|
||||
*
|
||||
* Appelé par le cron hebdomadaire (src/instrumentation.ts) et par la CLI
|
||||
* `npm run vehicules:sync` (scripts/sync-vehicules-rdw.ts).
|
||||
* Idempotent : rejouable sans effet de bord, les lignes disparues de la source sont purgées.
|
||||
*/
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { db, vehiculesReference, vehiculesModeles } from "@/db";
|
||||
import { clauseMiseAJour, tailleLot } from "@/db/upsert";
|
||||
import {
|
||||
DATASETS, type RdwRow, rdwQueryAll, cleLigne,
|
||||
paysReception, determinerEnergie, entier, nettoyerModele, retirerMarqueRedondante,
|
||||
} from "./rdw";
|
||||
import { normText } from "./vehicules-cle";
|
||||
|
||||
/** Nb de réceptions interrogées par requête — borne la taille des URL et la mémoire. */
|
||||
const TAILLE_LOT = 300;
|
||||
|
||||
/**
|
||||
* Champs de la source qui ont désormais une colonne dédiée : ils ne doivent plus être recopiés
|
||||
* dans `brut`, sous peine de payer deux fois le stockage (et, pour le JSONB, le nom de la clé
|
||||
* dans chacune des 2,9 M de lignes). Tout champ absent de cette liste atterrit dans `brut` —
|
||||
* un nouveau champ publié par RDW est donc conservé automatiquement, jamais perdu.
|
||||
*/
|
||||
const CHAMPS_TYPES = new Set([
|
||||
// clés de jointure
|
||||
"typegoedkeuringsnummer", "codevarianttgk", "codevariantgk", "codeuitvoeringtgk",
|
||||
"volgnummerrevisieuitvoering", "begindatumrevisieuitvoering", "voertuigcategorie",
|
||||
"handelsbenamingfabrikant", "typeaanduidingfabrikant", "merkcoderdw",
|
||||
// identification & carrosserie
|
||||
"codecarrosserie", "aantalwielen", "codelinksrechtsrijdend",
|
||||
"aantaldeurenondergrens", "aantaldeurenbovengrens",
|
||||
"aantalzitplaatsenondergrens", "aantalzitplaatsenbovengrens",
|
||||
// motorisation
|
||||
"codeenergiebron", "volgnummeraandrijving", "volgnummerenergiebron",
|
||||
"maximumnettovermogenogr", "maximumnettovermogenbgr",
|
||||
"toerentalmaxnettovermogenogr", "toerentalmaxnettovermogenbgr",
|
||||
"cilinderinhoudondergrens", "cilinderinhoudbovengrens", "maxbiopercentagebrandstof",
|
||||
"maximumvermogen30minogr", "maximumvermogen30minbgr",
|
||||
"actieradiusvolledigelekwltpbgr", "actieradiusexternoplaadwltpbgr", "elektrischeactieradiusnedc",
|
||||
"verbruikvolledigelekwltpbgr", "elekverbrexternoplaadbwltpbgr",
|
||||
// normes, conso, CO2
|
||||
"uitlaatemissieniveau", "regelgevingemissievoertuigen",
|
||||
"verbruikgecombineerdnedclaag", "verbruikgecombineerdnedchoog",
|
||||
"co2emisgecombineerdnedclaag", "co2emisgecombineerdnedchoog",
|
||||
"co2emissiestadnedclaag", "co2emissiestadnedchoog",
|
||||
"co2emissiebuitennedclaag", "co2emissiebuitennedchoog",
|
||||
"brandstofverbruikgecombwltpogr", "brandstofverbruikgecombwltpbgr",
|
||||
"co2emisgecombineerdwltpogr", "co2emisgecombineerdwltpbgr",
|
||||
// émissions polluantes
|
||||
"coemissietype1", "hcemissietype1", "noxemissietype1", "hcnoxemissietype1",
|
||||
"emissiedeeltjestype1", "emissieaantaldeeltjestype1",
|
||||
"coemissietype1wltp", "noxemissietype1wltp",
|
||||
"emissiedeeltjestype1wltp", "emissieaantaldeeltjestype1wltp",
|
||||
// bruit
|
||||
"geluidsniveauondergrens", "geluidsniveaubovengrens", "geluidsniveaurijdend",
|
||||
"geluidsniveautoerentalogr", "geluidsniveautoerentalbgr",
|
||||
// gabarit & masses
|
||||
"lengteondergrens", "lengtebovengrens", "breedteondergrens", "breedtebovengrens",
|
||||
"hoogteondergrens", "hoogtebovengrens", "wielbasisondergrens", "wielbasisbovengrens",
|
||||
"massarijklaarondergrens", "massarijklaarbovengrens",
|
||||
"maximummassaondergrens", "maximummassabovengrens",
|
||||
"maxconstructiesnelheidahwogr", "maxconstructiesnelheidahwbgr",
|
||||
"maximumconstructiesnelheidogr", "maximumconstructiesnelheidbgr",
|
||||
// résistance au roulement
|
||||
"paramrijweerstandf0ondergrens", "paramrijweerstandf0bovengrens",
|
||||
"paramrijweerstandf1ondergrens", "paramrijweerstandf1bovengrens",
|
||||
"paramrijweerstandf2ondergrens", "paramrijweerstandf2bovengrens",
|
||||
]);
|
||||
|
||||
/** Colonnes composant la clé d'unicité — jamais réécrites lors d'un conflit. */
|
||||
const CLE_UNICITE = ["receptionNumero", "variante", "version", "revision"];
|
||||
/** Clé du catalogue agrégé. */
|
||||
const CLE_CATALOGUE = ["marque", "modele"];
|
||||
|
||||
/** Liste des réceptions de la catégorie visée (M1 = voitures particulières). */
|
||||
async function listerReceptions(categorie: string): Promise<string[]> {
|
||||
const rows = await rdwQueryAll(DATASETS.basis, {
|
||||
$select: "typegoedkeuringsnummer",
|
||||
$group: "typegoedkeuringsnummer",
|
||||
$where: `voertuigcategorie='${categorie}'`,
|
||||
});
|
||||
return rows.map((r) => r.typegoedkeuringsnummer).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correspondance code marque RDW → libellé lisible (« PEUP » → « PEUGEOT »).
|
||||
* La table TGK ne stocke qu'un code ; le registre des véhicules immatriculés porte le
|
||||
* libellé en clair. On croise les deux via le numéro de réception.
|
||||
*/
|
||||
async function chargerLibellesMarques(): Promise<Map<string, string>> {
|
||||
const [parReception, parCode] = await Promise.all([
|
||||
rdwQueryAll("m9d7-ebf2", {
|
||||
$select: "typegoedkeuringsnummer,merk",
|
||||
$group: "typegoedkeuringsnummer,merk",
|
||||
$where: "typegoedkeuringsnummer IS NOT NULL",
|
||||
}),
|
||||
rdwQueryAll(DATASETS.merk, {
|
||||
$select: "typegoedkeuringsnummer,merkcoderdw",
|
||||
$group: "typegoedkeuringsnummer,merkcoderdw",
|
||||
}),
|
||||
]);
|
||||
|
||||
const receptionVersLibelle = new Map(parReception.map((r) => [r.typegoedkeuringsnummer, r.merk]));
|
||||
// Un code peut apparaître sous plusieurs libellés (casse, filiales) : on retient le plus fréquent.
|
||||
const votes = new Map<string, Map<string, number>>();
|
||||
for (const { typegoedkeuringsnummer, merkcoderdw } of parCode) {
|
||||
const libelle = receptionVersLibelle.get(typegoedkeuringsnummer);
|
||||
if (!libelle || !merkcoderdw) continue;
|
||||
const m = votes.get(merkcoderdw) ?? new Map<string, number>();
|
||||
m.set(libelle, (m.get(libelle) ?? 0) + 1);
|
||||
votes.set(merkcoderdw, m);
|
||||
}
|
||||
|
||||
const out = new Map<string, string>();
|
||||
for (const [code, m] of votes) {
|
||||
out.set(code, [...m.entries()].sort((a, b) => b[1] - a[1])[0][0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requête un jeu de données pour un lot de réceptions.
|
||||
* Paginé : un lot de 300 réceptions dépasse déjà 30 000 lignes, une réception très déclinée
|
||||
* suffirait à franchir le plafond Socrata — qui tronque silencieusement, sans erreur.
|
||||
*/
|
||||
function parLot(dataset: string, receptions: string[]): Promise<RdwRow[]> {
|
||||
const liste = receptions.map((r) => `'${r.replace(/'/g, "''")}'`).join(",");
|
||||
return rdwQueryAll(dataset, { $where: `typegoedkeuringsnummer in(${liste})` });
|
||||
}
|
||||
|
||||
type Ligne = typeof vehiculesReference.$inferInsert;
|
||||
|
||||
/** Assemble les 4 jeux de données en lignes prêtes à insérer. */
|
||||
function assembler(
|
||||
basis: RdwRow[],
|
||||
handelsbenaming: RdwRow[],
|
||||
merk: RdwRow[],
|
||||
energiebron: RdwRow[],
|
||||
libellesMarques: Map<string, string>,
|
||||
syncedAt: Date,
|
||||
): Ligne[] {
|
||||
const parCle = <T extends RdwRow>(rows: T[]) => {
|
||||
const m = new Map<string, T>();
|
||||
for (const r of rows) if (!m.has(cleLigne(r))) m.set(cleLigne(r), r);
|
||||
return m;
|
||||
};
|
||||
/** Idem, mais en conservant TOUTES les lignes : une version hybride en a plusieurs. */
|
||||
const toutesParCle = <T extends RdwRow>(rows: T[]) => {
|
||||
const m = new Map<string, T[]>();
|
||||
for (const r of rows) {
|
||||
const c = cleLigne(r);
|
||||
(m.get(c) ?? m.set(c, []).get(c)!).push(r);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const nom = parCle(handelsbenaming);
|
||||
const marque = parCle(merk);
|
||||
const energies = toutesParCle(energiebron);
|
||||
|
||||
return basis.map((b): Ligne => {
|
||||
const cle = cleLigne(b);
|
||||
const n = nom.get(cle);
|
||||
const mq = marque.get(cle);
|
||||
|
||||
// Sources d'énergie de la version, triées par numéro de séquence : la 1re est la source
|
||||
// principale (le thermique sur un hybride), celle qui porte émissions, conso et puissance.
|
||||
const sources = (energies.get(cle) ?? [])
|
||||
.sort((a, z) => Number(a.volgnummerenergiebron ?? 0) - Number(z.volgnummerenergiebron ?? 0));
|
||||
const en = sources.find((s) => (s.codeenergiebron ?? "").toUpperCase() !== "E") ?? sources[0];
|
||||
const elec = sources.find((s) => (s.codeenergiebron ?? "").toUpperCase() === "E");
|
||||
|
||||
const marqueCode = mq?.merkcoderdw ?? null;
|
||||
const marqueLibelle = marqueCode ? libellesMarques.get(marqueCode) ?? null : null;
|
||||
|
||||
// Ne reste en JSONB que ce qui n'a PAS de colonne dédiée : la longue traîne rare
|
||||
// (cycles d'essai poids lourds, places debout, valeurs pondérées). Tout le reste est typé —
|
||||
// mesuré, cela retire 99,2 % du poids de la colonne sans perdre une seule donnée.
|
||||
const brut: Record<string, string> = {};
|
||||
for (const src of [b, ...sources]) {
|
||||
if (!src) continue;
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
if (!CHAMPS_TYPES.has(k) && v !== "") brut[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
receptionNumero: b.typegoedkeuringsnummer,
|
||||
variante: b.codevarianttgk ?? "",
|
||||
version: b.codeuitvoeringtgk ?? "",
|
||||
revision: entier(b.volgnummerrevisieuitvoering) ?? 0,
|
||||
receptionPays: paysReception(b.typegoedkeuringsnummer),
|
||||
dateReception: b.begindatumrevisieuitvoering ?? null,
|
||||
|
||||
marqueCode,
|
||||
marque: marqueLibelle,
|
||||
modele: retirerMarqueRedondante(nettoyerModele(n?.handelsbenamingfabrikant), marqueLibelle),
|
||||
typeFabricant: n?.typeaanduidingfabrikant ?? null,
|
||||
|
||||
categorie: b.voertuigcategorie ?? null,
|
||||
carrosserie: b.codecarrosserie ?? null,
|
||||
nbRoues: entier(b.aantalwielen),
|
||||
conduite: b.codelinksrechtsrijdend ?? null,
|
||||
nbPortesMin: entier(b.aantaldeurenondergrens),
|
||||
nbPortesMax: entier(b.aantaldeurenbovengrens),
|
||||
nbPlacesMin: entier(b.aantalzitplaatsenondergrens),
|
||||
nbPlacesMax: entier(b.aantalzitplaatsenbovengrens),
|
||||
|
||||
energieCode: en?.codeenergiebron ?? null,
|
||||
energiesCodes: sources.length ? sources.map((s) => s.codeenergiebron).filter(Boolean) : null,
|
||||
energie: sources.length ? determinerEnergie(sources.map((s) => s.codeenergiebron)) : null,
|
||||
seqMotorisation: entier(en?.volgnummeraandrijving),
|
||||
seqEnergie: entier(en?.volgnummerenergiebron),
|
||||
puissanceKwMin: en?.maximumnettovermogenogr ?? null,
|
||||
puissanceKwMax: en?.maximumnettovermogenbgr ?? null,
|
||||
regimePuissanceMin: entier(en?.toerentalmaxnettovermogenogr),
|
||||
regimePuissanceMax: entier(en?.toerentalmaxnettovermogenbgr),
|
||||
cylindree: entier(en?.cilinderinhoudbovengrens ?? en?.cilinderinhoudondergrens),
|
||||
maxBiocarburant: en?.maxbiopercentagebrandstof ?? null,
|
||||
// Puissance électrique = régime 30 min (convention RDW pour les moteurs électriques).
|
||||
puissanceElecMin: elec?.maximumvermogen30minogr ?? null,
|
||||
puissanceElecMax: elec?.maximumvermogen30minbgr ?? null,
|
||||
autonomieElecKm: entier(
|
||||
elec?.actieradiusvolledigelekwltpbgr // 100 % électrique
|
||||
?? elec?.actieradiusexternoplaadwltpbgr // hybride rechargeable
|
||||
?? elec?.elektrischeactieradiusnedc, // ancien cycle NEDC
|
||||
),
|
||||
consoElec: elec?.verbruikvolledigelekwltpbgr ?? elec?.elekverbrexternoplaadbwltpbgr ?? null,
|
||||
|
||||
normeEmission: en?.uitlaatemissieniveau ?? null,
|
||||
reglementEmission: en?.regelgevingemissievoertuigen ?? null,
|
||||
|
||||
consoNedcMin: en?.verbruikgecombineerdnedclaag ?? null,
|
||||
consoNedcMax: en?.verbruikgecombineerdnedchoog ?? null,
|
||||
co2NedcMin: en?.co2emisgecombineerdnedclaag ?? null,
|
||||
co2NedcMax: en?.co2emisgecombineerdnedchoog ?? null,
|
||||
co2UrbainMin: en?.co2emissiestadnedclaag ?? null,
|
||||
co2UrbainMax: en?.co2emissiestadnedchoog ?? null,
|
||||
co2RouteMin: en?.co2emissiebuitennedclaag ?? null,
|
||||
co2RouteMax: en?.co2emissiebuitennedchoog ?? null,
|
||||
|
||||
consoWltpMin: en?.brandstofverbruikgecombwltpogr ?? null,
|
||||
consoWltpMax: en?.brandstofverbruikgecombwltpbgr ?? null,
|
||||
co2WltpMin: en?.co2emisgecombineerdwltpogr ?? null,
|
||||
co2WltpMax: en?.co2emisgecombineerdwltpbgr ?? null,
|
||||
|
||||
emCo: en?.coemissietype1 ?? null,
|
||||
emHc: en?.hcemissietype1 ?? null,
|
||||
emNox: en?.noxemissietype1 ?? null,
|
||||
emHcNox: en?.hcnoxemissietype1 ?? null,
|
||||
emParticules: en?.emissiedeeltjestype1 ?? null,
|
||||
emNbParticules: en?.emissieaantaldeeltjestype1 ?? null,
|
||||
emCoWltp: en?.coemissietype1wltp ?? null,
|
||||
emNoxWltp: en?.noxemissietype1wltp ?? null,
|
||||
emParticulesWltp: en?.emissiedeeltjestype1wltp ?? null,
|
||||
emNbParticulesWltp: en?.emissieaantaldeeltjestype1wltp ?? null,
|
||||
|
||||
niveauSonoreMin: en?.geluidsniveauondergrens ?? null,
|
||||
niveauSonoreMax: en?.geluidsniveaubovengrens ?? null,
|
||||
niveauSonoreRoulant: en?.geluidsniveaurijdend ?? null,
|
||||
regimeSonoreMin: entier(en?.geluidsniveautoerentalogr),
|
||||
regimeSonoreMax: entier(en?.geluidsniveautoerentalbgr),
|
||||
|
||||
longueurMin: entier(b.lengteondergrens),
|
||||
longueurMax: entier(b.lengtebovengrens),
|
||||
largeurMin: entier(b.breedteondergrens),
|
||||
largeurMax: entier(b.breedtebovengrens),
|
||||
hauteurMin: entier(b.hoogteondergrens),
|
||||
hauteurMax: entier(b.hoogtebovengrens),
|
||||
empattementMin: entier(b.wielbasisondergrens),
|
||||
empattementMax: entier(b.wielbasisbovengrens),
|
||||
masseOrdreMarcheMin: entier(b.massarijklaarondergrens),
|
||||
masseOrdreMarcheMax: entier(b.massarijklaarbovengrens),
|
||||
masseMaxMin: entier(b.maximummassaondergrens),
|
||||
masseMaxMax: entier(b.maximummassabovengrens),
|
||||
vitesseMaxMin: b.maxconstructiesnelheidahwogr ?? null,
|
||||
vitesseMaxMax: b.maxconstructiesnelheidahwbgr ?? null,
|
||||
vitesseConstrMin: en?.maximumconstructiesnelheidogr ?? null,
|
||||
vitesseConstrMax: en?.maximumconstructiesnelheidbgr ?? null,
|
||||
|
||||
resistanceF0Min: b.paramrijweerstandf0ondergrens ?? null,
|
||||
resistanceF0Max: b.paramrijweerstandf0bovengrens ?? null,
|
||||
resistanceF1Min: b.paramrijweerstandf1ondergrens ?? null,
|
||||
resistanceF1Max: b.paramrijweerstandf1bovengrens ?? null,
|
||||
resistanceF2Min: b.paramrijweerstandf2ondergrens ?? null,
|
||||
resistanceF2Max: b.paramrijweerstandf2bovengrens ?? null,
|
||||
|
||||
brut: Object.keys(brut).length ? brut : null,
|
||||
syncedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit le catalogue agrégé servant l'autocomplétion et la résolution, depuis le référentiel.
|
||||
*
|
||||
* L'agrégation reste en SQL (elle balaie 2,9 M de lignes) mais les clés normalisées sont
|
||||
* calculées en JS, par le `normText` de vehicules-cle.ts. C'est délibéré : une transposition
|
||||
* de cette normalisation en SQL en ferait une seconde implémentation, qui dériverait un jour
|
||||
* de la première — et une divergence ici ne casse rien, elle rend juste des véhicules
|
||||
* introuvables en silence. Le retour ne fait que ~11 000 lignes, le coût est nul.
|
||||
*/
|
||||
export async function reconstruireCatalogue(syncedAt: Date = new Date()) {
|
||||
const database = db();
|
||||
const agrege = await database.execute<{
|
||||
marque: string; modele: string; energies: unknown;
|
||||
configurations: number; annee_min: number | null; annee_max: number | null;
|
||||
}>(sql`
|
||||
SELECT
|
||||
marque,
|
||||
modele,
|
||||
COALESCE(jsonb_agg(DISTINCT energie) FILTER (WHERE energie IS NOT NULL), '[]'::jsonb) AS energies,
|
||||
COUNT(*)::int AS configurations,
|
||||
MIN(NULLIF(LEFT(date_reception, 4), ''))::int AS annee_min,
|
||||
MAX(NULLIF(LEFT(date_reception, 4), ''))::int AS annee_max
|
||||
FROM vehicules_reference
|
||||
-- Ceinture et bretelles : nettoyerModele() écarte déjà les bouchons à l'ingestion, mais le
|
||||
-- catalogue est ce que voit l'utilisateur — on ne laisse pas passer un « ---- » resté d'un
|
||||
-- passage antérieur.
|
||||
WHERE marque IS NOT NULL
|
||||
AND modele IS NOT NULL AND modele <> ''
|
||||
AND modele ~ '[A-Za-z0-9]'
|
||||
AND modele !~* '^(n/?a|onbekend|inconnu|unknown|nb)$'
|
||||
GROUP BY marque, modele
|
||||
`);
|
||||
|
||||
const lignes = (agrege.rows ?? agrege as unknown as typeof agrege.rows).map((r) => ({
|
||||
marque: r.marque,
|
||||
modele: r.modele,
|
||||
marqueNorm: normText(r.marque),
|
||||
modeleNorm: normText(r.modele),
|
||||
energies: r.energies,
|
||||
configurations: r.configurations,
|
||||
anneeMin: r.annee_min,
|
||||
anneeMax: r.annee_max,
|
||||
syncedAt,
|
||||
}));
|
||||
|
||||
const parLotCatalogue = tailleLot(vehiculesModeles);
|
||||
for (let i = 0; i < lignes.length; i += parLotCatalogue) {
|
||||
await database.insert(vehiculesModeles).values(lignes.slice(i, i + parLotCatalogue)).onConflictDoUpdate({
|
||||
target: [vehiculesModeles.marque, vehiculesModeles.modele],
|
||||
set: clauseMiseAJour(vehiculesModeles, CLE_CATALOGUE),
|
||||
});
|
||||
}
|
||||
|
||||
await database.delete(vehiculesModeles).where(lt(vehiculesModeles.syncedAt, syncedAt));
|
||||
}
|
||||
|
||||
export async function syncVehiculesRdw(options: { limite?: number; categorie?: string } = {}) {
|
||||
const categorie = options.categorie ?? "M1";
|
||||
const debut = Date.now();
|
||||
const syncedAt = new Date();
|
||||
const database = db();
|
||||
|
||||
console.log(`[rdw] libellés de marques…`);
|
||||
const libellesMarques = await chargerLibellesMarques();
|
||||
console.log(`[rdw] ${libellesMarques.size} marques résolues`);
|
||||
|
||||
let receptions = await listerReceptions(categorie);
|
||||
if (options.limite) receptions = receptions.slice(0, options.limite);
|
||||
console.log(`[rdw] ${receptions.length} réceptions ${categorie} à synchroniser`);
|
||||
|
||||
let lignes = 0;
|
||||
for (let i = 0; i < receptions.length; i += TAILLE_LOT) {
|
||||
const lot = receptions.slice(i, i + TAILLE_LOT);
|
||||
const [basis, handelsbenaming, merk, energiebron] = await Promise.all([
|
||||
parLot(DATASETS.basis, lot),
|
||||
parLot(DATASETS.handelsbenaming, lot),
|
||||
parLot(DATASETS.merk, lot),
|
||||
parLot(DATASETS.energiebron, lot),
|
||||
]);
|
||||
|
||||
const aInserer = assembler(basis, handelsbenaming, merk, energiebron, libellesMarques, syncedAt);
|
||||
const parInsert = tailleLot(vehiculesReference);
|
||||
for (let j = 0; j < aInserer.length; j += parInsert) {
|
||||
const paquet = aInserer.slice(j, j + parInsert);
|
||||
if (!paquet.length) continue;
|
||||
await database.insert(vehiculesReference).values(paquet).onConflictDoUpdate({
|
||||
target: [
|
||||
vehiculesReference.receptionNumero, vehiculesReference.variante,
|
||||
vehiculesReference.version, vehiculesReference.revision,
|
||||
],
|
||||
set: clauseMiseAJour(vehiculesReference, CLE_UNICITE),
|
||||
});
|
||||
lignes += paquet.length;
|
||||
}
|
||||
|
||||
const fait = Math.min(i + TAILLE_LOT, receptions.length);
|
||||
if (fait % (TAILLE_LOT * 10) === 0 || fait === receptions.length) {
|
||||
console.log(`[rdw] ${fait}/${receptions.length} réceptions — ${lignes} configurations`);
|
||||
}
|
||||
}
|
||||
|
||||
// Purge des configurations retirées de la source (réception abrogée, révision remplacée).
|
||||
const purge = await database.delete(vehiculesReference).where(lt(vehiculesReference.syncedAt, syncedAt));
|
||||
await reconstruireCatalogue(syncedAt);
|
||||
|
||||
const [{ modeles }] = await database
|
||||
.select({ modeles: sql<number>`count(*)::int` }).from(vehiculesModeles);
|
||||
|
||||
const resume = {
|
||||
receptions: receptions.length,
|
||||
configurations: lignes,
|
||||
modeles,
|
||||
purgees: purge.rowCount ?? 0,
|
||||
secondes: Math.round((Date.now() - debut) / 1000),
|
||||
};
|
||||
console.log(`[rdw] ✓`, resume);
|
||||
return resume;
|
||||
}
|
||||
108
src/knowledge/vehicules-cle.test.ts
Normal file
108
src/knowledge/vehicules-cle.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { cleVehicule, normText, reparerLibelle } from "./vehicules-cle";
|
||||
import { motsDeRecherche } from "./vehicules-recherche";
|
||||
|
||||
/**
|
||||
* RDW publie certains libellés avec le caractère accentué perdu (« A5 COUP? », « A5 COUP<EFBFBD> »).
|
||||
* Vérifié côté source : ce n'est pas notre ingestion. Au-delà de l'affichage, le défaut éclatait
|
||||
* un même modèle en plusieurs entrées du catalogue — d'où une réparation appliquée AVANT le
|
||||
* calcul de la clé, pour les faire converger.
|
||||
*/
|
||||
|
||||
describe("reparerLibelle — ne répare que le vérifiable", () => {
|
||||
it("restitue COUPÉ, que la source ait perdu l'accent en « ? » ou en U+FFFD", () => {
|
||||
expect(reparerLibelle("A5 COUP?")).toBe("A5 COUPÉ");
|
||||
expect(reparerLibelle("A5 COUP<55>")).toBe("A5 COUPÉ");
|
||||
expect(reparerLibelle("BMW ALPINA B4 GRAN COUP<55>")).toBe("BMW ALPINA B4 GRAN COUPÉ");
|
||||
});
|
||||
|
||||
it("restitue les noms propres attestés", () => {
|
||||
expect(reparerLibelle("CITRO<52>N BERLINGO API")).toBe("CITROËN BERLINGO API");
|
||||
expect(reparerLibelle("P?SSL SUMMIT 640")).toBe("PÖSSL SUMMIT 640");
|
||||
expect(reparerLibelle("N?4")).toBe("N°4");
|
||||
});
|
||||
|
||||
it("laisse intact un libellé sain", () => {
|
||||
expect(reparerLibelle("A5 COUPE")).toBe("A5 COUPE");
|
||||
expect(reparerLibelle("C5 AIRCROSS")).toBe("C5 AIRCROSS");
|
||||
expect(reparerLibelle("308")).toBe("308");
|
||||
});
|
||||
|
||||
it("n'invente pas un caractère qu'on ne sait pas reconstituer", () => {
|
||||
// « ?MISSIS », « 600 DB <20>CHARMING<4E> » : on ignore quel accent a été perdu. Deviner
|
||||
// produirait un nom faux, affiché avec le même aplomb qu'un vrai.
|
||||
expect(reparerLibelle("?MISSIS")).toBe("?MISSIS");
|
||||
expect(reparerLibelle("ECOVIP 412 <20> F EDITION")).not.toContain("É");
|
||||
});
|
||||
|
||||
it("retire le caractère de remplacement résiduel plutôt que de l'afficher au foyer", () => {
|
||||
expect(reparerLibelle("ECOVIP 412 <20> F EDITION")).toBe("ECOVIP 412 F EDITION");
|
||||
expect(reparerLibelle("600 DB <20>CHARMING<4E>")).toBe("600 DB CHARMING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleVehicule — la réparation fait converger les entrées éclatées", () => {
|
||||
it("donne la MÊME clé au libellé abîmé et au libellé sain", () => {
|
||||
// C'est tout l'enjeu : « A5 COUPE » (5 340 configurations) et « A5 COUP<55> » (463)
|
||||
// sont la même voiture. Sans convergence, l'une des deux ne voit aucun rappel.
|
||||
expect(cleVehicule("AUDI", "A5 COUP<55>")).toEqual(cleVehicule("AUDI", "A5 COUPE"));
|
||||
expect(cleVehicule("AUDI", "A5 COUP?")).toEqual(cleVehicule("AUDI", "A5 COUPÉ"));
|
||||
});
|
||||
|
||||
it("continue de distinguer deux modèles réellement différents", () => {
|
||||
expect(cleVehicule("AUDI", "A5 COUPE")).not.toEqual(cleVehicule("AUDI", "A6 COUPE"));
|
||||
expect(cleVehicule("CITROEN", "C5")).not.toEqual(cleVehicule("CITROEN", "C5 AIRCROSS"));
|
||||
});
|
||||
|
||||
it("reste insensible aux accents et à la casse du foyer", () => {
|
||||
expect(cleVehicule("Citroën", "c5 aircross")).toEqual(cleVehicule("CITROEN", "C5 AIRCROSS"));
|
||||
});
|
||||
|
||||
it("refuse une clé incomplète plutôt que d'en deviner la moitié", () => {
|
||||
expect(cleVehicule("AUDI", "")).toBeNull();
|
||||
expect(cleVehicule(undefined, "A5")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("motsDeRecherche — la marque et le modèle sont dans deux colonnes", () => {
|
||||
it("découpe une saisie qui enjambe marque et modèle", () => {
|
||||
// « audi a5 » ne renvoyait rien : la saisie entière était comparée à une seule colonne.
|
||||
expect(motsDeRecherche("audi a5")).toEqual(["AUDI", "A5"]);
|
||||
expect(motsDeRecherche(" citroën c5 aircross ")).toEqual(["CITROEN", "C5", "AIRCROSS"]);
|
||||
});
|
||||
|
||||
it("neutralise les jokers de LIKE, qui élargiraient la recherche en silence", () => {
|
||||
expect(motsDeRecherche("a5%")).toEqual(["A5"]);
|
||||
expect(motsDeRecherche("c_3")).toEqual(["C", "3"]);
|
||||
});
|
||||
|
||||
it("borne le nombre de mots pour qu'une saisie aberrante ne fabrique pas une requête à rallonge", () => {
|
||||
expect(motsDeRecherche("a b c d e f g")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("rend une liste vide sur une saisie sans contenu", () => {
|
||||
expect(motsDeRecherche(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("écarte l'énergie, qui n'est pas dans le nom du modèle mais se choisit en pastilles", () => {
|
||||
// Chaque mot devant figurer dans le libellé, « hybride » suffisait à tout faire disparaître.
|
||||
expect(motsDeRecherche("toyota yaris hybride")).toEqual(["TOYOTA", "YARIS"]);
|
||||
expect(motsDeRecherche("308 diesel")).toEqual(["308"]);
|
||||
});
|
||||
|
||||
it("garde « HYBRID » anglais, qui figure dans de vrais libellés", () => {
|
||||
// « RENAULT CLIO (E-TECH HYBRID) » existe au catalogue.
|
||||
expect(motsDeRecherche("clio hybrid")).toEqual(["CLIO", "HYBRID"]);
|
||||
});
|
||||
|
||||
it("ne vide pas la recherche quand l'énergie est le seul mot saisi", () => {
|
||||
expect(motsDeRecherche("hybride")).toEqual(["HYBRIDE"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normText", () => {
|
||||
it("efface accents, casse et espaces superflus", () => {
|
||||
expect(normText(" Citroën C3 ")).toBe("CITROEN C3");
|
||||
expect(normText("A5 COUPÉ")).toBe("A5 COUPE");
|
||||
});
|
||||
});
|
||||
85
src/knowledge/vehicules-cle.ts
Normal file
85
src/knowledge/vehicules-cle.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Vocabulaire et clé de rapprochement du domaine véhicules — le seul endroit où l'on décide
|
||||
* ce que « même modèle » veut dire.
|
||||
*
|
||||
* Il y avait trois réponses différentes à cette question dans le code (ILIKE pour
|
||||
* l'autocomplétion, égalité stricte pour les rappels, `includes()` bidirectionnel pour
|
||||
* l'entretien constructeur), donc trois comportements pour un même couple marque/modèle.
|
||||
* Le `includes()` faisait matcher « C5 AIRCROSS » sur une entrée « C5 » — une préconisation
|
||||
* d'entretien attribuée au mauvais véhicule, sans erreur ni trace.
|
||||
*
|
||||
* Ce module est pur : aucune I/O, testable sans base.
|
||||
*/
|
||||
|
||||
export type Energie = "essence" | "diesel" | "electrique" | "hybride" | "autre";
|
||||
|
||||
/**
|
||||
* Réparation des libellés abîmés à la source.
|
||||
*
|
||||
* RDW publie certains noms avec le caractère accentué perdu — soit un « ? » littéral, soit
|
||||
* U+FFFD. Vérifié côté source : l'API renvoie bien « A5 COUP? », ce n'est pas notre ingestion.
|
||||
*
|
||||
* On répare à la LECTURE et non à l'ingestion : rien à réimporter, et la correction s'applique
|
||||
* immédiatement aux 2,9 M de lignes déjà en base. Conséquence importante, au-delà du cosmétique —
|
||||
* le catalogue éclatait un même modèle en plusieurs entrées (« A5 COUPE » 5 340 configurations,
|
||||
* « A5 COUP<EFBFBD> » 463, « A5 COUP? » 1). Réparer avant de calculer la clé les fait converger.
|
||||
*
|
||||
* Table volontairement fermée : on ne répare que ce qui est vérifiable, jamais par supposition.
|
||||
* « COUPÉ » l'est parce que la source publie les deux orthographes pour les mêmes voitures ;
|
||||
* « PÖSSL » et « CITROËN » parce que ce sont des noms propres attestés. Les corruptions qu'on
|
||||
* ne sait pas reconstituer (« ?MISSIS », « 600 DB <EFBFBD>CHARMING<EFBFBD> ») sont laissées telles quelles
|
||||
* plutôt qu'inventées — 6 modèles, 8 configurations sur 2,9 M.
|
||||
*/
|
||||
const REPARATIONS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/COUP[?<3F>]/g, "COUPÉ"],
|
||||
[/CITRO[?<3F>]N/g, "CITROËN"],
|
||||
[/P[?<3F>]SSL/g, "PÖSSL"],
|
||||
[/\bN[?<3F>](?=\d)/g, "N°"],
|
||||
];
|
||||
|
||||
/** Répare un libellé pour l'affichage. RDW publie en majuscules, les remplacements aussi. */
|
||||
export function reparerLibelle(s: string): string {
|
||||
let out = s;
|
||||
for (const [motif, remplacement] of REPARATIONS) out = out.replace(motif, remplacement);
|
||||
// Corruption résiduelle non identifiable : on retire le caractère de remplacement plutôt que
|
||||
// d'afficher « <20> » à un foyer. Le « ? » littéral est laissé — indiscernable d'une ponctuation.
|
||||
return out.replace(/<2F>/g, "").replace(/\s{2,}/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation texte pour la comparaison : casse et accents effacés, espaces resserrés.
|
||||
* Indispensable car la source écrit « CITROEN » là où un foyer écrit « Citroën ».
|
||||
*/
|
||||
export function normText(s: string): string {
|
||||
return s
|
||||
.toUpperCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export type CleVehicule = { marqueNorm: string; modeleNorm: string };
|
||||
|
||||
/**
|
||||
* Clé de rapprochement d'un véhicule. Renvoie null si l'un des deux libellés manque :
|
||||
* un modèle sans marque n'identifie rien, et deviner serait pire que ne rien affirmer.
|
||||
*/
|
||||
export function cleVehicule(marque: unknown, modele: unknown): CleVehicule | null {
|
||||
if (typeof marque !== "string" || typeof modele !== "string") return null;
|
||||
// Réparation AVANT normalisation : « A5 COUP<55> » et « A5 COUPE » doivent produire la même
|
||||
// clé, sinon un même modèle reste scindé en deux au moment du rapprochement.
|
||||
const marqueNorm = normText(reparerLibelle(marque));
|
||||
const modeleNorm = normText(reparerLibelle(modele));
|
||||
return marqueNorm && modeleNorm ? { marqueNorm, modeleNorm } : null;
|
||||
}
|
||||
|
||||
const ENERGIES: readonly Energie[] = ["essence", "diesel", "electrique", "hybride", "autre"];
|
||||
|
||||
export function estEnergie(v: unknown): v is Energie {
|
||||
return typeof v === "string" && (ENERGIES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export function energiesDe(valeur: unknown): Energie[] {
|
||||
return Array.isArray(valeur) ? valeur.filter(estEnergie) : [];
|
||||
}
|
||||
191
src/knowledge/vehicules-recherche.ts
Normal file
191
src/knowledge/vehicules-recherche.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { db, vehiculesModeles, vehiculesReference } from "@/db";
|
||||
import { type Energie, energiesDe, normText, reparerLibelle } from "./vehicules-cle";
|
||||
|
||||
/**
|
||||
* Recherche véhicule pour le quiz — aucune saisie libre (§ 4.1) : on ne propose que des
|
||||
* couples marque/modèle réellement homologués, issus du référentiel RDW (src/db/schema.ts,
|
||||
* alimenté par scripts/sync-vehicules-rdw.ts).
|
||||
*
|
||||
* Deux portes d'entrée, de la plus précise à la plus tolérante :
|
||||
* 1. le champ K de la carte grise (numéro de réception) → identification exacte ;
|
||||
* 2. la recherche marque/modèle → suggestions, puis désambiguïsation par énergie.
|
||||
* Dans les deux cas, l'énergie proposée est restreinte à celles réellement commercialisées
|
||||
* pour ce modèle, ce qui garantit un appariement propre côté entretien-vehicules.ts.
|
||||
*/
|
||||
|
||||
export type SuggestionVehicule = { marque: string; modele: string; energies: Energie[] };
|
||||
|
||||
/** Ce qu'on retient d'une réception pour pré-remplir un véhicule du foyer. */
|
||||
export type VehiculeIdentifie = {
|
||||
/**
|
||||
* Le numéro tel qu'il est écrit dans le référentiel, pas tel que l'utilisateur l'a tapé.
|
||||
* C'est lui qu'on persiste dans l'asset : tout enrichissement futur (motorisation exacte,
|
||||
* cylindrée, norme d'émission, entretien constructeur) repart de cette clé — la redemander
|
||||
* plus tard coûterait une interaction, ce que la règle d'or interdit.
|
||||
*/
|
||||
receptionNumero: string;
|
||||
/** Champ D.2, renseignés seulement si la réception ne couvre qu'une variante/version. */
|
||||
variante: string | null;
|
||||
version: string | null;
|
||||
marque: string | null;
|
||||
modele: string | null;
|
||||
energies: Energie[];
|
||||
annee: number | null;
|
||||
categorie: string | null;
|
||||
/** Nb de configurations couvertes par la réception : >1 = variante/version non discriminée. */
|
||||
configurations: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Autocomplétion marque/modèle. S'appuie sur le catalogue agrégé (quelques milliers de lignes)
|
||||
* et non sur le référentiel complet (~3 M) : la frappe doit rester instantanée.
|
||||
*
|
||||
* La recherche porte sur les colonnes normalisées, les mêmes que celles de la résolution :
|
||||
* quelqu'un qui tape « citroën » trouve « CITROEN », et ce qu'il sélectionne se raccrochera
|
||||
* ensuite exactement — l'autocomplétion et la résolution ne peuvent plus diverger.
|
||||
*/
|
||||
/**
|
||||
* Mots de l'énergie, qui ne figurent pas dans les noms de modèles : l'énergie se choisit
|
||||
* séparément, en pastilles. « HYBRID » (anglais) n'y est PAS — il apparaît dans de vrais
|
||||
* libellés, comme « CLIO (E-TECH HYBRID) ».
|
||||
*/
|
||||
const MOTS_ENERGIE = new Set(["ESSENCE", "DIESEL", "GAZOLE", "ELECTRIQUE", "HYBRIDE"]);
|
||||
|
||||
/**
|
||||
* Découpe la saisie en mots à chercher.
|
||||
*
|
||||
* `%` et `_` sont retirés : ce sont les jokers de LIKE, et les laisser passer élargirait
|
||||
* silencieusement la recherche (aucun nom de modèle ne les utilise). Le nombre de mots est
|
||||
* borné pour qu'une saisie aberrante ne fabrique pas une requête à rallonge.
|
||||
*
|
||||
* L'énergie est écartée quand il reste autre chose à chercher : « toyota yaris hybride » est
|
||||
* une saisie naturelle, et comme chaque mot doit figurer dans le libellé, « hybride » suffisait
|
||||
* à ne rien renvoyer du tout.
|
||||
*/
|
||||
export function motsDeRecherche(query: string, max = 4): string[] {
|
||||
const mots = normText(query)
|
||||
.replace(/[%_]/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, max);
|
||||
|
||||
const utiles = mots.filter((m) => !MOTS_ENERGIE.has(m));
|
||||
return utiles.length ? utiles : mots;
|
||||
}
|
||||
|
||||
export async function searchModeles(query: string, limit = 8): Promise<SuggestionVehicule[]> {
|
||||
const q = normText(query);
|
||||
if (q.length < 2) return [];
|
||||
|
||||
const mots = motsDeRecherche(q);
|
||||
if (!mots.length) return [];
|
||||
|
||||
/**
|
||||
* La marque et le modèle vivent dans deux colonnes, mais le foyer tape « audi a5 » d'un
|
||||
* seul tenant : on cherche donc dans leur concaténation, et on exige que CHAQUE mot y
|
||||
* figure. Auparavant la saisie entière était comparée à l'une OU l'autre colonne, si bien
|
||||
* que « audi a5 » ne renvoyait rien alors que « a5 » fonctionnait.
|
||||
*/
|
||||
const libelle = sql`${vehiculesModeles.marqueNorm} || ' ' || ${vehiculesModeles.modeleNorm}`;
|
||||
const rows = await db()
|
||||
.select({
|
||||
marque: vehiculesModeles.marque,
|
||||
modele: vehiculesModeles.modele,
|
||||
energies: vehiculesModeles.energies,
|
||||
configurations: vehiculesModeles.configurations,
|
||||
})
|
||||
.from(vehiculesModeles)
|
||||
.where(and(...mots.map((mot) => sql`${libelle} LIKE ${`%${mot}%`}`)))
|
||||
// Un préfixe exact prime sur une correspondance en milieu de chaîne ; à égalité, le
|
||||
// modèle le plus décliné passe devant (proxy de diffusion réelle du modèle).
|
||||
.orderBy(
|
||||
asc(sql`CASE WHEN ${libelle} LIKE ${q + "%"} THEN 0
|
||||
WHEN ${vehiculesModeles.modeleNorm} LIKE ${q + "%"} THEN 1
|
||||
ELSE 2 END`),
|
||||
desc(vehiculesModeles.configurations),
|
||||
)
|
||||
// On en tire plus que demandé : la réparation des libellés fait fusionner des lignes
|
||||
// (« A5 COUPE » et « A5 COUP<55> » sont la même voiture), il faut de quoi remplir après coup.
|
||||
.limit(limit * 3);
|
||||
|
||||
return fusionnerParLibelle(rows).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regroupe les lignes qui désignent le même modèle une fois les libellés réparés.
|
||||
*
|
||||
* Sans ça, un foyer cherchant son Audi A5 verrait trois « A5 COUPÉ » identiques et en
|
||||
* choisirait un au hasard — dont deux mènent à des lignes marginales du catalogue. Les
|
||||
* énergies sont unies, l'ordre de pertinence de la requête est conservé.
|
||||
*/
|
||||
function fusionnerParLibelle(
|
||||
rows: Array<{ marque: string; modele: string; energies: unknown }>,
|
||||
): SuggestionVehicule[] {
|
||||
const parLibelle = new Map<string, SuggestionVehicule>();
|
||||
for (const r of rows) {
|
||||
const marque = reparerLibelle(r.marque);
|
||||
const modele = reparerLibelle(r.modele);
|
||||
const cle = `${normText(marque)}|${normText(modele)}`;
|
||||
const existante = parLibelle.get(cle);
|
||||
if (!existante) {
|
||||
parLibelle.set(cle, { marque, modele, energies: energiesDe(r.energies) });
|
||||
continue;
|
||||
}
|
||||
for (const e of energiesDe(r.energies)) {
|
||||
if (!existante.energies.includes(e)) existante.energies.push(e);
|
||||
}
|
||||
}
|
||||
return [...parLibelle.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identification par le champ K de la carte grise (numéro de réception communautaire),
|
||||
* affinée par le champ D.2 (Type-Variante-Version) quand l'utilisateur le saisit aussi.
|
||||
* Renvoie null si la réception est inconnue — repli déclaratif côté UI, jamais d'erreur.
|
||||
*/
|
||||
export async function identifierParReception(
|
||||
champK: string,
|
||||
tvv?: { variante?: string; version?: string },
|
||||
): Promise<VehiculeIdentifie | null> {
|
||||
const numero = champK.trim();
|
||||
if (numero.length < 4) return null;
|
||||
|
||||
const filtres = [eq(vehiculesReference.receptionNumero, numero)];
|
||||
if (tvv?.variante) filtres.push(eq(vehiculesReference.variante, tvv.variante.trim()));
|
||||
if (tvv?.version) filtres.push(eq(vehiculesReference.version, tvv.version.trim()));
|
||||
|
||||
const [row] = await db()
|
||||
.select({
|
||||
receptionNumero: sql<string | null>`max(${vehiculesReference.receptionNumero})`,
|
||||
// Une réception couvre souvent plusieurs variantes/versions : on ne retient le champ D.2
|
||||
// que s'il est déterminé, plutôt que d'en choisir un arbitrairement.
|
||||
variante: sql<string | null>`case when count(distinct ${vehiculesReference.variante}) = 1
|
||||
then max(${vehiculesReference.variante}) end`,
|
||||
version: sql<string | null>`case when count(distinct ${vehiculesReference.version}) = 1
|
||||
then max(${vehiculesReference.version}) end`,
|
||||
marque: sql<string | null>`max(${vehiculesReference.marque})`,
|
||||
modele: sql<string | null>`max(${vehiculesReference.modele})`,
|
||||
categorie: sql<string | null>`max(${vehiculesReference.categorie})`,
|
||||
annee: sql<number | null>`min(nullif(left(${vehiculesReference.dateReception}, 4), ''))::int`,
|
||||
energies: sql<string[]>`coalesce(array_agg(distinct ${vehiculesReference.energie})
|
||||
filter (where ${vehiculesReference.energie} is not null), '{}')`,
|
||||
configurations: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(vehiculesReference)
|
||||
.where(and(...filtres));
|
||||
|
||||
if (!row || row.configurations === 0 || !row.receptionNumero) return null;
|
||||
return {
|
||||
receptionNumero: row.receptionNumero,
|
||||
variante: row.variante,
|
||||
version: row.version,
|
||||
// Libellés réparés : c'est ce que le foyer voit, et ce qu'il persistera dans son asset.
|
||||
marque: row.marque && reparerLibelle(row.marque),
|
||||
modele: row.modele && reparerLibelle(row.modele),
|
||||
energies: energiesDe(row.energies),
|
||||
annee: row.annee,
|
||||
categorie: row.categorie,
|
||||
configurations: row.configurations,
|
||||
};
|
||||
}
|
||||
156
src/knowledge/vehicules-resolution.ts
Normal file
156
src/knowledge/vehicules-resolution.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db, vehiculesModeles, vehiculesReference } from "@/db";
|
||||
import { cleVehicule, energiesDe, reparerLibelle, type Energie } from "./vehicules-cle";
|
||||
import type { Asset } from "./schema";
|
||||
|
||||
/**
|
||||
* Résolution d'un véhicule du foyer vers le référentiel — le point de passage unique de tout
|
||||
* enrichissement (rappels constructeur, entretien, et ce qui viendra).
|
||||
*
|
||||
* Pourquoi centraliser : chaque consommateur avait sa propre façon de raccrocher un asset au
|
||||
* référentiel, donc son propre jeu de faux positifs et de silences. Un foyer identifié au
|
||||
* champ K et un foyer ayant tapé « citroen c3 » doivent aboutir au MÊME modèle canonique,
|
||||
* sans quoi les deux voient des rappels différents pour la même voiture.
|
||||
*
|
||||
* Deux voies, de la plus sûre à la plus tolérante :
|
||||
* 1. le numéro de réception (champ K) persisté à l'inscription → correspondance exacte ;
|
||||
* 2. la clé normalisée marque/modèle → correspondance stricte sur libellés comparables.
|
||||
* Aucune correspondance approchée : sur des campagnes de sécurité, un faux positif coûte
|
||||
* plus cher qu'un silence (§ 4.5, aucune comptabilité morale — on n'inquiète pas à tort).
|
||||
*/
|
||||
|
||||
export type VehiculeResolu = {
|
||||
/** Libellés canoniques du référentiel, pas ceux tapés par le foyer. */
|
||||
marque: string;
|
||||
modele: string;
|
||||
marqueNorm: string;
|
||||
modeleNorm: string;
|
||||
energie: Energie | null;
|
||||
/** Année déclarée par le foyer — sert de garde-fou temporel aux enrichissements. */
|
||||
annee: number | null;
|
||||
/** Comment on a raccroché : utile pour mesurer la qualité d'identification du parc. */
|
||||
via: "reception" | "libelle";
|
||||
};
|
||||
|
||||
function anneeDe(attributs: Record<string, unknown>): number | null {
|
||||
const a = attributs.annee;
|
||||
return typeof a === "number" && Number.isFinite(a) ? a : null;
|
||||
}
|
||||
|
||||
function energieDe(attributs: Record<string, unknown>): Energie | null {
|
||||
const c = attributs.energie;
|
||||
// `carburant` est l'ancien nom du même champ : les assets créés avant le renommage le
|
||||
// portent encore si la migration n'a pas tourné. On lit les deux plutôt que de rendre
|
||||
// muet un véhicule existant.
|
||||
const legacy = attributs.carburant;
|
||||
const valeur = typeof c === "string" && c ? c : typeof legacy === "string" && legacy ? legacy : null;
|
||||
return valeur as Energie | null;
|
||||
}
|
||||
|
||||
/** Voie 1 — le champ K identifie exactement, on prend les libellés du référentiel. */
|
||||
async function parReception(numero: string, attributs: Record<string, unknown>): Promise<VehiculeResolu | null> {
|
||||
const filtres = [eq(vehiculesReference.receptionNumero, numero)];
|
||||
const variante = attributs.variante;
|
||||
const version = attributs.version;
|
||||
if (typeof variante === "string" && variante) filtres.push(eq(vehiculesReference.variante, variante));
|
||||
if (typeof version === "string" && version) filtres.push(eq(vehiculesReference.version, version));
|
||||
|
||||
const [row] = await db()
|
||||
.select({
|
||||
marque: sql<string | null>`max(${vehiculesReference.marque})`,
|
||||
modele: sql<string | null>`max(${vehiculesReference.modele})`,
|
||||
// Une réception peut couvrir plusieurs motorisations : on ne retient l'énergie que si
|
||||
// elle est unique, sinon on laisse celle déclarée par le foyer trancher.
|
||||
energie: sql<string | null>`case when count(distinct ${vehiculesReference.energie}) = 1
|
||||
then max(${vehiculesReference.energie}) end`,
|
||||
total: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(vehiculesReference)
|
||||
.where(and(...filtres));
|
||||
|
||||
if (!row?.total || !row.marque || !row.modele) return null;
|
||||
const cle = cleVehicule(row.marque, row.modele);
|
||||
if (!cle) return null;
|
||||
|
||||
const energieRef = row.energie;
|
||||
return {
|
||||
marque: reparerLibelle(row.marque),
|
||||
modele: reparerLibelle(row.modele),
|
||||
...cle,
|
||||
energie: (energieRef as Energie | null) ?? energieDe(attributs),
|
||||
annee: anneeDe(attributs),
|
||||
via: "reception",
|
||||
};
|
||||
}
|
||||
|
||||
/** Voie 2 — marque/modèle déclarés, rapprochés sur la clé normalisée du catalogue. */
|
||||
async function parLibelle(
|
||||
marque: unknown,
|
||||
modele: unknown,
|
||||
attributs: Record<string, unknown>,
|
||||
): Promise<VehiculeResolu | null> {
|
||||
const cle = cleVehicule(marque, modele);
|
||||
if (!cle) return null;
|
||||
|
||||
const [row] = await db()
|
||||
.select({
|
||||
marque: vehiculesModeles.marque,
|
||||
modele: vehiculesModeles.modele,
|
||||
energies: vehiculesModeles.energies,
|
||||
})
|
||||
.from(vehiculesModeles)
|
||||
.where(and(
|
||||
eq(vehiculesModeles.marqueNorm, cle.marqueNorm),
|
||||
eq(vehiculesModeles.modeleNorm, cle.modeleNorm),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
// Modèle absent du catalogue (saisie libre héritée, marque exotique) : on garde quand même
|
||||
// la clé normalisée du foyer. Les enrichissements sauront ne rien trouver, sans planter.
|
||||
if (!row) {
|
||||
return {
|
||||
marque: reparerLibelle(String(marque)), modele: reparerLibelle(String(modele)), ...cle,
|
||||
energie: energieDe(attributs), annee: anneeDe(attributs), via: "libelle",
|
||||
};
|
||||
}
|
||||
|
||||
const declaree = energieDe(attributs);
|
||||
const disponibles = energiesDe(row.energies);
|
||||
return {
|
||||
marque: reparerLibelle(row.marque),
|
||||
modele: reparerLibelle(row.modele),
|
||||
...cle,
|
||||
// L'énergie déclarée ne prime que si le modèle la propose réellement.
|
||||
energie: declaree && disponibles.includes(declaree) ? declaree : disponibles.length === 1 ? disponibles[0] : declaree,
|
||||
annee: anneeDe(attributs),
|
||||
via: "libelle",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout un asset véhicule. Renvoie null s'il n'y a pas de quoi identifier quoi que ce soit —
|
||||
* un cas parfaitement normal (« je ne sais pas » est une réponse de première classe, § 4.3).
|
||||
*/
|
||||
export async function resoudreVehicule(asset: Asset): Promise<VehiculeResolu | null> {
|
||||
if (asset.type !== "vehicule") return null;
|
||||
const attributs = asset.attributes;
|
||||
|
||||
const reception = attributs.reception_numero;
|
||||
if (typeof reception === "string" && reception.trim().length >= 4) {
|
||||
const exact = await parReception(reception.trim(), attributs);
|
||||
if (exact) return exact;
|
||||
// Réception inconnue du référentiel (retirée, ou saisie d'un pays non couvert) :
|
||||
// on ne renonce pas, on retombe sur les libellés.
|
||||
}
|
||||
|
||||
return parLibelle(attributs.marque, attributs.modele, attributs);
|
||||
}
|
||||
|
||||
/** Résout plusieurs véhicules d'un coup, en écartant ceux qu'on ne sait pas identifier. */
|
||||
export async function resoudreVehicules(assets: Asset[]): Promise<Array<{ asset: Asset; resolu: VehiculeResolu }>> {
|
||||
const vehicules = assets.filter((a) => a.type === "vehicule");
|
||||
const resolus = await Promise.all(
|
||||
vehicules.map(async (asset) => ({ asset, resolu: await resoudreVehicule(asset).catch(() => null) })),
|
||||
);
|
||||
return resolus.filter((r): r is { asset: Asset; resolu: VehiculeResolu } => r.resolu !== null);
|
||||
}
|
||||
4
src/lib/cn.ts
Normal file
4
src/lib/cn.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Concaténation de classes conditionnelles — suffisant ici, évite une dépendance. */
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
23
src/lib/fonts.ts
Normal file
23
src/lib/fonts.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Fraunces, Inter } from "next/font/google";
|
||||
|
||||
/**
|
||||
* Deux familles, deux rôles (direction « institution chaleureuse ») :
|
||||
* - Fraunces, serif à contraste, porte toute la personnalité de marque (titres, chiffres clés).
|
||||
* - Inter, sans neutre, porte la lisibilité (texte courant, listes denses du hub, formulaires).
|
||||
*
|
||||
* `next/font/google` télécharge les fichiers au build et les sert depuis notre domaine :
|
||||
* aucun appel navigateur vers Google, condition non négociable (hébergement France, RGPD § 16).
|
||||
*/
|
||||
export const fontDisplay = Fraunces({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-display",
|
||||
// `opsz` bas + `SOFT` léger : on garde le contraste éditorial sans la bizarrerie de Fraunces.
|
||||
axes: ["SOFT", "opsz"],
|
||||
});
|
||||
|
||||
export const fontSans = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db, leads as leadsTable } from "@/db";
|
||||
|
||||
/** A-t-on déjà l'email de ce foyer ? Condition d'affichage de la preview (§ 10.2). */
|
||||
export async function hasUnlockedPreview(householdId: string): Promise<boolean> {
|
||||
/**
|
||||
* A-t-on déjà une adresse pour ce foyer ?
|
||||
*
|
||||
* Ne conditionne plus rien à l'affichage depuis D-005 (le mur e-mail post-quiz est retiré) :
|
||||
* l'aperçu est visible sans contrepartie, et l'adresse se récolte après le service rendu
|
||||
* (foyer/[id]/garder-lien.tsx). Sert désormais à ne pas reproposer la capture à quelqu'un
|
||||
* qui l'a déjà donnée.
|
||||
*/
|
||||
export async function hasLead(householdId: string): Promise<boolean> {
|
||||
const rows = await db().select({ id: leadsTable.id }).from(leadsTable).where(eq(leadsTable.householdId, householdId));
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,20 @@ import type { Pilier, DeadlineTemplate } from "@/knowledge/schema";
|
|||
* Un picto et une couleur par pilier — jamais décoratif seul : sert de repère de tri
|
||||
* (guides groupés par icône), de fil d'Ariane (badge en tête d'article) et de warning
|
||||
* visuel cohérent sur le hub. Piliers = les 4 seuls autorisés par le cadrage (§ 5.1).
|
||||
*
|
||||
* `color` pointe une variable CSS et non un hex : c'est ce qui permet aux mêmes composants
|
||||
* de basculer en mode sombre sans qu'aucun d'eux ne connaisse l'existence du mode sombre
|
||||
* (les valeurs claires/sombres vivent uniquement dans globals.css).
|
||||
*/
|
||||
export const PILIER_META: Record<Pilier, { label: string; Icon: LucideIcon; color: string }> = {
|
||||
maison: { label: "Maison", Icon: Home, color: "#2e6650" },
|
||||
vehicules: { label: "Véhicules", Icon: Car, color: "#8a5a2b" },
|
||||
papiers: { label: "Papiers", Icon: FileText, color: "#3a5f8a" },
|
||||
animaux: { label: "Animaux", Icon: PawPrint, color: "#8a3b6b" },
|
||||
maison: { label: "Maison", Icon: Home, color: "var(--pilier-maison)" },
|
||||
vehicules: { label: "Véhicules", Icon: Car, color: "var(--pilier-vehicules)" },
|
||||
papiers: { label: "Papiers", Icon: FileText, color: "var(--pilier-papiers)" },
|
||||
animaux: { label: "Animaux", Icon: PawPrint, color: "var(--pilier-animaux)" },
|
||||
};
|
||||
|
||||
export const PILIER_ORDER: Pilier[] = ["maison", "vehicules", "papiers", "animaux"];
|
||||
|
||||
type EnjeuType = DeadlineTemplate["enjeu"]["type"];
|
||||
|
||||
/** Un picto par enjeu — signale en un coup d'œil pourquoi l'échéance compte (§ 4.6 : le pourquoi, toujours). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue