Phase 0 : cadrage, base de connaissance v0 et app de test de désirabilité
- Docs : cadrage produit + architecture (référence), journal de décisions (D-002 API plaque), specs Phase 0 (quiz, landing, étude API) - Base de connaissance v0 : 29 fiches YAML validées par Zod (4 piliers) - Moteur v0 déterministe : DSL d'applicabilité, fenêtres de confiance, stratégies CT/passeport - App Next.js 15 : landing (prix 4,90 €/mois), quiz 8 écrans, hub figé, export .ics, capture email fondateur, funnel d'événements - Adapters : adresse (BAN + DPE ADEME), plaque (mock sans clé, anti-abus 1/quiz + cache + limite IP) - Infra : Postgres + Drizzle, Docker (conventions ai-net), déployé sous fokan.g0tch.myds.me Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
958d2d4eea
44 changed files with 5627 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# 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=
|
||||
|
||||
# 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
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
35
CLAUDE.md
Normal file
35
CLAUDE.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# fokan — règles de travail
|
||||
|
||||
Projet « Veille du foyer » (marque sœur de Kankwa, éditée par MIAW). La source de vérité complète est [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — en cas de doute sur une décision produit ou technique, la réponse y est probablement déjà, validée.
|
||||
|
||||
## Constitution produit (arbitre de toute feature)
|
||||
|
||||
- **Règle d'or** : chaque interaction supprime plus de travail qu'elle n'en coûte. Onboarding ≤ 2 min, aucune interaction récurrente > 10 s, le mode « je ne fais rien » est parfaitement servi.
|
||||
- **Inférence > saisie** : tout ce qui peut être déduit n'est jamais demandé. Aucune saisie obligatoire (exception unique : les contrats, opt-in). Jamais de question mémoire.
|
||||
- **« Je ne sais pas » est une réponse de première classe** : le moteur manipule des fenêtres avec niveau de confiance, pas des dates exigées.
|
||||
- **Le silence est une feature** : budget notifications strict, regroupement, priorisation légal > confort.
|
||||
- **Aucune comptabilité morale** : glissement silencieux, pas de rouge, pas de retard, pas de complétion, pas de gamification. Test universel : « ça marche pour quelqu'un qui a oublié que le service existe ? »
|
||||
- **Refus définitifs** : pas de to-do list/organiseur du quotidien, pas de coffre-fort documentaire (on indexe, on n'archive pas — le fichier brut est toujours jeté), pas de dashboard à tenir, pas de santé, pas de social/cadeaux, pas de conseil financier.
|
||||
|
||||
## Invariants d'architecture (gravés — § 19.3 du cadrage)
|
||||
|
||||
1. IDs de templates stables à vie.
|
||||
2. Réconciliation idempotente, rejouable sans effet de bord.
|
||||
3. État utilisateur inviolable (« fait », mute, responsables, dates apprises survivent à toute évolution de la base).
|
||||
4. Séparation stricte des 3 couches : Connaissance (templates YAML/MDX) / Instance (foyer, assets, deadlines) / Occurrences (timeline append-only).
|
||||
5. LLM aux frontières uniquement — le moteur d'échéances est 100 % déterministe et testable.
|
||||
6. Contenu multi-canal (mail, hub, SEO) dans le template — bicéphalie structurelle.
|
||||
7. Un pilier = de la configuration, jamais du code (un pilier est un `asset_type`, pas une table).
|
||||
|
||||
## Stack (décisions validées)
|
||||
|
||||
TypeScript de bout en bout · Next.js (App Router) unique pour SEO/hub PWA/API · PostgreSQL + Drizzle · pg-boss pour jobs et cron (pas de Redis) · Zod partout · React Email + adapter d'envoi · base de connaissance en YAML + MDX dans le repo, validée en CI (Git-only, pas de back-office) · better-auth auto-hébergé (mot de passe + liens magiques) · monorepo, CI GitHub Actions, déploiement Docker sur le serveur local (prod locale assumée, clause de sortie à ~200-300 foyers payants).
|
||||
|
||||
Priorité absolue des tests : le moteur de réconciliation (transitions du § 20.4, idempotence, inviolabilité de l'état utilisateur).
|
||||
|
||||
## Contraintes transverses
|
||||
|
||||
- Hébergement France, RGPD by design, minimisation (aucun blob stocké, jamais).
|
||||
- Jamais d'envoi mail depuis une IP résidentielle ; SPF/DKIM/DMARC stricts ; plan B routeur SaaS à un fichier de config.
|
||||
- Charge serveur minimale : données textuelles structurées, batch quotidien, pas de temps réel.
|
||||
- Ton produit : majordome complice et déculpabilisant, jamais productiviste ni moralisateur. Chaque rappel donne le pourquoi en une ligne.
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/knowledge ./knowledge
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# fokan — la veille du foyer
|
||||
|
||||
Service web qui **monte la garde sur les échéances dormantes du foyer** (maison, véhicules, papiers, animaux) et en tient le registre, sans jamais créer de tâche nouvelle pour l'utilisateur.
|
||||
|
||||
> *Personne ne retient tout ça — c'est normal. Nous, c'est notre métier.*
|
||||
|
||||
Marque sœur de [Kankwa](../kankwa), éditée par MIAW. Nom de code : **fokan** (naming définitif à confirmer).
|
||||
|
||||
## L'essentiel
|
||||
|
||||
- **Quiz de 2 min + détections (plaque, adresse)** → le calendrier complet du foyer (30-50 échéances), sans saisie.
|
||||
- **Le mail est le moteur** : rappels rares, au bon moment, avec l'action incluse. Réaction en 10 s ou ignorance sans conséquence.
|
||||
- **Freemium** : gratuit = le savoir instantané (quiz + hub figé + export .ics) ; payant (~4-5 €/mois par foyer) = la garde permanente + le registre.
|
||||
- **La barrière** : la base de connaissance des échéances françaises + le pipeline de compréhension de documents. Le produit, c'est le savoir.
|
||||
|
||||
## Documents
|
||||
|
||||
- [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — cadrage produit + architecture technique de référence, validé point par point le 22 juillet 2026. **Source de vérité du projet.**
|
||||
- [CLAUDE.md](CLAUDE.md) — règles de travail sur ce dépôt (constitution produit, invariants d'architecture, stack).
|
||||
- [docs/phase-0/etude-api-plaque.md](docs/phase-0/etude-api-plaque.md) — étude des fournisseurs API plaque + sources open data adresse.
|
||||
- [docs/phase-0/quiz.md](docs/phase-0/quiz.md) — les 8 écrans du quiz, réponses et inférences.
|
||||
- [docs/phase-0/landing.md](docs/phase-0/landing.md) — spec de la landing, prix affiché, hub figé, mesure de l'intention.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. **Phase 0** — test de désirabilité : landing + quiz réel + hub figé + prix affiché. Mesure de l'étoile polaire (conversion quiz → abonné).
|
||||
2. **MVP** — la veille seule : rappels mail, partage conjoint, récap mensuel, ~30 pages SEO.
|
||||
3. **Phase 2** — pipeline de compréhension de documents (mail entrant) + bannette.
|
||||
4. **Phase 3** — scan photo, multiplicateurs (foyer des parents, bien locatif), affiliation.
|
||||
|
||||
Prochains chantiers, dans l'ordre : ① Phase 0 (questions du quiz + landing + étude API plaque) · ② Base de connaissance v1 (~45 fiches) · ③ Squelette technique (monorepo, Drizzle, moteur de réconciliation + tests) · ④ Nom et identité.
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: fokan-db
|
||||
environment:
|
||||
POSTGRES_USER: fokan
|
||||
POSTGRES_PASSWORD: fokan
|
||||
POSTGRES_DB: fokan
|
||||
volumes:
|
||||
- /srv/user-data/fokan:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fokan"]
|
||||
interval: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: fokan-app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
ports:
|
||||
- "127.0.0.1:3100:3000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
networks:
|
||||
ai-net:
|
||||
external: true
|
||||
641
docs/cadrage-projet-fokan.md
Normal file
641
docs/cadrage-projet-fokan.md
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
# Document de cadrage — Projet « Veille du foyer »
|
||||
**Marque sœur de Kankwa, éditée par MIAW** · Version 1.0 — cadrage validé point par point le 22 juillet 2026 · Nom de code : *fokan* (ex-« La Veille », naming définitif à confirmer)
|
||||
|
||||
---
|
||||
|
||||
## 1. Résumé exécutif
|
||||
|
||||
Un service web qui **monte la garde sur les échéances dormantes du foyer** (maison, véhicules, papiers, animaux) et en **tient le registre**, sans jamais créer de tâche nouvelle pour l'utilisateur.
|
||||
|
||||
- **Le problème** : des dizaines d'échéances que personne ne stocke nulle part (entretien chaudière, contrôle technique, passeport des enfants, résiliations de contrats...), qui pèsent sur une seule personne du foyer, avec des enjeux légaux, financiers et de sécurité réels.
|
||||
- **La solution** : un quiz de 2 minutes + des détections automatiques (plaque, adresse) génèrent le calendrier complet du foyer. Le service prévient par mail au bon moment, avec l'action incluse. L'utilisateur réagit en 10 secondes ou ignore — sans conséquence, sans culpabilisation.
|
||||
- **Le modèle** : freemium. Gratuit = le savoir instantané (quiz + hub figé + export ics). Payant (~4-5 €/mois par foyer, palier unique) = la garde permanente + le registre. Affiliation en opportunité, licence B2B2C à terme.
|
||||
- **La barrière** : la base de connaissance des échéances françaises (règles, fréquences légales, délais, enjeux) + le pipeline de compréhension de documents. Le produit, c'est le savoir ; le code n'est que son véhicule.
|
||||
- **L'objectif** : plusieurs milliers d'euros de revenu récurrent mensuel, en solo, avec une charge serveur minimale.
|
||||
- **Le constat de marché** : demande massive et documentée (88 % des Français touchés par la charge mentale), sept fronts concurrentiels tous verticaux et/ou réactifs, **le quadrant « proactif × foyer entier » est vide**. Chaque composant du produit est validé séparément par un acteur existant ; personne n'a fait l'assemblage. Potentiel évalué : 8/10, avec une contrainte de tempo (les indés du scan IA itèrent vite).
|
||||
|
||||
**Le pitch en une phrase** : *Personne ne retient tout ça — c'est normal. Nous, c'est notre métier.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Vision, promesse et positionnement
|
||||
|
||||
### 2.1 La promesse
|
||||
Décharger la charge mentale des échéances du foyer **sans en créer une nouvelle**. Le service est un majordome silencieux : il sait déjà, il prévient au bon moment, il fournit le premier pas de l'action, il se tait le reste du temps, et il tient la mémoire de ce qui a été fait.
|
||||
|
||||
### 2.2 Ce que le produit EST
|
||||
- Un **service de veille** (push) : il prévient, l'utilisateur réagit.
|
||||
- Un **registre** (la bannette) : il comprend les documents qui passent et en conserve le sens.
|
||||
- Un **savoir expert** : les échéances françaises que les gens ignorent devoir connaître (obligations légales, délais administratifs réels, saisonnalités).
|
||||
|
||||
### 2.3 Ce que le produit N'EST PAS (refus définitifs)
|
||||
- **Pas une to-do list** ni un organiseur du quotidien (courses, repas, ménage, linge) — territoire saturé (Cozi & co) et incompatible avec la promesse.
|
||||
- **Pas un coffre-fort documentaire** — bataille perdue face à Digiposte ; on n'archive pas, on indexe (voir § 8.4).
|
||||
- **Pas un dashboard à tenir** — pas de complétion, pas de gamification, pas de compteurs rouges, pas de tâches « en retard ».
|
||||
- **Pas un produit santé** — frontière stricte (hébergement HDS, responsabilité). Aucune donnée médicale, jamais.
|
||||
- **Pas un réseau social ni un produit cadeaux/anniversaires** — les gens connaissent ces dates ; c'est le territoire de Kankwa (passerelle discrète possible).
|
||||
- **Pas un conseiller financier** — les finances n'existent que via la couche contrats (résiliations, renégociations), jamais en conseil de placement.
|
||||
|
||||
### 2.4 Positionnement concurrentiel
|
||||
Deux axes structurent le marché : *réactif vs proactif* et *vertical vs foyer entier*. Tous les acteurs identifiés sont réactifs (on les sollicite quand le problème est là) et/ou verticaux (un seul domaine). **Le produit occupe le quadrant vide : proactif × foyer entier.** Différenciation résumée : *les autres rangent tes papiers ou listent tes tâches ; nous, on sait déjà, on prévient avant, et on comprend ce qui passe.*
|
||||
|
||||
### 2.5 Marque
|
||||
- **Marque sœur sous MIAW** : identité propre, distincte de Kankwa (l'événementiel joyeux et collectif vs la sérénité domestique, sobre et de confiance).
|
||||
- Signature commune : « par le créateur de Kankwa ». Passerelles discrètes entre les deux produits (audience partagée : l'organisateur par défaut).
|
||||
- Valeurs communes revendiquées : artisan français indépendant, hébergement France, pas de publicité, pas de revente de données, transparence.
|
||||
- Le nom : chantier dédié, hors cadrage. Le ton : complice et déculpabilisant, jamais productiviste ni moralisateur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cible
|
||||
|
||||
### 3.1 Décision validée : produit universel, acquisition par portes verticales
|
||||
- **Le produit vise tout le monde** : le quiz adapte le service. Un locataire sans voiture avec un chat obtient un hub à ~12 échéances ; un propriétaire multi-équipé en obtient ~45. Personne ne voit de pilier vide.
|
||||
- **Le marketing ne vise jamais « tout le monde »** : chaque canal d'acquisition cible une douleur ou un moment précis (une page SEO « quand contrôle technique », une vidéo sur le passeport enfant expiré), et tous débouchent sur le même quiz universel. Les données d'usage désigneront les segments qui convertissent et retiennent le mieux.
|
||||
|
||||
### 3.2 Cœur de valeur (le foyer qui reçoit le plus)
|
||||
Le foyer propriétaire 30-55 ans multi-équipé : maison individuelle + 1-2 véhicules + enfants + souvent animaux. 30-50 échéances dormantes, plusieurs centaines d'euros d'enjeu annuel (résiliations, amendes évitées, sinistres couverts). Ordre de grandeur : 8-10 millions de foyers en France.
|
||||
|
||||
### 3.3 Persona de référence
|
||||
**Léa, 32-40 ans**, « organisatrice par défaut » du foyer (héritée de Kankwa) : charge mentale élevée, c'est elle qui pense à tout ; son conjoint Thomas participe si on lui assigne directement. Variantes servies par le même produit : le solo propriétaire, le couple sans enfants, l'aidant de parents âgés (multiplicateur an 2), le bailleur (multiplicateur an 2).
|
||||
|
||||
---
|
||||
|
||||
## 4. Philosophie produit — les règles gravées
|
||||
|
||||
Ces règles constituent la **constitution du produit**. Elles ont été validées intégralement et servent d'arbitre à chaque demande de feature future.
|
||||
|
||||
### 4.1 La règle d'or
|
||||
**Chaque interaction doit supprimer plus de travail qu'elle n'en coûte.** Budgets stricts : onboarding ≤ 2 minutes ; aucune interaction récurrente > 10 secondes ; le mode « je ne fais rien » reste un mode parfaitement servi.
|
||||
|
||||
### 4.2 L'outil sait déjà (inférence > saisie)
|
||||
- Tout ce qui peut être déduit ne doit jamais être demandé (type de chauffage → entretien obligatoire ; année du véhicule → contrôle technique ; département → Loi Montagne).
|
||||
- **Aucune saisie obligatoire.** Unique exception validée : les contrats (voir § 5.5), parce que c'est la seule saisie à récompense monétaire directe — et la détection (transfert de mail) la rendra progressivement inutile.
|
||||
- Le quiz ne pose que des questions **factuelles et faciles** (choses qu'on sait sans chercher). **Jamais de question mémoire** (« quand a eu lieu ton dernier ramonage ? » est interdit).
|
||||
|
||||
### 4.3 « Je ne sais pas » est une réponse de première classe
|
||||
Le moteur manipule des **fenêtres avec niveau de confiance**, pas des dates exigées :
|
||||
- **Date connue** (rare) : précision réelle (ex. contrôle technique via la plaque).
|
||||
- **Fenêtre estimée** : « cette année / l'an dernier / aucune idée » — si aucune idée, rappel programmé à la prochaine fenêtre saisonnière logique.
|
||||
- **Sans date du tout** : les échéances saisonnières et légales n'ont besoin d'aucune info personnelle (≈ la moitié de la base).
|
||||
- La donnée se **construit par l'usage** : « déjà fait » en un tap devient la date. Jamais d'interrogatoire.
|
||||
|
||||
### 4.4 Le silence comme feature
|
||||
- Budget notifications strict : jamais plus de quelques envois par mois, regroupés quand c'est possible (« 3 choses ce mois-ci »), priorisés (l'obligatoire légal avant le confort).
|
||||
- Chaque rappel a un « plus jamais ça » sans culpabilisation.
|
||||
- La confiance vient de là : *quand il sonne, c'est que c'est important.*
|
||||
|
||||
### 4.5 Aucune dette d'entretien, aucune comptabilité morale
|
||||
- Une échéance passée sans réponse **glisse silencieusement** vers la prochaine fenêtre. Pas de rouge, pas de retard, pas de taux de complétion, pas de gamification, pas de streaks.
|
||||
- Au retour après 6 mois d'absence : une seule question douce (« la chaudière, c'est fait finalement ? »), jamais un cimetière de tâches.
|
||||
- **Le test universel** : « est-ce que ça marche pour quelqu'un qui a oublié que le service existe ? » Si oui → conforme. Si non → to-do list déguisée → refus.
|
||||
|
||||
### 4.6 Le ton : majordome, pas moniteur
|
||||
- L'imprécision est assumée par le service, jamais reprochée à l'utilisateur (« si c'est déjà fait, dis-le-moi et je me tais »).
|
||||
- **Le pourquoi, toujours, en une ligne** : chaque rappel donne la raison et l'enjeu (« obligatoire, et exigé par l'assurance en cas de sinistre »). On aide un adulte, on ne donne pas d'ordre.
|
||||
- Le marketing dit « personne ne retient ça, c'est normal », jamais « soyez organisé ».
|
||||
|
||||
### 4.7 Conséquence assumée sur les métriques
|
||||
Les métriques d'engagement (DAU, temps passé, sessions) seront **volontairement mauvaises** — c'est le signe que le produit fonctionne. Voir § 12 pour le pilotage réel.
|
||||
|
||||
---
|
||||
|
||||
## 5. Périmètre fonctionnel
|
||||
|
||||
### 5.1 Vue d'ensemble
|
||||
**Un moteur · 4 piliers (Maison, Véhicules, Papiers, Animaux) · contrats intégrés aux piliers en opt-in · 2 multiplicateurs an 2 · 1 bannette · 2 canaux (hub + mail) · 0 appli native.**
|
||||
Frontière temporelle du périmètre : **tout ce qui se prévoit à plus de 2 semaines, rien de ce qui se fait dans la journée.**
|
||||
|
||||
### 5.2 Pilier Maison (~20-25 échéances)
|
||||
- **Entretien & sécurité** : chaudière gaz/fioul (annuel obligatoire), PAC/clim (bisannuel), ramonage (annuel, souvent exigé par l'assurance — règlement sanitaire départemental), détecteurs de fumée (piles + remplacement 10 ans), VMC, chauffe-eau (détartrage), adoucisseur (sel/entretien), fosse septique (vidange ~4 ans), purge radiateurs.
|
||||
- **Saisonnier extérieur** : gouttières (automne), inspection toiture, taille haies/élagage (obligations de voisinage, périodes), piscine (hivernage / remise en route / analyses), commande de bois avant l'hiver.
|
||||
- **Administratif & financier logement** : taxe foncière, redevance ordures, assurance habitation (échéance, renégociation), AG de copropriété (convocation, pouvoirs), révision loyer IRL / bail, fin d'offre énergie à prix fixe, diagnostics à durée limitée (si vente/location en vue), déclaration des biens immobiliers.
|
||||
- **Équipements & garanties** (via bannette, phase 2) : dates de fin de garantie extraites des factures = échéances dormantes.
|
||||
|
||||
### 5.3 Pilier Véhicules (~10-12 échéances par véhicule)
|
||||
- Contrôle technique (4 ans puis 2 ans ; contre-visite 2 mois) — **date réelle obtenue par la plaque**.
|
||||
- Révision constructeur (km ou date), vidange, **courroie de distribution** (l'oubli qui coûte un moteur — préconisations par modèle), plaquettes, pneus (usure + hiver : Loi Montagne, 1er nov-31 mars selon département), batterie (alerte avant l'hiver), recharge clim.
|
||||
- Assurance auto (échéance, renégociation), carte grise (changement d'adresse : 1 mois), Crit'Air / règles ZFE (par département).
|
||||
- Deux-roues, vélos, remorque : mêmes mécaniques, en option du quiz.
|
||||
|
||||
### 5.4 Pilier Papiers (~8-10 échéances)
|
||||
- CNI (15 ans), passeports (10 ans adulte, **5 ans enfant** — le piège classique), permis format carte (15 ans).
|
||||
- **La valeur experte** : ce n'est pas la date d'expiration qui compte, c'est **date − délai d'obtention réel − règle des 6 mois de validité exigée par certains pays**. Rappels calés sur les délais mairie/ANTS constatés et sur les départs en vacances.
|
||||
- Déclaration de revenus (calendrier par département), déclaration/modulation du prélèvement à la source, recensement citoyen (16 ans) + JDC (inféré de l'âge des enfants).
|
||||
- **Sous-module CESU/emploi à domicile** : déclarations, attestation fiscale annuelle, crédit d'impôt 50 % (une question de quiz : « tu emploies quelqu'un à domicile ? »).
|
||||
- Papiers d'identité : dates extraites par photo si l'utilisateur le souhaite ; **les scans ne sont jamais conservés** (voir § 8.4).
|
||||
|
||||
### 5.5 Pilier Animaux (~6-8 échéances par animal)
|
||||
- Vaccins (rappel annuel), antiparasitaires (mensuel, saison avril-oct minimum), vermifuge (trimestriel), bilan vétérinaire senior (selon l'âge), mise à jour I-CAD (si déménagement), assurance animale (échéance).
|
||||
- Garde à réserver : déclenchée par l'approche des vacances scolaires.
|
||||
- Inférence triviale (« chien, chat, autre ? âge ? »), attachement émotionnel maximal, présence régulière du service entre deux grosses échéances.
|
||||
|
||||
### 5.6 Les contrats — intégrés aux piliers, en opt-in (décision révisée)
|
||||
- **Pas de pilier « Contrats » dans l'interface.** Chaque contrat vit dans le pilier qu'il sert : assurance habitation et énergie → Maison ; assurance auto → Véhicules ; assurance animale → Animaux ; mutuelle, box, mobile, salle de sport, assurance emprunteur → rattachés au foyer.
|
||||
- **Existence conditionnée au renseignement par l'utilisateur** : proposé après le quiz (jamais dedans), au moment pertinent, en deux champs maximum (« chez qui ? mois d'échéance approximatif ? », « je ne sais pas » accepté → fenêtre par défaut), contre une promesse monétaire explicite.
|
||||
- **La détection rattrapera la déclaration** : en phase 2, transférer le PDF du contrat = le renseigner.
|
||||
- C'est le gisement principal de l'affiliation future (loi Hamon, loi Lemoine, fins d'engagement) — proportionnelle à la confiance donnée.
|
||||
|
||||
### 5.7 Multiplicateurs (an 2 — même moteur, plus de périmètre)
|
||||
- **Le foyer des parents** (aidance) : « ajoute la maison de tes parents » — marché en explosion, pile la cible (l'organisateur par défaut gère aussi ça), ARPU multiplié.
|
||||
- **Le bien locatif** (bailleur) : révision IRL, régularisation des charges, validité des diagnostics, échéances fiscales — périodicités très codifiées, willingness-to-pay élevée.
|
||||
- La structure multi-foyers du modèle de comptes (§ 6.3) les rend natifs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architecture produit
|
||||
|
||||
### 6.1 Le hub web (PWA)
|
||||
- **Un hub par foyer**, centralisé, accessible web/mobile. Pas d'appli native.
|
||||
- **Résidence secondaire, pas principale** : on peut être un utilisateur parfaitement servi sans jamais l'ouvrir spontanément. On y **atterrit** depuis un mail (lien magique, sans login) pour agir en 10 secondes.
|
||||
- Écran d'accueil = **un état, pas une liste** : « ✓ Tout est sous contrôle — prochaine échéance : ramonage, octobre. » Rassurant, vide d'obligations.
|
||||
- Contenu : les échéances par pilier, la bannette (phase 2), les membres, les réglages de notifications, le récap.
|
||||
|
||||
### 6.2 Les canaux
|
||||
- **Le mail est le moteur** (décision : mail seul au MVP). Chaque rappel = objet clair (l'échéance), corps = enjeu en une ligne + boutons d'action ([✓ Fait] [Déjà fait] [→ Déléguer] [Premier pas de l'action]). Zéro habillage marketing.
|
||||
- **Délivrabilité = infrastructure critique** : domaine d'envoi dédié, SPF/DKIM/DMARC, routeur sérieux, montée en volume progressive, monitoring spam. Un rappel en spam = la promesse rompue.
|
||||
- **Abonnement ics en option** : flux webcal en lecture seule (« voir mes échéances dans mon agenda »), qui se met à jour seul. L'export .ics statique reste l'objet du gratuit.
|
||||
- Extensions ultérieures (phase 3, optionnelles) : SMS pour le critique à sanction (excellent taux de lecture, coût unitaire), push PWA pour qui installe.
|
||||
|
||||
### 6.3 Comptes et foyer (modèle validé)
|
||||
- **Le foyer est l'objet central** : un foyer = un quiz, une base d'échéances, une bannette, **un abonnement** (facturé au foyer, rattaché au compte payeur).
|
||||
- **Comptes légers** : lien magique par mail (ou passkey), pas de mot de passe. Rejoindre un foyer = cliquer l'invitation + confirmer son mail (~20 secondes).
|
||||
- **Membres égaux** : pas de hiérarchie admin/invité — esprit de redistribution de la charge. Seule la facturation est individuelle.
|
||||
- **Chaque échéance a un responsable**, réassignable en un tap : c'est le geste de délégation, cœur de la promesse. Le membre assigné reçoit **directement** ses rappels (la charge est redistribuée, pas relayée).
|
||||
- **Asymétrie assumée** : l'organisateur démarre seul, le produit vaut à 100 % en solo ; l'invitation du conjoint arrive au moment naturel (première délégation), jamais exigée.
|
||||
- **Multi-foyers natif** : un compte peut être membre de plusieurs foyers (le sien, celui de ses parents, son bien locatif) — fondation des multiplicateurs.
|
||||
- Continuité long terme : le modèle survit aux séparations, décès, changements de mail (chaque personne contrôle son accès — conformité RGPD).
|
||||
|
||||
---
|
||||
|
||||
## 7. Le moteur d'intelligence
|
||||
|
||||
### 7.1 Les trois sources, par ordre de déploiement (décision : « tout l'API-sable en premier »)
|
||||
|
||||
**Phase 1 — L'inférence par quiz + les détections API**
|
||||
- Le quiz : 6-8 questions factuelles en 2 volets (logement / véhicules) + animaux + papiers. Résultat : 30-50 échéances générées instantanément.
|
||||
- **La plaque d'immatriculation** : un champ → marque, modèle, année, motorisation, Crit'Air, **date réelle du prochain contrôle technique** + préconisations d'entretien du modèle. L'« effet waouh » du quiz. Sources : API agrégateurs SIV, payantes à l'appel (~5-50 centimes selon richesse — HistoVec est une interface publique, pas une API exploitable). Étude des fournisseurs à mener au build.
|
||||
- **L'adresse** : pré-suggestion du logement via l'open data gratuit (DPE ADEME, Base Adresse Nationale, BDNB) — type de chauffage, année, surface. **Toujours en suggestion corrigeable** (« D'après les données publiques : chauffage gaz, maison de 1974 — c'est toujours ça ? »), jamais en vérité imposée : couverture et fraîcheur imparfaites assumées. Une inférence fausse non corrigeable détruirait la confiance.
|
||||
|
||||
**Phase 2 — Le pipeline de compréhension de documents (entrée mail)**
|
||||
- Transfert d'un mail (facture, contrat, attestation) vers l'adresse dédiée du foyer → OCR + LLM → extraction (type, émetteur, date, montant, échéance liée) → **confirmation en un tap** → alimentation de la bannette + mise à jour des fenêtres.
|
||||
- Décision technique ouverte au build : l'endpoint (adresse entrante par foyer via service d'inbound mail, vs upload web, vs les deux).
|
||||
|
||||
**Phase 3 — Le même pipeline, entrée photo**
|
||||
- Scan photo du courrier papier (avis de taxe foncière, convocation d'AG, relance) : capture de l'**imprévisible**, complément du moteur d'inférence qui couvre le prévisible.
|
||||
- Mail entrant et scan partagent ~90 % du code : un seul moteur de compréhension, deux portes d'entrée. Le mail (PDF propres) rode le moteur avant la photo (plus difficile).
|
||||
|
||||
### 7.2 Règles de fonctionnement du moteur
|
||||
- Fenêtres de confiance (§ 4.3), glissement silencieux (§ 4.5), reprogrammation invisible au « fait ».
|
||||
- **Jamais d'automatisme silencieux sur une extraction** : toute donnée extraite d'un document est montrée pour confirmation (« Taxe foncière, 1 247 €, avant le 15 octobre — c'est bien ça ? »). Une date fausse sur un avis d'impôt = crédibilité morte.
|
||||
- Chaque fiche d'échéance de la base porte : règle d'inférence, récurrence, fenêtre par défaut sans info, saisonnalité, enjeu/pourquoi (une ligne), comportement au « fait », comportement de glissement, document attendu et qui peut le réclamer, potentiel d'affiliation, source légale/référence.
|
||||
|
||||
### 7.3 La règle de gratuité des détections (décision validée)
|
||||
**Indexée sur le coût des sources** : ce qui est issu d'API/open data gratuit est offert à tous (l'adresse) ; ce qui repose sur des API payantes est ajusté (la plaque : réglage pressenti = 1 plaque offerte au quiz avec limites anti-abus et cache des résultats, véhicules supplémentaires et re-vérifications au payant — à trancher au vu des prix réels).
|
||||
|
||||
---
|
||||
|
||||
## 8. La bannette (décision révisée — remplace le « carnet »)
|
||||
|
||||
### 8.1 Le principe : on n'archive pas, on indexe
|
||||
- La bannette est la **boîte de réception intelligente du foyer** : un input entre (transfert de mail en phase 2, photo en phase 3), il est **analysé** — l'analyse est un prérequis absolu, pas de bannette sans analyse — et ce qui est conservé est **le sens, pas le fichier**.
|
||||
- Chaque entrée du registre : type de document, émetteur, date(s), montant, échéance/équipement lié, action proposée, statut (confirmé/ignoré), **provenance** (« reçu par mail le 12/09, objet : Facture entretien »).
|
||||
- **Le document brut est jeté après confirmation** (conservation transitoire le temps de l'analyse uniquement).
|
||||
|
||||
### 8.2 Pourquoi ce choix
|
||||
- **Taille disque minimale** (objectif explicite) : le contrat « infra à quelques euros/mois » tient à l'échelle.
|
||||
- **Pas de coffre-fort** : aucune concurrence frontale avec Digiposte ; positionnement limpide (*les autres rangent tes papiers ; nous, on les comprend et on en tire les conséquences*).
|
||||
- **Posture sécurité/RGPD radicalement allégée** : pas de scans de CNI stockés, pas d'archive sensible à protéger à vie. Les papiers d'identité restent servis : photo → extraction de la date d'expiration → échéance créée → image supprimée.
|
||||
|
||||
### 8.3 La parade à la perte d'archive
|
||||
Les moments de valeur historiques du « carnet » (sinistre, garantie, revente) reposaient sur l'original. Réponse : **la provenance**. L'original reste là où il vit déjà (la boîte mail de l'utilisateur) ; le registre est la table des matières qui sait dire où le chercher. Zéro disque, valeur largement préservée. Le registre structuré (historique daté des entretiens, montants, prestataires) garde en propre sa valeur de journal du foyer (revente, suivi, mémoire).
|
||||
|
||||
---
|
||||
|
||||
## 9. Parcours utilisateurs de référence
|
||||
|
||||
### 9.1 Onboarding (Léa, 2 minutes)
|
||||
Landing (via une page SEO ou le bouche-à-oreille) → volet logement (maison/appart, proprio/locataire, chauffage, cheminée, extérieur/piscine — pré-suggéré par l'adresse si fournie) → volet véhicule : **elle tape sa plaque** → « Peugeot 308 de 2019, prochain contrôle technique avant mars 2027 » → animaux (« un chat ») → papiers (« des papiers de +10 ans ? Je ne sais pas » — accepté) → **le hub s'affiche, déjà rempli : 34 échéances sous contrôle**, les 3 prochaines visibles. Choix : export ics gratuit / activer la veille (abo mensuel).
|
||||
|
||||
### 9.2 L'année type, pilier par pilier
|
||||
- **Maison** (septembre) : mail « Ta chaudière gaz mérite son entretien annuel avant l'hiver — obligatoire, et ton assurance peut exiger l'attestation. Si c'est déjà fait, dis-le-moi et je me tais. [✓ Fait] [Déjà fait] [3 chauffagistes près de chez toi] ». Un tap → atterrissage sans login → reprogrammation invisible. Octobre : gouttières (glisse en silence si ignoré).
|
||||
- **Véhicules** (janvier) : « Le contrôle technique de la 308 est à faire avant le 12 mars — 135 € d'amende sinon. Les délais s'allongent en février. [Prendre rdv] [Fait] ». Date réelle issue de la plaque, zéro saisie.
|
||||
- **Papiers** (mars) : « Le passeport de Jules (valable 5 ans seulement pour un enfant) expire en novembre. Certains pays exigent 6 mois de validité : pour partir cet été, c'est maintenant — les rdv mairie prennent 2-4 mois. [Trouver un créneau] ». La valeur = date − délai − règle des 6 mois.
|
||||
- **Animaux** (avril) : « Saison des tiques : antiparasitaire du chat à reprendre. » (juin, déclenché par les vacances) : « Vous partez cet été ? Les gardes se réservent tôt. »
|
||||
- **La bannette** (phase 2-3) : l'avis de taxe foncière arrive → transfert de mail ou photo → « Taxe foncière 2027, 1 247 €, avant le 15 octobre — c'est bien ça ? [Oui] » → registre alimenté, échéance créée, rappel J-7, suggestion mensualisation. L'original reste dans la boîte mail, la provenance est tracée.
|
||||
|
||||
### 9.3 Le conjoint
|
||||
Léa assigne le CT à Thomas → « Thomas n'est pas encore là — l'inviter ? » → lien magique, 20 secondes → il est membre égal du foyer et reçoit **directement** ses rappels. Métrique vitale : le taux de foyers à 2+ membres (assurance anti-churn).
|
||||
|
||||
### 9.4 Le cycle de facturation (mensuel)
|
||||
Chaque mois, le prélèvement arrive **avec sa preuve** : le récap mensuel (« ce mois-ci : 3 échéances surveillées, 1 rappel envoyé, rien n'a reposé sur ta mémoire »). Le récap n'est pas un nice-to-have : c'est l'organe vital de la rétention d'un produit conçu pour être oublié et facturé au mois.
|
||||
|
||||
---
|
||||
|
||||
## 10. Modèle économique
|
||||
|
||||
### 10.1 Structure : freemium, palier unique, mensuel (décisions validées)
|
||||
- **Gratuit — le savoir instantané** : le quiz complet + détections open data (adresse) + 1 plaque offerte (avec limites anti-abus) + le **hub figé** (on voit ses 30-45 échéances : la démonstration est faite) + l'**export .ics statique** (l'objet viral et généreux).
|
||||
- **Payant — la garde + le registre** : ~**4-5 €/mois par foyer**, palier unique. Inclut : la veille active (rappels d'action par mail, « fait »/« déjà fait », reprogrammation, glissement), le partage conjoint, le récap mensuel, le flux ics vivant, puis (phase 2+) la bannette et la compréhension de documents, les plaques supplémentaires.
|
||||
- **La frontière obéit à un double filtre** :
|
||||
1. *Filtre coût* : tout ce qui a un coût variable réel (LLM, plaques au-delà de la première) est payant — le gratuit ne doit jamais pouvoir coûter de l'argent en scalant.
|
||||
2. *Filtre valeur* : parmi ce qui ne coûte rien, la **garde** (rappels actifs, reprogrammation, partage, récap, flux vivant) reste payante — un mail coûte zéro mais c'est le cœur de la valeur.
|
||||
- Le curseur exact (goûter de veille en gratuit ? essai limité ?) est un **paramètre à tuner par l'expérimentation**, pas un dogme. La grille détaillée gratuit/inclus est un chantier de build.
|
||||
- Évolutions prévues : option annuelle à tarif réduit ajoutée plus tard **comme outil de rétention** (une décision de renouvellement par an, adossée au récap annuel) ; palier supérieur éventuel porté par les multiplicateurs (multi-foyers, multi-biens) — non tranché, à revisiter en an 2.
|
||||
|
||||
### 10.2 L'affiliation (décision : en opportunité, une fois la base constituée)
|
||||
- **Règles non négociables** : uniquement dans les rappels où l'action sert l'utilisateur (jamais de push commercial hors échéance) ; toujours affichée (« lien partenaire — c'est ce qui finance la veille ») ; proportionnelle à la confiance (surtout les contrats renseignés).
|
||||
- **Dès le jour 1** : les rappels embarquent leurs liens d'action **neutres** (comparateurs, annuaires, prise de rdv). L'emplacement et l'habitude existent ; basculer en lien commissionné plus tard est trivial.
|
||||
- Gisements par rendement décroissant : assurance emprunteur (loi Lemoine, commissions élevées), assurances habitation/auto (loi Hamon), énergie (fins de prix fixe), contrôle technique (prise de rdv), chauffagiste/ramoneur (mise en relation), assurance animale.
|
||||
- Ordre de grandeur : 20-60 € par bascule d'assurance ; potentiel de +30-50 % du revenu abonnements à base constante.
|
||||
|
||||
### 10.3 La licence B2B2C (an 3, après preuve de rétention)
|
||||
La base de connaissance + le moteur se licencient : **agents immobiliers et notaires** (offrir le calendrier du logement au nouveau propriétaire — moment de vie parfait), **assureurs habitation** (un client rappelé de son entretien sinistre moins — intérêt direct à financer la veille), **constructeurs** (le canal qui a fait les 130 000 foyers de Mon Suivi Logement). Modèle : X € par foyer activé ou marque blanche. C'est le potentiel au-delà du plafond B2C solo.
|
||||
|
||||
### 10.4 Ce que le modèle exclut
|
||||
La publicité (incompatible marque + volume), le freemium à compteurs mesquins (tuerait la viralité de l'export), l'Open Banking (coût, friction, anxiété — l'inférence par quiz + la bannette le rendent inutile).
|
||||
|
||||
### 10.5 Équation économique cible
|
||||
- Objectif : 3 000 €/mois de MRR → **650-750 foyers payants** à 4-5 €/mois (l'affiliation réduisant le besoin).
|
||||
- Avec 2-4 % de conversion quiz → abonné : **20-40 000 quiz complétés** cumulés nécessaires.
|
||||
- Churn : en mensuel, chaque point compte — garde-fou à ~7-8 %/mois (au-delà, le churn prend temporairement le volant, cf. § 12).
|
||||
- Point mort (infra + outils) : ~150-200 foyers.
|
||||
- Horizon réaliste : 18-30 mois, porté par le SEO.
|
||||
- Coûts variables à surveiller : API plaque (centimes/appel — coût d'acquisition borné), LLM (centimes/document — réservé au payant), mails (négligeable), stockage (minimal par design de la bannette).
|
||||
|
||||
---
|
||||
|
||||
## 11. Acquisition
|
||||
|
||||
### 11.1 Réalisme viral
|
||||
La boucle virale native est **faible** (l'invitation conjoint touche 1 personne du même foyer → rétention, pas croissance). La croissance repose sur le contenu et le bouche-à-oreille. Décision : **SEO dès le MVP, TikTok en option ultérieure.**
|
||||
|
||||
### 11.2 Le SEO longue traîne — canal n°1
|
||||
- **La base de connaissance est bicéphale** : chaque échéance documentée = une fiche moteur (règles, fenêtres, messages) + une page publique SEO (« Ramonage : obligation, fréquence, prix, sanctions » → CTA quiz). Rédiger la base, c'est produire le site — un seul chantier.
|
||||
- Territoire vaste et faiblement disputé : « périodicité ramonage », « validité CNI », « quand contrôle technique », « courroie de distribution quand », « passeport enfant durée », « loi montagne pneus départements »...
|
||||
- ~30 pages au MVP, extension continue. Délai incompressible : 6-12 mois avant traction — d'où le démarrage immédiat.
|
||||
|
||||
### 11.3 Les autres leviers
|
||||
- **L'objet viral** : le résultat du quiz (« les 34 échéances de ton foyer ») et l'export .ics gratuit, conçus pour être montrés/partagés.
|
||||
- **Le bouche-à-oreille du récap** (« ce truc m'a évité 135 € d'amende ») et la signature « par le créateur de Kankwa » + passerelles discrètes depuis l'audience Kankwa.
|
||||
- **TikTok** (option ultérieure) : registre « La vie d'orga » version foyer — le passeport enfant expiré à l'aéroport, la courroie oubliée, la contre-visite. Aléatoire mais capable de pics.
|
||||
- Portes verticales : chaque canal cible une douleur précise, tous convergent vers le quiz universel (cf. § 3.1).
|
||||
|
||||
---
|
||||
|
||||
## 12. Roadmap validée
|
||||
|
||||
### Phase 0 — Le test de désirabilité (avant toute construction produit)
|
||||
Landing + quiz réel (avec détections API) + hub figé + **prix mensuel affiché**. Objectif : mesurer l'étoile polaire (conversion) pour un coût quasi nul. C'est l'hypothèse la plus incertaine du projet (« les gens paieront-ils ? ») : on la teste en premier.
|
||||
|
||||
### MVP — La veille seule
|
||||
- Le quiz + détections API (adresse open data pour tous, 1 plaque offerte).
|
||||
- Le hub figé gratuit + export .ics statique.
|
||||
- L'abonnement mensuel : rappels d'action par mail, « fait »/« déjà fait », reprogrammation et glissement, partage conjoint (liens magiques, membres égaux, assignation), récap mensuel, flux ics vivant.
|
||||
- Les liens d'action neutres dans chaque rappel.
|
||||
- ~30 pages SEO issues de la base de connaissance.
|
||||
- L'infrastructure mail (domaine dédié, SPF/DKIM/DMARC, montée en volume progressive).
|
||||
|
||||
### Phase 2 — Le pipeline de compréhension + la bannette
|
||||
- Mail entrant (endpoint à trancher) → OCR/LLM → extraction → confirmation → registre avec provenance, document brut jeté.
|
||||
- Les contrats se renseignent par transfert de PDF.
|
||||
- Extension de la base de connaissance et des pages SEO en continu.
|
||||
|
||||
### Phase 3 — Extensions
|
||||
- Scan photo du courrier papier (même pipeline, entrée caméra).
|
||||
- Multiplicateurs : foyer des parents, bien locatif (+ réflexion palier de prix supérieur).
|
||||
- Affiliation activée en opportunité (bascule des liens neutres).
|
||||
- Option annuelle en rétention. SMS critique éventuel. Push PWA éventuel.
|
||||
- Pré-étude licence B2B2C.
|
||||
|
||||
---
|
||||
|
||||
## 13. Métriques et pilotage
|
||||
|
||||
### 13.1 L'étoile polaire (décision : fixe)
|
||||
**La conversion quiz complété → abonné.** C'est elle qui arbitre les priorités : toute feature, toute page, toute semaine de travail se juge à sa capacité à transformer un curieux en payant.
|
||||
|
||||
### 13.2 Le garde-fou
|
||||
**Le churn mensuel** : s'il dépasse ~7-8 %/mois, il prend temporairement le volant (le produit convertit mais ne tient pas sa promesse dans la durée → priorité au récap, à la pertinence des rappels, à l'invitation du conjoint).
|
||||
|
||||
### 13.3 Le tableau de bord complet
|
||||
1. Conversion quiz → abonné (étoile polaire).
|
||||
2. Churn mensuel (garde-fou).
|
||||
3. Taux de réaction aux rappels (« fait »/« déjà fait »/action cliquée — la preuve que la garde sert ; indicateur avancé du churn).
|
||||
4. Taux de foyers à 2+ membres (assurance anti-churn).
|
||||
5. Délivrabilité et taux d'ouverture des mails (infrastructure vitale).
|
||||
6. Trafic SEO → quiz complétés (le moteur d'acquisition).
|
||||
7. (Plus tard) Revenu d'affiliation par abonné actif ; MRR en objectif de fond, non piloté directement.
|
||||
|
||||
### 13.4 Rappel philosophique
|
||||
DAU, sessions et temps passé seront volontairement mauvais : c'est conforme. La valeur se mesure en échéances honorées et en euros économisés, pas en engagement.
|
||||
|
||||
---
|
||||
|
||||
## 14. Synthèse de l'audit de marché
|
||||
|
||||
### 14.1 La demande
|
||||
- Charge mentale : 88 % des Français touchés (OpinionWay 2022) ; portée par une seule personne dans ~80 % des couples (IFOP 2021) ; 8 femmes sur 10 concernées (Ipsos 2026) ; 91 % des parents la ressentent, 44 % en souffrent régulièrement (Wecasa/YouGov 2024).
|
||||
- Marché adressable : ~30 M de résidences principales, ~40 M de voitures particulières ; cœur solvable ~8-10 M de foyers propriétaires multi-équipés.
|
||||
- **Nuance clé** : une douleur documentée n'est pas un marché solvable — la solvabilité est validée par les points de prix constatés (cf. 14.3), pas par les sondages.
|
||||
|
||||
### 14.2 Les sept fronts concurrentiels (état juillet 2026)
|
||||
1. **Organiseurs familiaux** (Cozi ~400 k$/mois après 15 ans + pub ; FamilyWall ; MyFamiliz 2,99 €/mois ; Mental Loadless 3,99-19,99 €/mois, hébergé France, Balance Score + IA ; Share(d), 3,7 M€ levés) : le flux quotidien — l'anti-territoire du produit. Leçon : le B2C organiseur payant est un cimetière ; ne jamais y glisser.
|
||||
2. **Contrats/abonnements** (Origame : gratuit, commission à la bascule, détection Open Banking, 560 €/an d'économies moyennes revendiquées ; banques avec détection d'abonnements) : gratuit financé par l'affiliation = la norme du front. Valide l'étage affiliation ; interdit un abo « contrats seuls ».
|
||||
3. **Véhicule** (Drivvo, My Garage, Simply Auto, CarFul — préconfiguration par carte grise —, Odopass — historique pour la revente —, CodeNekt — gratuit financé par les garages) : fragmenté, sans gagnant, orienté passionnés. L'inférence existe en vertical ; jamais reliée au foyer, jamais pour les distraits.
|
||||
4. **Maison/CIL** (CIL obligatoire logements neufs depuis 01/2023 ; Mon Suivi Logement 130 000+ foyers en B2B2C constructeurs ; Hommy/Leroy Merlin ; Le bon'home) : distribution verrouillée par les pros du bâtiment ; **l'acquisition directe grand public est libre**.
|
||||
5. **Coffre-fort numérique** (Digiposte/La Poste : des millions à plus de 10 M d'utilisateurs selon les sources, Premium ~39,99 €/an, hébergé France, valeur probante ; Arkevia/Crédit Agricole ; LockSelf) : **valide le point de prix (~40 €/an) et l'appétence « France/tranquillité »**. Range sans comprendre ni anticiper. Ne jamais se positionner « coffre-fort » (d'où la bannette).
|
||||
6. **Scan IA de courrier — front émergent** (MyAdmin IA : OCR + Mistral, adresse mail dédiée, Premium 6,90 €/mois ; SOS Papier : freemium, 4,99 €/mois ou 39,99 €/an ; Importemps : analyse d'un courrier + brouillon de réponse) : micro-acteurs, tous **réactifs** et **sans structure** (pas de foyer, pas de moteur proactif, pas de registre lié aux échéances). Valide le besoin et le prix du pipeline ; impose un tempo (la fenêtre se referme) ; confirme que la différenciation est l'assemblage, jamais le scan seul.
|
||||
7. **IA générique** (ChatGPT, Claude...) : sait générer la liste des échéances gratuitement ; ne monte pas la garde. La défense = l'exécution durable (notifications fiables sur des années, reprogrammation, registre, délégation) + le pipeline + la base maintenue.
|
||||
|
||||
### 14.3 La lecture stratégique
|
||||
- Deux axes : *réactif vs proactif* × *vertical vs foyer entier*. **Tous les acteurs sont réactifs et/ou verticaux. Le quadrant proactif × foyer entier est vide.**
|
||||
- Chaque composant du produit est validé séparément par un acteur existant : le prix par Digiposte et SOS Papier (~40-60 €/an), l'inférence par CarFul, la monétisation par Origame, la distribution B2B2C par Mon Suivi Logement, le pipeline par les indés du scan. **Personne n'a fait l'assemblage.** Le risque n'est pas « est-ce que ça se vend » mais « qui assemble le premier ».
|
||||
- Le transversal se vend mal en une requête Google → marketing par portes verticales (acté § 3.1 et § 11).
|
||||
- **Verdict : potentiel 8/10** — le meilleur ratio effort/revenu/défendabilité des pistes explorées, avec une contrainte de tempo réelle.
|
||||
|
||||
### 14.4 Précédents de modèle
|
||||
- Cozi prouve qu'un organiseur peut générer plusieurs M$/an (mais en 15 ans, avec pub). Digiposte prouve la solvabilité de masse à ~40 €/an sur la tranquillité administrative. Ohai.ai (US, 6 M$ levés, 25 $/mois avec assistants humains) prouve qu'on paie cher un service qui *pense à ta place* — le produit se situe dans l'espace de prix intermédiaire, avec l'anticipation sans le coût humain.
|
||||
|
||||
---
|
||||
|
||||
## 15. Risques et parades (par gravité)
|
||||
|
||||
1. **La conversion gratuit → payant ne décolle pas** (l'hypothèse existentielle). *Parade : la phase 0 la teste avant toute construction ; le double filtre gratuit/payant est tunable ; l'effet plaque maximise la démonstration.*
|
||||
2. **Le churn silencieux du mensuel** (produit conçu pour être oublié + prélèvement mensuel). *Parade : le récap mensuel systématique comme preuve de valeur ; l'invitation du conjoint (2 ancres > 1) ; l'option annuelle en rétention ; le garde-fou à 7-8 %.*
|
||||
3. **La vitesse des indés du scan IA** (SOS Papier & co pourraient ajouter du proactif). *Parade : le moteur d'inférence et la structure foyer sont longs à copier ; exécuter vite ; ne jamais réduire le pitch au scan.*
|
||||
4. **L'entrée d'un gros à 3-5 ans** (La Poste ajoutant des rappels à Digiposte, une banque, un assureur). *Parade : la vitesse, la niche transversale, l'angle indépendant (pas de conflit d'intérêt sur les résiliations) ; à terme, être l'acteur qu'on licencie plutôt que celui qu'on écrase.*
|
||||
5. **La confiance sur l'extraction et les inférences** (une date fausse = crédibilité morte). *Parade : confirmation systématique en un tap, pré-suggestions toujours corrigeables, jamais d'automatisme silencieux.*
|
||||
6. **La délivrabilité mail** (un rappel en spam = promesse rompue). *Parade : infrastructure soignée dès le MVP (§ 6.2), monitoring permanent.*
|
||||
7. **Le coût éditorial permanent de la base** (ZFE, lois, périodicités qui changent). *Parade : l'assumer comme le cœur du métier — c'est le coût ET la barrière ; mutualisé avec le SEO (base bicéphale).*
|
||||
8. **L'exemplarité données** (courriers fiscaux et personnels via API LLM tierce). *Parade : fournisseur choisi pour la conformité (option UE), zéro conservation chez le tiers, document brut jeté (design bannette), transparence totale — un sujet de marque autant que de technique.*
|
||||
9. **Le feature creep** (les utilisateurs demanderont une to-do list, du stockage, du quotidien). *Parade : la constitution du § 4 et les refus du § 2.3 — ce document.*
|
||||
|
||||
---
|
||||
|
||||
## 16. Exigences transverses
|
||||
|
||||
- **Hébergement France** (doctrine MIAW), RGPD by design (comptes individuels, suppression réelle, minimisation par la bannette).
|
||||
- **Sécurité** : liens magiques à durée limitée, chiffrement en transit et au repos pour le peu qui est stocké, pas de mots de passe à protéger, surface minimale par design (pas d'archive de documents).
|
||||
- **Charge serveur minimale** (contrat fondateur) : données textuelles structurées, pas de stockage de fichiers, PWA statique + API légère, mails. Coûts variables bornés (plaque, LLM) et adossés au payant.
|
||||
- **Accessibilité et simplicité** : le hub doit être utilisable par le conjoint le moins motivé du foyer.
|
||||
|
||||
---
|
||||
|
||||
## 17. Décisions ouvertes (à trancher au build, hors cadrage)
|
||||
|
||||
| Sujet | Options | Échéance |
|
||||
|---|---|---|
|
||||
| Le nom et l'identité visuelle | Chantier dédié marque sœur MIAW | Avant phase 0 |
|
||||
| Fournisseur API plaque et coût réel | Étude comparative des agrégateurs SIV | Phase 0 |
|
||||
| Réglage exact de la gratuité plaque | 1 offerte / limites IP / cache | Phase 0 |
|
||||
| Grille détaillée gratuit vs inclus | Tuning sur le double filtre coût/valeur | MVP puis itératif |
|
||||
| Endpoint de la bannette | Adresse entrante par foyer / upload web / les deux | Phase 2 |
|
||||
| Fournisseur LLM du pipeline | Critères : conformité UE, zéro conservation, coût | Phase 2 |
|
||||
| Palier de prix supérieur (multi-foyers) | Avec les multiplicateurs | An 2 |
|
||||
| Structure juridique de l'affiliation | Plateformes vs partenariats directs | En opportunité |
|
||||
|
||||
---
|
||||
|
||||
## 18. Les 14 décisions de cadrage (référence rapide)
|
||||
|
||||
1. **Cible** : produit universel, acquisition par portes verticales.
|
||||
2. **Périmètre** : 4 piliers (Maison, Véhicules, Papiers, Animaux) ; contrats intégrés aux piliers en opt-in renseigné ; refus définitifs maintenus.
|
||||
3. **Philosophie** : gravée intégralement (règle d'or, inférence, « je ne sais pas », silence, glissement, ton majordome).
|
||||
4. **Canaux** : hub web (PWA) destination ; mail seul moteur au MVP ; ics en option.
|
||||
5. **Comptes** : foyer objet central, membres égaux, liens magiques, un abonnement par foyer, multi-foyers natif.
|
||||
6. **Détection** : phase 1 = tout l'API-sable (adresse gratuite pour tous, plaque selon coût) ; puis mail entrant ; puis scan photo. Gratuité indexée sur le coût des sources.
|
||||
7. **Mémoire** : la bannette, pas le coffre — analyse prérequise, conservation du sens + provenance, jamais du fichier brut.
|
||||
8. **Gratuit/payant** : double filtre coût + valeur ; gratuit = savoir instantané, payant = garde + registre ; curseur à tuner.
|
||||
9. **Prix** : palier unique, mensuel au lancement (~4-5 €/mois par foyer) ; récap mensuel comme preuve ; annuel plus tard en rétention.
|
||||
10. **Affiliation** : en opportunité une fois la base constituée ; liens d'action neutres dès le jour 1.
|
||||
11. **Marque** : sœur sous MIAW, « par le créateur de Kankwa ».
|
||||
12. **Acquisition** : SEO dès le MVP (base de connaissance bicéphale) ; TikTok en option.
|
||||
13. **Roadmap** : Phase 0 (test de désirabilité) → MVP (veille seule) → Phase 2 (pipeline + bannette) → Phase 3 (scan, multiplicateurs, affiliation, annuel).
|
||||
14. **Pilotage** : étoile polaire = conversion quiz → abonné (fixe) ; churn en garde-fou (~7-8 %/mois).
|
||||
|
||||
---
|
||||
|
||||
# PARTIE II — Architecture technique de référence
|
||||
*Complément au cadrage produit · Décisions validées point par point le 22 juillet 2026*
|
||||
|
||||
---
|
||||
|
||||
## 19. Exigences et audit des approches
|
||||
|
||||
### 19.1 Les trois exigences fondatrices
|
||||
1. **Un nouveau pilier réutilise les schémas et écrans existants** — ajouter un pilier ne doit demander aucun nouveau code (ni table, ni endpoint, ni écran).
|
||||
2. **L'enrichissement de l'intelligence génère automatiquement les rappels** — publier une nouvelle fiche de connaissance déploie l'échéance chez tous les foyers concernés, sans migration ni action utilisateur.
|
||||
3. **La base de connaissance est générique et évolutive** — déclarative, versionnée, avec une sémantique de migration définie.
|
||||
|
||||
### 19.2 Approches auditées et rejetées
|
||||
- **A. Modélisation par pilier** (tables/modèles `maison`, `vehicule`...) : rapide au début, condamnée ensuite — chaque pilier = migrations + code + écrans. Viole l'exigence 1. *Rejetée.*
|
||||
- **B. Ultra-générique EAV/graphe** (tout en entité-attribut-valeur) : requêtes illisibles, intégrité impossible, perfs médiocres. La généricité doit être au bon niveau, pas partout. *Rejetée.*
|
||||
- **C. Moteur de règles du commerce / workflow engine** (Drools, Temporal, Camunda) : la bonne idée conceptuelle (règles = données) mais infra lourde et courbe d'apprentissage injustifiées pour un domaine « récurrences + fenêtres » porté par un solo. *Rejetée, patterns retenus.*
|
||||
- **D. LLM à l'exécution** (l'IA évalue quelles échéances s'appliquent) : non-déterministe (deux foyers identiques → résultats différents), coûteux à chaque évaluation, non-testable. *Rejetée — et gravée en règle : le LLM vit aux frontières (pipeline documents), jamais dans le moteur d'échéances, qui est 100 % déterministe.*
|
||||
- **E. Ontologie à 3 couches + moteur de réconciliation déclaratif** : le pattern des systèmes qui ont exactement ce problème — un état *désiré* défini par des règles versionnées, réconcilié avec un état *réel* contenant de l'historique utilisateur (Kubernetes, Terraform, React). ***Retenue.***
|
||||
|
||||
### 19.3 Les invariants d'architecture (gravés)
|
||||
1. **IDs de templates stables à vie** — un identifiant publié ne change jamais de sens.
|
||||
2. **Réconciliation idempotente** — rejouable à volonté sans effet de bord.
|
||||
3. **État utilisateur inviolable** — les « fait », mute, responsables, dates apprises survivent à toute évolution de la base.
|
||||
4. **Séparation stricte des 3 couches** — Connaissance / Instance / Occurrences.
|
||||
5. **LLM aux frontières uniquement** — le cœur est déterministe et testable.
|
||||
6. **Contenu multi-canal dans le template** — mail, hub et SEO issus de la même fiche (bicéphalie structurelle).
|
||||
7. **Un pilier = de la configuration, jamais du code.**
|
||||
|
||||
---
|
||||
|
||||
## 20. Le pattern : ontologie 3 couches + réconciliation
|
||||
|
||||
### 20.1 Couche 1 — Connaissance (les templates, la valeur du produit)
|
||||
La base de connaissance est un ensemble de **`DeadlineTemplates` déclaratifs** : des fichiers de données (YAML + blocs MDX pour les contenus longs), versionnés dans Git, **jamais du code**. Format de référence :
|
||||
|
||||
```yaml
|
||||
id: chaudiere-gaz-entretien # identifiant STABLE à vie
|
||||
version: 3 # incrémenté à chaque modification
|
||||
pilier: maison
|
||||
applicabilite: # prédicat sur les attributs de l'asset
|
||||
asset_type: logement
|
||||
when: { chauffage: "gaz" }
|
||||
recurrence:
|
||||
rrule: "FREQ=YEARLY"
|
||||
fenetre_saisonniere: [sept, oct]
|
||||
fenetre_defaut: prochaine_saison # comportement si « je ne sais pas »
|
||||
glissement: silencieux # comportement si non traité
|
||||
enjeu:
|
||||
type: legal+assurance
|
||||
resume: "Obligatoire ; attestation exigible par l'assurance en cas de sinistre."
|
||||
contenus: # bicéphale par construction
|
||||
mail: { objet: ..., corps: ... }
|
||||
hub: { titre: ..., description: ... }
|
||||
seo: { slug: periodicite-entretien-chaudiere-gaz, page: ./chaudiere-gaz.mdx }
|
||||
actions:
|
||||
- label: "Chauffagistes près de chez toi"
|
||||
slot_affiliation: chauffagiste # lien neutre au MVP, commissionné plus tard
|
||||
document_attendu: attestation_entretien # pour la bannette (phase 2)
|
||||
reclamable_par: [assureur]
|
||||
source: "Décret 2009-649"
|
||||
```
|
||||
|
||||
- **Le DSL d'applicabilité** : prédicats simples sur les attributs d'assets — égalité, comparaison, présence/absence, appartenance à une liste, combinaisons ET/OU. Volontairement minimal.
|
||||
- **La soupape des stratégies nommées** : pour les ~2 % de règles réellement complexes (ex. courroie de distribution : km ET années, par modèle), le template référence une stratégie implémentée en code (`strategy: courroie_distribution`). La généricité a une soupape, pas une religion.
|
||||
- **Champs portés par chaque fiche** (rappel § 7.2) : règle d'inférence/applicabilité, récurrence, fenêtre par défaut sans info, saisonnalité, enjeu/pourquoi en une ligne, comportement au « fait », comportement de glissement, contenus multi-canaux, actions et slots d'affiliation, document attendu et qui peut le réclamer, source légale.
|
||||
|
||||
### 20.2 Couche 2 — Instance (le foyer)
|
||||
Deux entités génériques suffisent à tous les piliers présents et futurs :
|
||||
- **`Asset`** : `type` + attributs en JSONB. Un logement, un véhicule, un animal, un document d'identité, un contrat sont des assets. **Un pilier n'est pas une table : c'est un `asset_type`.**
|
||||
- **`Deadline`** : `template_id` + `asset_id` + l'état *utilisateur* — fenêtre courante, niveau de confiance de la date, responsable (membre assigné), muet (« plus jamais ça »), contrat renseigné le cas échéant.
|
||||
- Le **`Foyer`** agrège assets, membres (égaux), deadlines et l'abonnement. Un compte peut appartenir à plusieurs foyers (multiplicateurs an 2 natifs).
|
||||
|
||||
### 20.3 Couche 3 — Occurrences (la timeline, append-only)
|
||||
- Chaque cycle d'une deadline = une **`Occurrence`** : fenêtre due, statut (à venir / fait / déjà fait / glissé), source de la date (inférée, apprise, détectée, extraite).
|
||||
- Les envois de notifications y sont **journalisés** (idempotence des rappels : jamais deux fois le même).
|
||||
- Le **registre de la bannette** (phase 2) s'y branche : une entrée = un événement documentaire (type, émetteur, dates, montant, action, provenance) rattaché à un asset et/ou une occurrence. Conforme au § 8 : le sens est conservé, jamais le fichier.
|
||||
|
||||
### 20.4 Le moteur de réconciliation — la pièce maîtresse
|
||||
Un job **idempotent** qui, pour chaque foyer :
|
||||
1. calcule l'**état désiré** = `templates actifs × assets du foyer` (évaluation des prédicats d'applicabilité) ;
|
||||
2. le **diffe** avec l'état existant ;
|
||||
3. applique les transitions :
|
||||
|
||||
| Situation | Action du moteur |
|
||||
|---|---|
|
||||
| Template nouvellement applicable (nouvel asset OU nouvelle fiche publiée) | Création de la deadline + première occurrence (fenêtre par défaut) |
|
||||
| Template modifié (version incrémentée) | Mise à jour des occurrences **futures uniquement** ; l'historique est intouché |
|
||||
| Template déprécié | Archivage **silencieux** des deadlines (pas de notification, pas de trace visible) |
|
||||
| Asset supprimé/modifié rendant le prédicat faux | Archivage silencieux des deadlines liées |
|
||||
| Deadline mutée par l'utilisateur (« plus jamais ça ») | **Jamais recréée**, quelles que soient les évolutions de la base |
|
||||
|
||||
**Règle sacrée** : l'état utilisateur est toujours préservé (invariant 3). C'est ce mécanisme qui réalise l'exigence 2 : *publier une fiche « détartrage chauffe-eau » dans le repo → à la réconciliation suivante, tous les foyers concernés ont l'échéance.* Enrichir la connaissance = enrichir le produit, sans migration, sans code, sans action utilisateur.
|
||||
|
||||
**Déclencheurs** : publication d'une version de la base (batch global) · mutation d'un foyer (quiz, ajout/modif d'asset, réponse à un rappel) · passage quotidien de sécurité. **Un batch quotidien suffit** (les échéances se jouent à +2 semaines) : pas d'event-driven, pas de temps réel, pas de complexité inutile.
|
||||
|
||||
### 20.5 Le scheduler de notifications
|
||||
Job quotidien : occurrences entrant en fenêtre de rappel → application du **budget notifications** (regroupement « 3 choses ce mois-ci », priorisation légal > confort, plafond mensuel — *le budget est une politique du moteur, pas de chaque template*) → écriture en **outbox** → envoi via l'adapter mail → journalisation. Idempotent et rejouable : un cron manqué se rattrape sans doublon.
|
||||
|
||||
### 20.6 Quiz et formulaires : déclaratifs aussi
|
||||
Chaque `asset_type` définit son **schéma d'attributs (JSON Schema/Zod)** et ses **questions de quiz** ; le front les rend génériquement (composants de formulaire pilotés par le schéma). **Ajouter un pilier = ajouter des fichiers** (un asset_type + ses questions + ses templates) — zéro écran, zéro endpoint nouveau (exigence 1).
|
||||
|
||||
### 20.7 SEO : le même repo
|
||||
Les blocs `contenus.seo` + fichiers MDX des templates génèrent les pages statiques (SSG Next). Rédiger une fiche produit simultanément la règle moteur ET la page d'acquisition — la bicéphalie du § 11.2 est structurelle, pas une bonne intention.
|
||||
|
||||
### 20.8 Les adapters — frontières du système
|
||||
Le cœur (moteur + scheduler) ne connaît aucun service externe. Tout passe par des adapters aux contrats stricts :
|
||||
- **Détection plaque** : entrée = plaque ; sortie = attributs d'asset véhicule + dates apprises (CT). Fournisseur interchangeable.
|
||||
- **Détection adresse** : entrée = adresse ; sortie = attributs *suggérés* de logement (jamais imposés). Sources open data (DPE ADEME, BAN, BDNB).
|
||||
- **Envoi mail** : entrée = message composé (React Email) ; sortie = statut d'envoi. **Le plan B (routeur SaaS) est à un fichier de config** (cf. § 26.2).
|
||||
- **Pipeline LLM** (phase 2) : entrée = document (mail entrant, puis photo) ; sortie = événement de bannette candidat (extraction à confirmer) + attributs. LLM local par défaut, repli API conforme documenté.
|
||||
- **Paiement** : abonnement du foyer (webhooks à retries — tolérants à l'indispo, cf. § 26.1).
|
||||
|
||||
---
|
||||
|
||||
## 21. Modèle de données (schéma de référence)
|
||||
|
||||
Tables principales (PostgreSQL, via Drizzle) :
|
||||
|
||||
| Table | Rôle | Champs clés |
|
||||
|---|---|---|
|
||||
| `users` | Comptes légers | email, credentials (hash) ; sessions better-auth |
|
||||
| `households` | Le foyer, objet central | abonnement (statut, période), réglages |
|
||||
| `memberships` | Lien user→foyer | rôle unique « membre » (égalité), préférences de notification **par personne** |
|
||||
| `assets` | Instances génériques | `household_id`, `asset_type`, `attributes JSONB`, provenance (quiz/détection/bannette) |
|
||||
| `deadlines` | Échéance instanciée | `template_id` (+ version appliquée), `asset_id`, responsable, confiance, muted, état contrat |
|
||||
| `occurrences` | Timeline append-only | `deadline_id`, fenêtre due, statut, source de date, timestamps |
|
||||
| `notifications_log` | Journal d'envois (outbox) | occurrence, canal, statut, message-id — idempotence |
|
||||
| `bannette_entries` (phase 2) | Le registre | type, émetteur, dates, montant, `asset_id`/`occurrence_id`, action, statut, **provenance** ; *aucun blob* |
|
||||
| `knowledge_versions` | Versions de base déployées | hash de la release, date, stats de réconciliation |
|
||||
|
||||
Notes : attributs d'assets et contenus en **JSONB** (souplesse) sur socle **relationnel** (intégrité foyer→deadline→occurrence) — le meilleur des deux mondes, pas de NoSQL. Index sur `(household_id)`, `(asset_type)`, prédicats JSONB fréquents (GIN ciblé), `(deadline_id, fenêtre)` pour le scheduler. Volumétrie cible triviale : 10 000 foyers × 40 échéances = quelques centaines de milliers de lignes — le batch quotidien est un calcul négligeable.
|
||||
|
||||
---
|
||||
|
||||
## 22. Stack applicative (décisions 1-2 validées)
|
||||
|
||||
**TypeScript de bout en bout** — choix motivé par : un seul langage partout (vélocité solo, zéro changement de contexte), les types comme colonne vertébrale de la base de connaissance (fiches validées par des schémas partagés entre moteur, quiz, mails et SEO : une fiche mal formée casse la CI, pas la prod), l'écosystème le plus actif sur tous les besoins du produit (SSG/SSR, PWA, mails composables, SDK LLM). Alternative Python écartée : avantage IA marginal contre la taxe permanente de deux langages.
|
||||
|
||||
| Brique | Choix | Justification |
|
||||
|---|---|---|
|
||||
| Framework | **Next.js (App Router), unique** | Un framework pour les trois visages : pages SEO statiques (SSG), hub PWA, API. Pas de split Astro+API : deux frameworks = taxe permanente pour un solo |
|
||||
| Base de données | **PostgreSQL + Drizzle ORM** | Relationnel + JSONB ; ORM typé, léger, migrations propres (jouées au déploiement) |
|
||||
| Jobs & cron | **pg-boss** | Files et planification *dans Postgres* — zéro Redis, zéro infra supplémentaire ; réconciliation et scheduler = jobs pg-boss |
|
||||
| Validation | **Zod partout** | Un seul système de schémas : templates, quiz, API |
|
||||
| Mails | **React Email** + adapter d'envoi | Mails d'action = composants typés, testables, prévisualisables |
|
||||
| Base de connaissance | **YAML + MDX dans le repo** | Parsée et validée au build (cf. § 23) |
|
||||
| Front hub | PWA légère (installable, notifications futures) | Pas d'appli native (cadrage § 6.1) |
|
||||
|
||||
---
|
||||
|
||||
## 23. Édition de la base de connaissance (décision 3 validée : Git-only)
|
||||
|
||||
**Workflow** : fiches éditées dans l'IDE → une PR par ajout/modification → CI valide (schémas Zod, stabilité des IDs, incrément de version, rrule valide, non-régression) → merge → build SEO + déclenchement de la réconciliation globale.
|
||||
|
||||
**Arbitrage documenté** (pour mémoire de la décision) :
|
||||
- *Git-only* — pour : zéro dev, validation CI imbattable (une fiche invalide ne peut pas atteindre la prod), historique/diff/rollback natifs, revue de soi-même par la PR, branches = préversions gratuites. Contre : seul le fondateur édite, ergonomie moyenne du contenu long (mitigée par MDX), pas d'édition mobile, prévisualisation à construire.
|
||||
- *Back-office* — pour : édition ouverte (rédacteur, expert, partenaire), ergonomie, corrections rapides. Contre : semaines de dev immédiates pour un besoin hypothétique, validation réimplémentée (moins bien), historique à recoder, surface d'attaque en plus, risque de publication impulsive.
|
||||
- **L'argument décisif** : A est réversible (la source de vérité étant des données validées par schéma, un back-office futur peut devenir un client qui commit), B ne l'est pas. **Critère de bascule** : le jour où quelqu'un d'autre doit éditer régulièrement.
|
||||
|
||||
---
|
||||
|
||||
## 24. Hébergement et exploitation (décision 4 validée : prod locale)
|
||||
|
||||
### 24.1 Le choix et sa justification
|
||||
**Production sur le serveur local existant** (Docker + Caddy natifs, LLM local disponible). Justification spécifique au produit : tolérance exceptionnelle à l'indisponibilité (échéances à +2 semaines, rappels en batch quotidien, rien de temps réel — 6 h de panne = zéro impact ; un cron manqué se rejoue le lendemain, le glissement est dans le design ; les webhooks de paiement ont des retries sur plusieurs jours ; pas de pic de trafic type Kankwa). « Hébergé en France » rigoureusement vrai. Coût nul, souveraineté totale.
|
||||
|
||||
### 24.2 Les 5 mitigations — exigences non négociables
|
||||
1. **Perte de données = seul risque inacceptable** : dumps Postgres quotidiens **chiffrés** vers un stockage objet externe français (S3 type Scaleway), **restauration testée régulièrement**. LA condition.
|
||||
2. **Jamais d'IP résidentielle exposée** : tunnel (Cloudflare Tunnel ou équivalent) **ou petit VPS frontal en reverse proxy** devant Caddy ; surface minimale ; mises à jour disciplinées.
|
||||
3. **Détection de panne externe** : monitoring type UptimeRobot + **heartbeats sur les crons** (« le job quotidien n'a pas pingé » → alerte). Sans ça, le scénario « scheduler planté depuis 5 jours » reste possible.
|
||||
4. **Électricité/réseau** : onduleur ; une coupure box d'une journée est absorbée par le design.
|
||||
5. **Réputation mail indépendante du serveur** : cf. § 26.2 (relais par IP propre).
|
||||
|
||||
### 24.3 La clause de sortie (gravée)
|
||||
Tout est Docker + Postgres standard : migration triviale vers PaaS/VPS. **Seuil de réexamen formel : ~200-300 foyers payants** (des clients qui paient pour de la fiabilité). Alternatives documentées le jour venu : PaaS français (Scalingo, Clever Cloud — ~25-50 €/mois, Postgres managé, zéro ops) ou VPS français + Coolify.
|
||||
|
||||
### 24.4 Le LLM local (phase 2) — l'atout souveraineté
|
||||
Pipeline de la bannette exécuté sur le LLM local : *« vos courriers sont analysés sur nos serveurs, en France, et ne sont jamais transmis à un tiers »* — argument qu'aucun concurrent du scan IA ne peut revendiquer (tous en API Mistral/OpenAI). Résout le risque n°8 du cadrage (exemplarité données), supprime le coût variable par document (détend le filtre coût du gratuit/payant), latence indifférente en batch. **Condition : benchmark de qualité d'extraction** sur documents réels (dates, montants sur factures/avis) avant activation — la confirmation en un tap tolère un peu d'erreur, pas beaucoup. **Repli documenté** : API conforme (option UE, zéro conservation) si la précision ne suit pas.
|
||||
|
||||
---
|
||||
|
||||
## 25. Authentification (décision 5 validée)
|
||||
|
||||
**better-auth auto-hébergé** (pas de service tiers : Clerk/Auth0 = SaaS américain contradictoire avec la posture, coût par utilisateur, dépendance). Modes : **login/mot de passe + liens magiques**. Le mot de passe offre le chemin familier (hachage sérieux, reset — qui repasse par le mail) ; le lien magique reste le véhicule des **invitations au foyer** et de l'**atterrissage sans login** depuis les rappels (liens à durée limitée). Passkeys ajoutables plus tard. Sessions en Postgres.
|
||||
|
||||
---
|
||||
|
||||
## 26. Envoi des mails (décision 6 validée : auto-hébergé sous conditions)
|
||||
|
||||
### 26.1 Le choix
|
||||
Envoi auto-hébergé depuis l'infrastructure, en s'inspirant du setup Kankwa existant. **Différence d'enjeu assumée** : pour Kankwa, une invitation en spam est un désagrément ; ici **le mail EST le produit** — un rappel en spam = la promesse rompue en silence (risque n°6 du cadrage).
|
||||
|
||||
### 26.2 Les trois conditions gravées
|
||||
1. **Jamais d'envoi depuis une IP résidentielle** (plages en PBL Spamhaus par défaut, port 25 souvent bloqué par les FAI). Solution : le **VPS frontal du § 24.2 fait aussi relais SMTP sortant** — IP fixe et propre, PTR/rDNS configuré, réputation constructible. Un composant, deux rôles. (Vérifier le routage réel du setup Kankwa.)
|
||||
2. **Hygiène complète** : SPF, DKIM, DMARC en politique stricte, **domaine d'envoi dédié** (ex. `mail.<domaine>.fr`), montée en volume progressive (warming), List-Unsubscribe même en transactionnel.
|
||||
3. **Monitoring de délivrabilité en métrique de prod** : taux d'ouverture par FAI (postmaster tools Gmail/Outlook/Orange), vérification automatisée de blacklists, alertes. **Plan B à un fichier de config** : l'envoi passe par l'adapter (§ 20.8) — bascule vers un routeur SaaS (Brevo — français — ou Postmark) en une heure en cas de dégradation. Souveraineté par défaut, jamais au prix de la promesse.
|
||||
|
||||
---
|
||||
|
||||
## 27. Organisation du code, CI et déploiement (décision 7 validée)
|
||||
|
||||
- **Monorepo** : app Next + moteur + adapters + base de connaissance (YAML/MDX) ensemble — **la CI valide code ET connaissance d'un même mouvement**.
|
||||
- **CI GitHub Actions** : lint, typecheck, **tests du moteur de réconciliation en priorité absolue** (la pièce la plus critique : suites sur les transitions du § 20.4, l'idempotence, l'inviolabilité de l'état utilisateur), validation Zod des fiches, build.
|
||||
- **Déploiement** : image Docker construite en CI → tirée par le serveur (webhook/SSH) → migrations Drizzle jouées au déploiement.
|
||||
- **Deux environnements** : staging + prod (sur l'infra locale), une branche = une préversion de la base de connaissance testable.
|
||||
|
||||
---
|
||||
|
||||
## 28. Décisions techniques ouvertes (à trancher au build)
|
||||
|
||||
| Sujet | Note | Échéance |
|
||||
|---|---|---|
|
||||
| Fournisseur API plaque | Étude comparative agrégateurs SIV (coût, richesse CT) | Phase 0 |
|
||||
| Choix du tunnel/VPS frontal | Cloudflare Tunnel vs petit VPS FR (qui ferait aussi relais SMTP) | MVP |
|
||||
| Vérification du setup mail Kankwa | IP d'envoi réelle, réputation, réutilisabilité | MVP |
|
||||
| Modèle LLM local + benchmark extraction | Précision dates/montants sur corpus réel ; seuil d'acceptation | Phase 2 |
|
||||
| Endpoint bannette | Adresse entrante par foyer (service inbound) vs upload vs les deux | Phase 2 |
|
||||
| Prestataire de paiement | Stripe vs alternative — webhooks à retries requis | Phase 0/MVP |
|
||||
| Stockage objet des backups | S3 français (Scaleway ou équivalent), chiffrement | MVP |
|
||||
|
||||
---
|
||||
|
||||
*Prochains chantiers d'exécution, dans l'ordre : ① Phase 0 (questions exactes du quiz + landing + prix affiché + étude API plaque) · ② Base de connaissance v1 (~45 fiches au format § 20.1, bicéphales) · ③ Squelette technique (monorepo, schéma Drizzle, moteur de réconciliation + ses tests) · ④ Nom et identité.*
|
||||
17
docs/decisions.md
Normal file
17
docs/decisions.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Journal des décisions post-cadrage
|
||||
|
||||
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-002 — 22 juillet 2026 · Véhicules : API plaque payante self-service (annule D-001)
|
||||
|
||||
**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).
|
||||
|
||||
**Décision** : API plaque payante self-service (~5 c/requête, fournisseur à confirmer par essai comparatif), avec le réglage du § 7.3 du cadrage : **1 plaque offerte par quiz, cache des résultats par plaque, limite anti-abus par IP**. L'utilisateur ne saisit que sa plaque — zéro saisie de documents, priorité confirmée.
|
||||
|
||||
**Date CT** : non fournie par les API self-service → **fenêtre calculée** depuis la date de 1re immatriculation retournée par l'API (exacte pour les < 4 ans, estimée ensuite), affinable en un tap plus tard (ex. lecture optionnelle de la vignette pare-brise). Les contrats B2B « date CT réelle » (AAA Data/Autorigin) restent suspendus, réactivables si le besoin se confirme.
|
||||
|
||||
**Conséquences** : coût variable borné (~quelques euros en Phase 0, ~50-100 €/1 000 quiz) assumé comme coût d'acquisition ; l'effet waouh de la détection est conservé pour le test de désirabilité ; repli déclaratif (marque/modèle/année) pour qui refuse de donner sa plaque.
|
||||
|
||||
## ~~D-001 — 22 juillet 2026 · Véhicules : full gratuit, pas d'API plaque~~ (annulée le jour même par D-002)
|
||||
|
||||
Décision initiale : volet véhicules 100 % déclaratif pour un coût nul. Annulée après vérification : l'utilisateur ne doit pas saisir manuellement, et aucune API gratuite n'existe légalement.
|
||||
68
docs/phase-0/etude-api-plaque.md
Normal file
68
docs/phase-0/etude-api-plaque.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Phase 0 — Étude API plaque & sources de détection
|
||||
|
||||
> **Décision [D-002](../decisions.md) (22/07/2026) : API plaque payante self-service (~5 c/req), 1 plaque offerte par quiz, cache + limite IP.** Vérifié : aucune API gratuite ne peut exister — la réutilisation du SIV exige une [licence ministérielle avec redevance](https://mobile.interieur.gouv.fr/Repertoire-des-informations-publiques/La-reutilisation-des-donnees-du-systeme-d-immatriculation-des-vehicules), et HistoVec (gratuit) exige nom + numéro de formule de la carte grise. La date CT reste indisponible en self-service → fenêtre calculée depuis la 1re immatriculation. Contacts AAA Data/Autorigin (CT réelle) suspendus, réactivables.
|
||||
|
||||
*État au 22 juillet 2026 — recherche préliminaire web ; les points marqués 📞 nécessitent un contact commercial pour être tranchés.*
|
||||
|
||||
## 1. Le paysage des fournisseurs
|
||||
|
||||
| Fournisseur | Modèle | Prix constaté | Données | CT / Crit'Air | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **AAA Data — SIvin®** | API B2B sous contrat, concessionnaire historique des données SIV | 📞 non publié | 75 M véhicules, plaque ou VIN → caractéristiques techniques ; « identifiants légaux pour assurance, carte grise et contrôle technique » | 📞 à confirmer | **Le candidat sérieux** — source officielle, à contacter en priorité |
|
||||
| **Autorigin (B2B)** | Rapports d'historique par plaque, API B2B | 📞 non publié | Historique complet : **résultats de contrôles techniques**, kilométrage, sinistres, situation administrative | ✅ CT confirmé | Le seul qui affiche la donnée CT ; probablement facturé au rapport (riche, donc cher ?) 📞 |
|
||||
| **apiplaqueimmatriculation.com** | Self-service, packs mensuels | Dès **39 €/mois** sans engagement (~800 req/mois sur le pack standard → **~5 c/req**) | 80+ champs : marque, modèle, carburant, CO₂, code moteur, tecdocID, VIN | ❌ non mentionnés | Plan B économique pour l'effet waouh de base |
|
||||
| **api-plaque-immatriculation.com** | Self-service, paliers | Gratuit 10 req/mois · 59 $/600 req (**~9 c/req**) · 199 $/5 000 (~4 c) · 299 $/10 000 (~3 c) | 100+ champs (VIN, SRA, KType, puissance, CO₂, dimensions) | ❌ non mentionnés | Alternative self-service ; palier gratuit pratique pour le dev |
|
||||
| **AutomotivAPI (iziscar)** | API données auto européennes | Non relevé | Marque, modèle, motorisation, 1re immat, KType, codes SRA | ❌ | À creuser seulement si les deux ci-dessus déçoivent |
|
||||
| **API Particulier (ANTS)** | API officielle « extrait d'immatriculation » | Gratuit | Données du certificat d'immatriculation, temps réel SIV | — | ❌ **Inutilisable** : réservée aux collectivités (stationnement résidentiel), DataPass + FranceConnect |
|
||||
| **HistoVec** | Interface publique | Gratuit | Historique + CT | ✅ | ❌ Pas d'API exploitable (confirmé, conforme au cadrage) |
|
||||
|
||||
Le prix constaté (~3-10 centimes/requête en self-service) est **dans la fourchette basse de l'estimation du cadrage** (5-50 c). À ce tarif, la règle « 1 plaque offerte par quiz » coûte ~50-100 € pour 1 000 quiz complétés — coût d'acquisition borné et négligeable, le réglage pressenti (§ 7.3) est validé sous réserve des prix AAA Data.
|
||||
|
||||
## 2. Le point critique : la date réelle du prochain CT
|
||||
|
||||
**Constat** : aucune API self-service n'expose la date du prochain contrôle technique dans ses champs publics. La donnée existe (UTAC-OTC → HistoVec, et Autorigin la sert dans ses rapports), mais elle passe par des contrats B2B.
|
||||
|
||||
**Deux voies, à mener en parallèle :**
|
||||
|
||||
1. **📞 Contact commercial AAA Data (SIvin) et Autorigin** : demander explicitement « date de validité / date du dernier CT par plaque », le prix à l'appel, et les conditions (volumes minimums, engagement). C'est le scénario cible du cadrage (« date réelle obtenue par la plaque »).
|
||||
2. **Plan B dégradé mais honnête — l'inférence par la date de 1re immatriculation** (champ présent chez tous les fournisseurs) : 1er CT avant le 4e anniversaire, puis tous les 2 ans. Pour un véhicule de moins de 4 ans la date est **exacte** ; au-delà, on obtient une **fenêtre estimée** (parité des années + anniversaire), affinée par une micro-question optionnelle du quiz (« dernier CT : cette année / l'an dernier / aucune idée ») — conforme au § 4.3. L'effet waouh devient « Peugeot 308 de 2019, CT à prévoir vers mars 2027 » au lieu d'une date certaine : acceptable pour la Phase 0, à upgrader dès qu'un contrat CT est signé.
|
||||
|
||||
**Décision recommandée pour la Phase 0** : démarrer avec un agrégateur self-service (essai gratuit puis pack ~39-59 €/mois) pour marque/modèle/année/motorisation + plan B CT, et lancer les deux contacts commerciaux immédiatement. L'architecture rend le changement indolore : la détection plaque est un **adapter** (§ 20.8 du cadrage), le fournisseur est interchangeable.
|
||||
|
||||
## 3. Détection adresse — open data confirmé, gratuit
|
||||
|
||||
Les trois sources du cadrage existent et sont servies par des API publiques gratuites :
|
||||
|
||||
| Source | API | Usage fokan |
|
||||
|---|---|---|
|
||||
| **BAN** (Base Adresse Nationale) | API Adresse — `api-adresse.data.gouv.fr` | Autocomplétion + normalisation de l'adresse, clé de jointure |
|
||||
| **DPE ADEME** | « API DPE logements » (portail open data ADEME / data.gouv) | Type de chauffage, année de construction, surface — la pré-suggestion du volet logement |
|
||||
| **BDNB** (CSTB) | « API BDNB Open » | Caractéristiques bâtiment en complément/recoupement |
|
||||
| **Bonus découvert : IMOPE** (Observatoire National des Bâtiments) | API IMOPE | 400+ attributs par adresse (DPE, énergie, technique, risques...) agrégés de 100+ sources — **candidate pour remplacer les jointures manuelles DPE+BDNB par un seul appel** ; vérifier conditions d'accès et fraîcheur |
|
||||
|
||||
Conforme à la règle de gratuité (§ 7.3) : la détection adresse est offerte à tous. Toujours en **suggestion corrigeable**, jamais en vérité imposée.
|
||||
|
||||
## 4. Actions restantes de ce chantier
|
||||
|
||||
*Révisé par D-002 :*
|
||||
|
||||
- [ ] Ouvrir un compte d'essai chez les deux self-service (apiplaqueimmatriculation.com, api-plaque-immatriculation.com — palier gratuit 10 req/mois pour le dev), comparer la qualité réelle sur ~20 plaques connues (exactitude marque/modèle/1re immat, champs utiles à l'entretien).
|
||||
- [ ] Choisir le fournisseur et implémenter l'adapter plaque (interchangeable par design, § 20.8).
|
||||
- [ ] Implémenter le réglage anti-abus : 1 plaque/quiz, cache par plaque normalisée, limite IP.
|
||||
- [ ] Tester la couverture réelle DPE/BDNB/IMOPE sur ~10 adresses connues (dont une récente et une rurale).
|
||||
- [ ] Table des préconisations courroie/révision par modèle (chantier base de connaissance) — croisée avec le modèle retourné par l'API.
|
||||
- ~~📞 Contacter AAA Data (SIvin®) / Autorigin pour la date CT réelle~~ — suspendu, réactivable si le besoin se confirme.
|
||||
|
||||
## Sources
|
||||
|
||||
- [AAA Data — Nos API](https://www.aaa-data.fr/nos-solutions-api/) · [SIvin®](https://www.aaa-data.fr/api-sivin/)
|
||||
- [Autorigin B2B](https://api.b2b.autorigin.com/)
|
||||
- [apiplaqueimmatriculation.com](https://apiplaqueimmatriculation.com/)
|
||||
- [api-plaque-immatriculation.com](https://www.api-plaque-immatriculation.com/)
|
||||
- [AutomotivAPI (iziscar)](https://iziscar.com/automotivapi/)
|
||||
- [API Particulier — Extrait d'immatriculation véhicule (ANTS)](https://particulier.api.gouv.fr/catalogue/ants/extrait_immatriculation_vehicule)
|
||||
- [HistoVec intègre les données du contrôle technique — Sécurité Routière](https://www.securite-routiere.gouv.fr/actualites/histovec-integre-desormais-les-donnees-issues-du-controle-technique-des-vehicules)
|
||||
- [API Adresse (BAN) — data.gouv.fr](https://www.data.gouv.fr/dataservices/api-adresse-base-adresse-nationale-ban)
|
||||
- [API DPE logements — data.gouv.fr](https://www.data.gouv.fr/dataservices/api-dpe-logements)
|
||||
- [API BDNB Open — data.gouv.fr](https://www.data.gouv.fr/dataservices/api-bdnb-open)
|
||||
- [API IMOPE — data.gouv.fr](https://www.data.gouv.fr/dataservices/api-imope-observatoire-national-des-batiments)
|
||||
44
docs/phase-0/landing.md
Normal file
44
docs/phase-0/landing.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Phase 0 — Landing, prix affiché, hub figé
|
||||
|
||||
*Objectif unique : mesurer « les gens paieront-ils ? » pour un coût quasi nul. Tout ce qui ne sert pas cette mesure est hors périmètre.*
|
||||
|
||||
## 1. La landing
|
||||
|
||||
**Structure (une page, sobre, ton majordome) :**
|
||||
|
||||
1. **Hero** — « Personne ne retient tout ça — c'est normal. Nous, c'est notre métier. » Sous-titre : « Chaudière, contrôle technique, passeport des enfants, vaccins du chat : réponds à 8 questions, on monte la garde sur toutes les échéances de ton foyer. » CTA unique : **[Faire le quiz — 2 minutes]**.
|
||||
2. **La démonstration** — 3 exemples de rappels réels (un par pilier fort), affichés comme des mails : chaudière (septembre), CT avec le montant de l'amende, passeport enfant avec la règle des 6 mois. Chaque exemple montre le *pourquoi en une ligne* — c'est le savoir expert qui vend.
|
||||
3. **Comment ça marche** — 3 pas : « Tu réponds à 8 questions faciles → on génère le calendrier complet de ton foyer → on te prévient par mail au bon moment, avec l'action incluse. Le reste du temps, on se tait. »
|
||||
4. **L'anti-promesse** (différenciation) — « 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. »
|
||||
5. **Le prix, affiché sans détour** — gratuit : le quiz + ton calendrier à télécharger (.ics). La veille : **4,90 €/mois par foyer** (prix test de la fourchette 4-5 € ; variante A/B possible à 3,90 € plus tard, jamais deux prix affichés en même temps). « Sans engagement, hébergé en France, pas de pub, pas de revente de données. Par le créateur de Kankwa. »
|
||||
6. **FAQ courte** — données (rien de stocké au-delà du nécessaire, pas de scan conservé), résiliation en un clic, « pourquoi pas gratuit ? » (réponse honnête : pas de pub = c'est toi le client, pas le produit).
|
||||
|
||||
## 2. Le hub figé (résultat du quiz)
|
||||
|
||||
- L'écran d'état : « ✓ 34 échéances sous contrôle », les 3 prochaines avec leur pourquoi, les 4 piliers dépliables avec toutes les échéances inférées et leurs fenêtres.
|
||||
- **Figé** = tout est visible mais rien n'est actionnable (pas de « fait », pas d'assignation) — la démonstration est complète, la garde est payante.
|
||||
- Deux boutons persistants : **[Activer la veille — 4,90 €/mois]** · [Télécharger mon calendrier (.ics)].
|
||||
- L'export .ics est **réellement livré** (l'objet généreux et viral du cadrage) : échéances aux fenêtres par défaut, sans les relances intelligentes.
|
||||
|
||||
## 3. La mesure de l'intention de payer (décision Phase 0)
|
||||
|
||||
**Recommandation : porte honnête, pas de fake door trompeuse.** Le bouton « Activer la veille — 4,90 €/mois » mène à un écran : *« On ouvre dans quelques semaines. Laisse ton mail : les fondateurs auront le premier mois offert. »* (email + consentement).
|
||||
|
||||
- **Pourquoi pas d'encaissement réel en Phase 0** : le service de veille n'existe pas encore — prélever pour un service non rendu abîmerait la marque de confiance avant sa naissance. Le clic sur un bouton **avec prix affiché** suivi du dépôt d'email est un proxy fort et propre de l'étoile polaire.
|
||||
- **Métriques** : visiteurs → quiz démarré → quiz complété → clic « Activer » → email déposé (funnel complet). Cible indicative du cadrage : 2-4 % quiz complété → intention. Secondaire : téléchargements .ics, provenance du trafic (chaque page SEO/porte verticale taguée).
|
||||
- Bascule vers l'encaissement réel (Stripe ou équivalent — décision § 28) dès que le MVP veille est livrable aux fondateurs inscrits.
|
||||
|
||||
## 4. Périmètre technique Phase 0 (minimal assumé)
|
||||
|
||||
- **Un seul déploiement Next.js** : landing statique + quiz (formulaires pilotés par les schémas d'asset_types — déjà le pattern § 20.6, en version simplifiée) + hub figé rendu côté serveur + génération .ics.
|
||||
- **Adapters réels dès la Phase 0** : adresse (BAN + DPE/BDNB, gratuit) et plaque (self-service ~5 c/req, décision D-002 : 1 plaque/quiz, cache par plaque, limite IP ; repli déclaratif si plaque refusée).
|
||||
- **Pas encore** : comptes, paiement, moteur de réconciliation complet, envoi de mails récurrents. Le quiz écrit un foyer + assets en base (réutilisables au MVP — rien n'est jeté) ; l'email d'intention part dans la même base.
|
||||
- Analytics **sobres et RGPD-propres** (auto-hébergé type Plausible/Umami — cohérent avec la posture, pas de bandeau cookies à rallonge).
|
||||
- 3-5 premières pages SEO (portes verticales : contrôle technique, ramonage, passeport enfant) publiées en même temps pour amorcer le délai d'indexation de 6-12 mois — chacune avec CTA vers le quiz.
|
||||
|
||||
## 5. Ce que la Phase 0 doit apprendre avant d'engager le MVP
|
||||
|
||||
1. La conversion quiz complété → intention de payer (l'hypothèse existentielle, risque n°1 du cadrage).
|
||||
2. Où le quiz perd les gens (écran par écran).
|
||||
3. Le taux de confiance sur les détections (adresses fournies vs passées, plaques fournies vs repli déclaratif).
|
||||
4. Le nombre moyen d'échéances générées par foyer réel (calibre la promesse « 34 échéances »).
|
||||
84
docs/phase-0/quiz.md
Normal file
84
docs/phase-0/quiz.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Phase 0 — Les questions exactes du quiz
|
||||
|
||||
*Budget : ≤ 2 minutes, 8 écrans max. Chaque question est factuelle (on sait sans chercher), « je ne sais pas » est toujours une réponse valide, aucune question mémoire, aucune saisie obligatoire. Chaque réponse déclenche des inférences documentées — le quiz est la matérialisation du DSL d'applicabilité (§ 20.1).*
|
||||
|
||||
## Écran 0 — L'adresse (optionnel, avant tout)
|
||||
|
||||
> **« Ton adresse ? On pré-remplit ce qu'on peut avec les données publiques. »**
|
||||
> Champ autocomplété (API Adresse/BAN) · bouton « Passer » toujours visible.
|
||||
|
||||
- Si fournie → appel DPE/BDNB → l'écran 1 arrive **pré-rempli** avec bandeau : *« D'après les données publiques : maison de 1974, chauffage gaz — c'est toujours ça ? »* Tout est corrigeable en un tap.
|
||||
- Inférences directes : département → Loi Montagne (pneus hiver), ZFE/Crit'Air, calendrier déclaration de revenus, règlement sanitaire départemental (ramonage).
|
||||
|
||||
## Volet Logement
|
||||
|
||||
### Écran 1 — Le logement
|
||||
|
||||
> **« Ton logement, c'est plutôt : »**
|
||||
> [Maison] [Appartement] · [Propriétaire] [Locataire]
|
||||
|
||||
Inférences : propriétaire → taxe foncière, déclaration des biens, AG de copro (si appart) ; locataire → révision IRL du loyer, assurance habitation (obligatoire) ; maison → toiture, gouttières ; appartement + propriétaire → AG de copropriété.
|
||||
|
||||
### Écran 2 — Le chauffage
|
||||
|
||||
> **« Tu te chauffes comment ? »** *(plusieurs réponses possibles)*
|
||||
> [Gaz] [Fioul] [Électrique] [Pompe à chaleur / clim] [Bois / poêle / cheminée] [Je ne sais pas]
|
||||
|
||||
Inférences : gaz/fioul → entretien annuel obligatoire de la chaudière ; PAC/clim → entretien bisannuel ; bois/cheminée → ramonage annuel ; électrique seul → rien (et c'est très bien). « Je ne sais pas » → rappel doux à la première fenêtre saisonnière (septembre) : « au fait, tu te chauffes comment ? ».
|
||||
|
||||
### Écran 3 — Les équipements *(multi-sélection, tout est optionnel)*
|
||||
|
||||
> **« Coche ce que tu as : »**
|
||||
> [Jardin / haies] [Piscine] [Fosse septique] [Adoucisseur d'eau] [Chauffe-eau électrique] [Rien de tout ça]
|
||||
|
||||
Inférences : jardin → taille/élagage (périodes légales de voisinage) ; piscine → hivernage + remise en route ; fosse → vidange ~4 ans ; adoucisseur → sel/entretien ; chauffe-eau → détartrage. Les détecteurs de fumée et la VMC ne sont **pas demandés** : inférés pour tout logement (obligation universelle).
|
||||
|
||||
## Volet Véhicules *(décision D-002 : API plaque payante, 1 offerte par quiz)*
|
||||
|
||||
### Écran 4 — La plaque (l'effet waouh)
|
||||
|
||||
> **« Une voiture au foyer ? Tape sa plaque, on s'occupe du reste. »**
|
||||
> Champ plaque · [Ajouter un 2e véhicule] · [Pas de voiture] · [Je préfère ne pas la donner]
|
||||
|
||||
- Plaque fournie → API : *« Peugeot 308 de 2019, essence — contrôle technique à prévoir avant mars 2027. Courroie de distribution préconisée vers 120 000 km, pneus hiver obligatoires dans ton département du 1er nov au 31 mars, Crit'Air 1. »* Confirmable/corrigeable en un tap.
|
||||
- La date CT est une **fenêtre calculée** depuis la date de 1re immatriculation retournée par l'API (exacte pour les < 4 ans : 1er CT au 4e anniversaire ; estimée ensuite : cycles de 2 ans). Affinage optionnel plus tard, jamais bloquant : « la date exacte est sur ta vignette pare-brise — dis-la-moi un jour et je serai au jour près ».
|
||||
- Inférences : CT, révisions constructeur, courroie (par modèle), Crit'Air/ZFE, pneus hiver (département Loi Montagne), batterie avant l'hiver.
|
||||
- **Repli déclaratif** (plaque refusée) : marque/modèle autocomplétés + année + carburant → mêmes échéances en fenêtres estimées.
|
||||
- Anti-abus : 1 plaque par quiz, cache par plaque, limite IP (§ 7.3).
|
||||
- Option discrète en fin d'écran : [Moto/scooter] [Remorque] (mêmes mécaniques, quiz allégé).
|
||||
|
||||
## Volet Foyer
|
||||
|
||||
### Écran 5 — Qui vit ici ?
|
||||
|
||||
> **« Le foyer, c'est : »**
|
||||
> Adultes : [1] [2] [3+] · Enfants : [aucun] ou tranches d'âge cochables [0-5] [6-11] [12-15] [16-17]
|
||||
|
||||
Inférences : par personne → CNI/passeport (dont **passeport enfant : 5 ans seulement**, calé sur les tranches) ; 15-17 ans → recensement citoyen + JDC ; enfants → rappels calés sur vacances scolaires (garde animaux, passeports avant l'été). Pas de prénoms demandés (ajoutables plus tard dans le hub — jamais exigés).
|
||||
|
||||
### Écran 6 — Les animaux
|
||||
|
||||
> **« Des animaux ? »**
|
||||
> [Chien] [Chat] [Autre] [Aucun] · si oui : âge approximatif [jeune] [adulte] [senior] [je ne sais pas]
|
||||
|
||||
Inférences : vaccins annuels, antiparasitaires (saison avril-oct), vermifuge, bilan senior (si senior), garde avant vacances scolaires.
|
||||
|
||||
### Écran 7 — Les papiers (la seule question « floue » assumée)
|
||||
|
||||
> **« Tes papiers d'identité (CNI, passeport) ont plus de 10 ans ? »**
|
||||
> [Oui, certains] [Non, refaits récemment] [Aucune idée]
|
||||
|
||||
- « Aucune idée » est **parfaitement servi** : rappel doux unique (« un jour, jette un œil à la date de ta CNI — on la surveillera pour toi ») + proposition de photo-extraction (phase 2, image jamais conservée).
|
||||
- Question bonus (un seul tap, optionnelle) : **« Tu emploies quelqu'un à domicile (ménage, garde, jardin) ? »** [Oui] [Non] → sous-module CESU (déclarations, attestation fiscale, crédit d'impôt 50 %).
|
||||
|
||||
## Écran final — La révélation
|
||||
|
||||
> **« ✓ 34 échéances sous contrôle. Tu n'as plus besoin d'y penser. »**
|
||||
> Les 3 prochaines affichées avec leur pourquoi · les 4 piliers avec leurs comptes · deux boutons :
|
||||
> **[Activer la veille — 4,90 €/mois]** · [Télécharger mon calendrier (.ics gratuit)]
|
||||
|
||||
**Ce qui n'est jamais dans le quiz** : les contrats (proposés après, au moment pertinent, contre promesse monétaire — § 5.6) ; les dates de dernier entretien (questions mémoire interdites) ; le prénom du conjoint (l'invitation vient à la première délégation).
|
||||
|
||||
**Comptage** : 8 écrans, ~12 taps pour le parcours médian, 1 seule saisie clavier réellement utile (la plaque ; l'adresse est autocomplétée). Budget 2 minutes tenu.
|
||||
|
||||
**Instrumentation Phase 0** (l'étoile polaire se mesure ici) : taux de complétion par écran (où abandonne-t-on ?), taux d'adresses fournies, taux de plaques fournies vs repli déclaratif, distribution du nombre d'échéances générées, clics sur chacun des deux boutons finaux.
|
||||
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: { url: process.env.DATABASE_URL! },
|
||||
});
|
||||
45
knowledge/templates/animaux.yaml
Normal file
45
knowledge/templates/animaux.yaml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Pilier Animaux — fiches v0
|
||||
- id: vaccins-rappel
|
||||
version: 1
|
||||
pilier: animaux
|
||||
applicabilite: { asset_type: animal }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: securite, resume: "Le rappel annuel conditionne la protection — et l'accès aux pensions et gardes." }
|
||||
contenus:
|
||||
hub: { titre: "Rappel de vaccins", description: "Une visite véto par an, à la date anniversaire du dernier vaccin." }
|
||||
|
||||
- id: antiparasitaires-saison
|
||||
version: 1
|
||||
pilier: animaux
|
||||
applicabilite: { asset_type: animal, when: { espece: { in: [chien, chat] } } }
|
||||
recurrence: { freq: monthly, saison: [4, 5, 6, 7, 8, 9, 10] }
|
||||
enjeu: { type: securite, resume: "Saison des tiques et puces d'avril à octobre — traitement mensuel." }
|
||||
contenus:
|
||||
hub: { titre: "Antiparasitaires", description: "Traitement mensuel pendant la saison chaude, minimum avril-octobre." }
|
||||
|
||||
- id: vermifuge
|
||||
version: 1
|
||||
pilier: animaux
|
||||
applicabilite: { asset_type: animal, when: { espece: { in: [chien, chat] } } }
|
||||
recurrence: { freq: quarterly }
|
||||
enjeu: { type: securite, resume: "Tous les 3 mois pour un adulte — protège l'animal et la famille." }
|
||||
contenus:
|
||||
hub: { titre: "Vermifuge", description: "Un traitement par trimestre." }
|
||||
|
||||
- id: bilan-veterinaire-senior
|
||||
version: 1
|
||||
pilier: animaux
|
||||
applicabilite: { asset_type: animal, when: { age: senior } }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: securite, resume: "Après 8-10 ans, un bilan annuel détecte tôt ce qui se soigne bien." }
|
||||
contenus:
|
||||
hub: { titre: "Bilan vétérinaire senior", description: "Un check-up complet par an pour un animal senior." }
|
||||
|
||||
- id: garde-vacances
|
||||
version: 1
|
||||
pilier: animaux
|
||||
applicabilite: { asset_type: animal }
|
||||
recurrence: { freq: yearly, saison: [5, 6] }
|
||||
enjeu: { type: confort, resume: "Les bonnes pensions et pet-sitters affichent complet des semaines avant l'été." }
|
||||
contenus:
|
||||
hub: { titre: "Garde pour les vacances", description: "À réserver tôt si vous partez cet été." }
|
||||
157
knowledge/templates/maison.yaml
Normal file
157
knowledge/templates/maison.yaml
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Pilier Maison — fiches v0 (Phase 0 : hub figé)
|
||||
- id: chaudiere-gaz-entretien
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { chauffage: { contains: gaz } } }
|
||||
recurrence: { freq: yearly, saison: [9, 10] }
|
||||
enjeu: { type: legal+assurance, resume: "Obligatoire chaque année ; l'attestation peut être exigée par ton assurance en cas de sinistre." }
|
||||
contenus:
|
||||
hub: { titre: "Entretien de la chaudière gaz", description: "Un passage annuel du chauffagiste, idéalement avant l'hiver." }
|
||||
source: "Décret 2009-649"
|
||||
|
||||
- id: chaudiere-fioul-entretien
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { chauffage: { contains: fioul } } }
|
||||
recurrence: { freq: yearly, saison: [9, 10] }
|
||||
enjeu: { type: legal+assurance, resume: "Obligatoire chaque année, comme pour le gaz." }
|
||||
contenus:
|
||||
hub: { titre: "Entretien de la chaudière fioul", description: "Un passage annuel du chauffagiste, avant la saison de chauffe." }
|
||||
source: "Décret 2009-649"
|
||||
|
||||
- id: pac-clim-entretien
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { chauffage: { contains: pac } } }
|
||||
recurrence: { freq: biennial, saison: [4, 5] }
|
||||
enjeu: { type: legal, resume: "Entretien obligatoire tous les 2 ans pour les PAC et clims de 4 à 70 kW." }
|
||||
contenus:
|
||||
hub: { titre: "Entretien pompe à chaleur / clim", description: "Contrôle d'étanchéité et performance, tous les 2 ans." }
|
||||
source: "Décret 2020-912"
|
||||
|
||||
- id: ramonage
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { chauffage: { contains: bois } } }
|
||||
recurrence: { freq: yearly, saison: [9, 10] }
|
||||
enjeu: { type: legal+assurance, resume: "Obligatoire (règlement sanitaire départemental) ; le certificat est souvent exigé par l'assurance." }
|
||||
contenus:
|
||||
hub: { titre: "Ramonage cheminée / poêle", description: "Au moins un ramonage par an, avant la saison de chauffe." }
|
||||
|
||||
- id: detecteurs-fumee
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement }
|
||||
recurrence: { freq: yearly, saison: [10] }
|
||||
enjeu: { type: securite, resume: "Piles à tester chaque année ; détecteur à remplacer tous les 10 ans." }
|
||||
contenus:
|
||||
hub: { titre: "Détecteurs de fumée", description: "Un test des piles à l'automne — 30 secondes qui comptent." }
|
||||
source: "Loi Morange 2010-238"
|
||||
|
||||
- id: vmc-nettoyage
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement }
|
||||
recurrence: { freq: yearly, saison: [3, 4] }
|
||||
enjeu: { type: confort, resume: "Bouches encrassées = humidité et surconsommation." }
|
||||
contenus:
|
||||
hub: { titre: "Nettoyage des bouches VMC", description: "Un coup d'éponge annuel sur les bouches d'extraction." }
|
||||
|
||||
- id: chauffe-eau-detartrage
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: chauffe_eau } } }
|
||||
recurrence: { freq: biennial, saison: [5, 6] }
|
||||
enjeu: { type: argent, resume: "Le tartre use la résistance et fait grimper la facture." }
|
||||
contenus:
|
||||
hub: { titre: "Détartrage du chauffe-eau", description: "Tous les 2 ans environ selon la dureté de l'eau." }
|
||||
|
||||
- id: adoucisseur-entretien
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: adoucisseur } } }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: confort, resume: "Sel à recharger et désinfection annuelle pour garder une eau saine." }
|
||||
contenus:
|
||||
hub: { titre: "Entretien de l'adoucisseur", description: "Vérification du sel et entretien annuel." }
|
||||
|
||||
- id: fosse-septique-vidange
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: fosse_septique } } }
|
||||
recurrence: { freq: custom, interval_years: 4 }
|
||||
enjeu: { type: legal, resume: "Vidange par un professionnel agréé environ tous les 4 ans — obligation d'entretien." }
|
||||
contenus:
|
||||
hub: { titre: "Vidange de la fosse septique", description: "Environ tous les 4 ans, par un vidangeur agréé." }
|
||||
|
||||
- id: gouttieres-nettoyage
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { type: maison } }
|
||||
recurrence: { freq: yearly, saison: [10, 11] }
|
||||
enjeu: { type: argent, resume: "Une gouttière bouchée = infiltrations et façade abîmée." }
|
||||
contenus:
|
||||
hub: { titre: "Nettoyage des gouttières", description: "Après la chute des feuilles, avant les grosses pluies." }
|
||||
|
||||
- id: haies-taille
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: jardin } } }
|
||||
recurrence: { freq: yearly, saison: [3, 9] }
|
||||
enjeu: { type: legal, resume: "Obligations de voisinage ; taille interdite en période de nidification (16 mars - 15 août) pour les agriculteurs, déconseillée pour tous." }
|
||||
contenus:
|
||||
hub: { titre: "Taille des haies et élagage", description: "Fin d'hiver ou fin d'été, hors nidification." }
|
||||
|
||||
- id: piscine-hivernage
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: piscine } } }
|
||||
recurrence: { freq: yearly, saison: [10] }
|
||||
enjeu: { type: argent, resume: "Un hivernage raté = remise en état coûteuse au printemps." }
|
||||
contenus:
|
||||
hub: { titre: "Hivernage de la piscine", description: "Quand l'eau passe sous 15 °C, généralement en octobre." }
|
||||
|
||||
- id: piscine-remise-en-route
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { equipements: { contains: piscine } } }
|
||||
recurrence: { freq: yearly, saison: [4, 5] }
|
||||
enjeu: { type: confort, resume: "Redémarrer avant que l'eau tourne — sinon traitement choc et patience." }
|
||||
contenus:
|
||||
hub: { titre: "Remise en route de la piscine", description: "Au printemps, avant les premières chaleurs." }
|
||||
|
||||
- id: taxe-fonciere
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { statut: proprietaire } }
|
||||
recurrence: { freq: yearly, saison: [10] }
|
||||
enjeu: { type: legal, resume: "Majoration de 10 % en cas de retard de paiement." }
|
||||
contenus:
|
||||
hub: { titre: "Taxe foncière", description: "Avis en septembre, paiement pour mi-octobre (ou mensualisation)." }
|
||||
|
||||
- id: assurance-habitation-echeance
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: argent, resume: "Résiliable à tout moment après 1 an (loi Hamon) — la renégociation fait souvent gagner 50-150 €." }
|
||||
contenus:
|
||||
hub: { titre: "Assurance habitation", description: "Échéance annuelle — le bon moment pour comparer." }
|
||||
|
||||
- id: ag-copropriete
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { type: appartement, statut: proprietaire } }
|
||||
recurrence: { freq: yearly, saison: [4, 5, 6] }
|
||||
enjeu: { type: administratif, resume: "Absent sans pouvoir = décisions subies (travaux, charges)." }
|
||||
contenus:
|
||||
hub: { titre: "Assemblée générale de copropriété", description: "Convocation à surveiller ; pouvoir à donner si absent." }
|
||||
|
||||
- id: revision-loyer-irl
|
||||
version: 1
|
||||
pilier: maison
|
||||
applicabilite: { asset_type: logement, when: { statut: locataire } }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: argent, resume: "La révision IRL est encadrée — vérifier qu'elle est correcte protège des abus." }
|
||||
contenus:
|
||||
hub: { titre: "Révision annuelle du loyer (IRL)", description: "À la date anniversaire du bail — on vérifie le calcul." }
|
||||
46
knowledge/templates/papiers.yaml
Normal file
46
knowledge/templates/papiers.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Pilier Papiers — fiches v0
|
||||
- id: cni-validite
|
||||
version: 1
|
||||
pilier: papiers
|
||||
applicabilite: { asset_type: personne, when: { role: adulte, papiers_anciens: { in: [oui, nsp] } } }
|
||||
recurrence: { freq: once }
|
||||
enjeu: { type: administratif, resume: "CNI valable 15 ans ; les délais mairie + ANTS atteignent 1 à 3 mois en saison." }
|
||||
contenus:
|
||||
hub: { titre: "Carte d'identité à vérifier", description: "Un œil sur la date d'expiration — on cale le renouvellement sur les vrais délais." }
|
||||
|
||||
- id: passeport-enfant
|
||||
version: 1
|
||||
pilier: papiers
|
||||
applicabilite: { asset_type: personne, when: { role: enfant } }
|
||||
recurrence: { freq: yearly, saison: [3] }
|
||||
strategy: passeport_enfant
|
||||
enjeu: { type: administratif, resume: "Passeport enfant : 5 ans seulement (le piège classique). Certains pays exigent 6 mois de validité restante." }
|
||||
contenus:
|
||||
hub: { titre: "Passeport enfant", description: "Vérification avant l'été : date − délai d'obtention − règle des 6 mois." }
|
||||
|
||||
- id: recensement-jdc
|
||||
version: 1
|
||||
pilier: papiers
|
||||
applicabilite: { asset_type: personne, when: { tranche: "16-17" } }
|
||||
recurrence: { freq: once }
|
||||
enjeu: { type: legal, resume: "Recensement obligatoire à 16 ans — sans lui, pas d'inscription au permis ni aux examens." }
|
||||
contenus:
|
||||
hub: { titre: "Recensement citoyen (16 ans)", description: "En mairie ou en ligne dans les 3 mois du 16e anniversaire, puis JDC." }
|
||||
|
||||
- id: declaration-revenus
|
||||
version: 1
|
||||
pilier: papiers
|
||||
applicabilite: { asset_type: foyer }
|
||||
recurrence: { freq: yearly, saison: [4, 5] }
|
||||
enjeu: { type: legal, resume: "10 % de majoration en cas de retard ; la date limite dépend du département." }
|
||||
contenus:
|
||||
hub: { titre: "Déclaration de revenus", description: "Ouverture en avril, date limite selon ton département (fin mai - début juin)." }
|
||||
|
||||
- id: cesu-attestation-fiscale
|
||||
version: 1
|
||||
pilier: papiers
|
||||
applicabilite: { asset_type: foyer, when: { cesu: true } }
|
||||
recurrence: { freq: yearly, saison: [4] }
|
||||
enjeu: { type: argent, resume: "Le crédit d'impôt de 50 % se joue là — l'attestation annuelle CESU est à reporter dans la déclaration." }
|
||||
contenus:
|
||||
hub: { titre: "Attestation fiscale CESU", description: "Disponible au printemps, à vérifier avant la déclaration de revenus." }
|
||||
58
knowledge/templates/vehicules.yaml
Normal file
58
knowledge/templates/vehicules.yaml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Pilier Véhicules — fiches v0
|
||||
- id: controle-technique
|
||||
version: 1
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule }
|
||||
recurrence: { freq: biennial }
|
||||
strategy: ct_vehicule
|
||||
enjeu: { type: legal, resume: "135 € d'amende et immobilisation possible ; 1er CT au 4e anniversaire, puis tous les 2 ans." }
|
||||
contenus:
|
||||
hub: { titre: "Contrôle technique", description: "Fenêtre calculée depuis la première immatriculation — affinable à la date exacte de ta vignette." }
|
||||
source: "Code de la route R323-22"
|
||||
|
||||
- id: revision-constructeur
|
||||
version: 1
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: argent, resume: "Une révision suivie = garantie préservée et pannes évitées." }
|
||||
contenus:
|
||||
hub: { titre: "Révision constructeur", description: "Selon carnet : tous les ans ou tous les 15-30 000 km." }
|
||||
|
||||
- id: courroie-distribution
|
||||
version: 1
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule, when: { carburant: { in: [essence, diesel] } } }
|
||||
recurrence: { freq: custom, interval_years: 6 }
|
||||
strategy: courroie_distribution
|
||||
enjeu: { type: argent, resume: "L'oubli qui coûte un moteur : rupture = plusieurs milliers d'euros. Préconisation par modèle (km ET années)." }
|
||||
contenus:
|
||||
hub: { titre: "Courroie de distribution", description: "À remplacer selon la préconisation du modèle (souvent 5-10 ans ou 60-160 000 km)." }
|
||||
|
||||
- id: pneus-hiver-loi-montagne
|
||||
version: 1
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule, when: { loi_montagne: true } }
|
||||
recurrence: { freq: yearly, saison: [10] }
|
||||
enjeu: { type: legal, resume: "Équipements hiver obligatoires du 1er novembre au 31 mars dans ton département." }
|
||||
contenus:
|
||||
hub: { titre: "Pneus hiver (Loi Montagne)", description: "Monter les équipements avant le 1er novembre." }
|
||||
source: "Loi Montagne II, décret 2020-1264"
|
||||
|
||||
- id: batterie-hiver
|
||||
version: 1
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule }
|
||||
recurrence: { freq: yearly, saison: [11] }
|
||||
enjeu: { type: confort, resume: "Les batteries lâchent au premier froid — un test évite la panne du matin de décembre." }
|
||||
contenus:
|
||||
hub: { titre: "Batterie avant l'hiver", description: "Un test rapide si elle a plus de 4 ans." }
|
||||
|
||||
- id: assurance-auto-echeance
|
||||
version: 1
|
||||
pilier: vehicules
|
||||
applicabilite: { asset_type: vehicule }
|
||||
recurrence: { freq: yearly }
|
||||
enjeu: { type: argent, resume: "Résiliable à tout moment après 1 an (loi Hamon) — comparer à l'échéance fait souvent gagner 100-300 €." }
|
||||
contenus:
|
||||
hub: { titre: "Assurance auto", description: "Échéance annuelle — le bon moment pour comparer." }
|
||||
8
next.config.ts
Normal file
8
next.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pg", "yaml"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
2789
package-lock.json
generated
Normal file
2789
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "fokan",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:push": "drizzle-kit push",
|
||||
"knowledge:check": "tsx scripts/validate-knowledge.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"next": "^15.3.0",
|
||||
"pg": "^8.13.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
14
scripts/validate-knowledge.ts
Normal file
14
scripts/validate-knowledge.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/** CI de la base de connaissance : une fiche invalide casse le build, jamais la prod (§ 23). */
|
||||
import { loadTemplates } from "../src/knowledge/loader";
|
||||
|
||||
try {
|
||||
const templates = loadTemplates();
|
||||
const parPilier = templates.reduce<Record<string, number>>((acc, t) => {
|
||||
acc[t.pilier] = (acc[t.pilier] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
console.log(`✓ ${templates.length} fiches valides`, parPilier);
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
76
src/adapters/adresse.ts
Normal file
76
src/adapters/adresse.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Adapter adresse (§ 20.8) : open data gratuit, résultats toujours en SUGGESTION corrigeable,
|
||||
* jamais en vérité imposée (§ 7.1). Toute panne → le quiz continue sans pré-remplissage.
|
||||
*/
|
||||
|
||||
export type AdresseSuggestion = {
|
||||
label: string;
|
||||
citycode: string;
|
||||
departement: string;
|
||||
loi_montagne: boolean;
|
||||
};
|
||||
|
||||
export type LogementSuggestion = {
|
||||
type?: "maison" | "appartement";
|
||||
annee_construction?: number;
|
||||
chauffage?: string;
|
||||
surface?: number;
|
||||
};
|
||||
|
||||
/** Départements où la Loi Montagne s'applique (en tout ou partie — v0 : suggestion départementale). */
|
||||
const DEPARTEMENTS_LOI_MONTAGNE = new Set([
|
||||
"01", "03", "04", "05", "06", "07", "09", "11", "12", "15", "19", "21", "23", "25", "26",
|
||||
"2A", "2B", "30", "31", "34", "38", "39", "42", "43", "46", "48", "52", "54", "55", "63",
|
||||
"64", "65", "66", "67", "68", "69", "70", "71", "73", "74", "81", "82", "84", "88", "90",
|
||||
]);
|
||||
|
||||
export async function searchAdresse(q: string): Promise<AdresseSuggestion[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(q)}&type=housenumber&limit=5`,
|
||||
{ signal: AbortSignal.timeout(4000) },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.features ?? []).map((f: { properties: { label: string; citycode: string } }) => {
|
||||
const citycode: string = f.properties.citycode ?? "";
|
||||
const departement = citycode.startsWith("97") ? citycode.slice(0, 3) : citycode.slice(0, 2);
|
||||
return {
|
||||
label: f.properties.label,
|
||||
citycode,
|
||||
departement,
|
||||
loi_montagne: DEPARTEMENTS_LOI_MONTAGNE.has(departement),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Pré-suggestion logement via l'observatoire DPE de l'ADEME. Couverture imparfaite assumée. */
|
||||
export async function suggestLogement(adresse: 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";
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${base}?q=${encodeURIComponent(adresse)}&size=1&select=type_batiment,annee_construction,type_energie_principale_chauffage,surface_habitable_logement`,
|
||||
{ signal: AbortSignal.timeout(4000) },
|
||||
);
|
||||
if (!res.ok) 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();
|
||||
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,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
73
src/adapters/plaque.ts
Normal file
73
src/adapters/plaque.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* 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" };
|
||||
}
|
||||
14
src/app/api/adresse/route.ts
Normal file
14
src/app/api/adresse/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchAdresse, suggestLogement } from "@/adapters/adresse";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = req.nextUrl.searchParams.get("q") ?? "";
|
||||
if (q.length < 5) return NextResponse.json({ suggestions: [] });
|
||||
const withLogement = req.nextUrl.searchParams.get("logement") === "1";
|
||||
if (withLogement) {
|
||||
const logement = await suggestLogement(q);
|
||||
return NextResponse.json({ suggestions: [], logement });
|
||||
}
|
||||
const suggestions = await searchAdresse(q);
|
||||
return NextResponse.json({ suggestions });
|
||||
}
|
||||
20
src/app/api/event/route.ts
Normal file
20
src/app/api/event/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, events } from "@/db";
|
||||
|
||||
const Body = z.object({
|
||||
name: z.string().max(50),
|
||||
householdId: z.string().uuid().optional(),
|
||||
props: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = Body.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
try {
|
||||
await db().insert(events).values(parsed.data);
|
||||
} catch {
|
||||
// Le funnel ne doit jamais casser le parcours.
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
20
src/app/api/interet/route.ts
Normal file
20
src/app/api/interet/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, leads } from "@/db";
|
||||
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
householdId: z.string().uuid().optional(),
|
||||
source: z.string().default("activer_veille"),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = Body.safeParse(await req.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "email invalide" }, { status: 400 });
|
||||
await db().insert(leads).values({
|
||||
email: parsed.data.email,
|
||||
householdId: parsed.data.householdId,
|
||||
source: parsed.data.source,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
28
src/app/api/plaque/route.ts
Normal file
28
src/app/api/plaque/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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 });
|
||||
}
|
||||
29
src/app/api/quiz/route.ts
Normal file
29
src/app/api/quiz/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, households, assets } from "@/db";
|
||||
import { QuizAnswers, quizToAssets } from "@/engine/quiz-to-assets";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = QuizAnswers.safeParse(await req.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "réponses invalides" }, { status: 400 });
|
||||
}
|
||||
|
||||
const database = db();
|
||||
const [household] = await database
|
||||
.insert(households)
|
||||
.values({ quizAnswers: parsed.data })
|
||||
.returning({ id: households.id });
|
||||
|
||||
const assetRows = quizToAssets(parsed.data).map((a) => ({
|
||||
householdId: household.id,
|
||||
assetType: a.type,
|
||||
label: a.label,
|
||||
attributes: a.attributes,
|
||||
provenance: a.type === "vehicule" && (a.attributes.source === "api" || a.attributes.source === "mock")
|
||||
? "detection_plaque"
|
||||
: "quiz",
|
||||
}));
|
||||
if (assetRows.length) await database.insert(assets).values(assetRows);
|
||||
|
||||
return NextResponse.json({ id: household.id });
|
||||
}
|
||||
71
src/app/foyer/[id]/activer.tsx
Normal file
71
src/app/foyer/[id]/activer.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function Activer({ householdId }: { householdId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
function track(name: string) {
|
||||
fetch("/api/event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, householdId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setSending(true);
|
||||
const res = await fetch("/api/interet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, householdId, source: "activer_veille" }),
|
||||
}).catch(() => null);
|
||||
setSending(false);
|
||||
if (res?.ok) setDone(true);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="card" style={{ textAlign: "center" }}>
|
||||
<p><strong>C'est noté ✓</strong></p>
|
||||
<p className="muted">On t'écrit à l'ouverture — ton premier mois sera offert.
|
||||
D'ici là, ton calendrier est à toi.</p>
|
||||
<p><a className="btn btn-secondary" href={`/foyer/${householdId}/calendrier.ics`}
|
||||
onClick={() => track("ics_download")}>Télécharger mon calendrier (.ics)</a></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ textAlign: "center" }}>
|
||||
{!open ? (
|
||||
<>
|
||||
<p style={{ margin: "0 0 0.75rem" }}>
|
||||
<button className="btn btn-primary" onClick={() => { setOpen(true); track("cta_activate"); }}>
|
||||
Activer la veille — 4,90 €/mois
|
||||
</button>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
<a className="btn btn-ghost" href={`/foyer/${householdId}/calendrier.ics`}
|
||||
onClick={() => track("ics_download")}>ou télécharger mon calendrier (.ics gratuit)</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p><strong>On ouvre dans quelques semaines.</strong></p>
|
||||
<p className="muted">Laisse ton mail : les fondateurs auront le premier mois offert.</p>
|
||||
<div style={{ display: "flex", gap: "0.5rem", maxWidth: "420px", margin: "0.75rem auto" }}>
|
||||
<input type="email" placeholder="ton@mail.fr" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<button className="btn btn-primary" disabled={sending || !email.includes("@")} onClick={submit}>
|
||||
{sending ? "…" : "Me prévenir"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted small">Juste ce mail-là. Pas de pub, désinscription en un clic.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/app/foyer/[id]/calendrier.ics/route.ts
Normal file
25
src/app/foyer/[id]/calendrier.ics/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db, assets as assetsTable } from "@/db";
|
||||
import { buildHub } from "@/engine/hub";
|
||||
import { hubToIcs } from "@/engine/ics";
|
||||
import type { Asset, AssetType } from "@/knowledge/schema";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const rows = await db().select().from(assetsTable).where(eq(assetsTable.householdId, id));
|
||||
if (!rows.length) return new Response("introuvable", { status: 404 });
|
||||
|
||||
const assets: Asset[] = rows.map((r) => ({
|
||||
type: r.assetType as AssetType,
|
||||
label: r.label,
|
||||
attributes: r.attributes as Record<string, unknown>,
|
||||
}));
|
||||
const ics = hubToIcs(buildHub(assets), id);
|
||||
|
||||
return new Response(ics, {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="echeances-foyer.ics"',
|
||||
},
|
||||
});
|
||||
}
|
||||
75
src/app/foyer/[id]/page.tsx
Normal file
75
src/app/foyer/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db, assets as assetsTable } from "@/db";
|
||||
import { buildHub } from "@/engine/hub";
|
||||
import type { Asset, AssetType } from "@/knowledge/schema";
|
||||
import { Activer } from "./activer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PILIER_LABELS: Record<string, string> = {
|
||||
maison: "Maison",
|
||||
vehicules: "Véhicules",
|
||||
papiers: "Papiers",
|
||||
animaux: "Animaux",
|
||||
};
|
||||
|
||||
export default async function FoyerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!/^[0-9a-f-]{36}$/.test(id)) notFound();
|
||||
|
||||
const rows = await db().select().from(assetsTable).where(eq(assetsTable.householdId, id));
|
||||
if (!rows.length) notFound();
|
||||
|
||||
const assets: Asset[] = rows.map((r) => ({
|
||||
type: r.assetType as AssetType,
|
||||
label: r.label,
|
||||
attributes: r.attributes as Record<string, unknown>,
|
||||
}));
|
||||
const hub = buildHub(assets);
|
||||
|
||||
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>
|
||||
|
||||
<div className="card">
|
||||
{hub.prochaines.map((d) => (
|
||||
<div className="deadline" key={d.templateId + d.assetLabel}>
|
||||
<p><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">{d.enjeu}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Activer householdId={id} />
|
||||
|
||||
{(Object.entries(hub.parPilier) as [string, typeof hub.prochaines][]).map(([pilier, deadlines]) =>
|
||||
deadlines.length ? (
|
||||
<section key={pilier}>
|
||||
<h2><span className="pilier-badge">{PILIER_LABELS[pilier]}</span>{" "}
|
||||
<span className="muted small">{deadlines.length} échéance{deadlines.length > 1 ? "s" : ""}</span></h2>
|
||||
<div className="card">
|
||||
{deadlines.map((d, i) => (
|
||||
<div className="deadline" key={d.templateId + i}>
|
||||
<p><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">{d.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
<div className="footer-note muted small">
|
||||
<p>Ce calendrier est figé : il montre ce qu'on surveillerait pour toi. La veille active
|
||||
(rappels par mail au bon moment, délégation au conjoint, mise à jour continue) — c'est l'abonnement.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
129
src/app/globals.css
Normal file
129
src/app/globals.css
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
:root {
|
||||
--bg: #faf8f4;
|
||||
--card: #ffffff;
|
||||
--ink: #26302c;
|
||||
--muted: #6b7570;
|
||||
--accent: #2e6650;
|
||||
--accent-soft: #e7f0ec;
|
||||
--border: #e4e0d8;
|
||||
--warn: #8a6d3b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
main { max-width: 680px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
|
||||
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; }
|
||||
|
||||
.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; }
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem 1.4rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
input[type="text"], input[type="email"] {
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 1rem;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
}
|
||||
input: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;
|
||||
}
|
||||
|
||||
.deadline { border-top: 1px solid var(--border); padding: 0.9rem 0; }
|
||||
.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; }
|
||||
|
||||
.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);
|
||||
}
|
||||
16
src/app/layout.tsx
Normal file
16
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "fokan — la veille du foyer",
|
||||
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.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
74
src/app/page.tsx
Normal file
74
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<main>
|
||||
<p className="pilier-badge">fokan</p>
|
||||
<h1 style={{ marginTop: "1rem" }}>
|
||||
Personne ne retient tout ça — c'est normal. <br />
|
||||
Nous, c'est notre métier.
|
||||
</h1>
|
||||
<p className="muted" style={{ fontSize: "1.05rem" }}>
|
||||
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 style={{ margin: "1.5rem 0" }}>
|
||||
<Link href="/quiz" className="btn btn-primary">Faire le quiz — 2 minutes</Link>
|
||||
</p>
|
||||
|
||||
<h2>Ce qu'on t'évite d'oublier</h2>
|
||||
<div className="card mail-demo">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h2>Comment ça marche</h2>
|
||||
<div className="card">
|
||||
<p><strong>1.</strong> Tu réponds à 8 questions faciles — des choses que tu sais sans chercher.</p>
|
||||
<p><strong>2.</strong> On génère le calendrier complet de ton foyer : 30 à 45 échéances, instantanément.</p>
|
||||
<p><strong>3.</strong> On te prévient par mail au bon moment, avec le premier pas de l'action.
|
||||
<strong> Le reste du temps, on se tait.</strong></p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<h2>Le prix, sans détour</h2>
|
||||
<div className="card">
|
||||
<p><strong>Gratuit</strong> — le quiz + ton calendrier complet à télécharger (.ics).</p>
|
||||
<p><strong>La veille : 4,90 €/mois par foyer</strong> — on surveille, on te prévient,
|
||||
tu peux tout déléguer à ton conjoint en un tap. Sans engagement.</p>
|
||||
<p className="muted small">Hébergé en France · pas de pub · pas de revente de données ·
|
||||
par le créateur de Kankwa.</p>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: "2rem 0" }}>
|
||||
<Link href="/quiz" className="btn btn-primary">Voir les échéances de mon foyer</Link>
|
||||
</p>
|
||||
|
||||
<div className="footer-note muted small">
|
||||
<p><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>
|
||||
);
|
||||
}
|
||||
368
src/app/quiz/page.tsx
Normal file
368
src/app/quiz/page.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"use 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 [plaqueInput, setPlaqueInput] = useState("");
|
||||
const [plaqueLoading, setPlaqueLoading] = useState(false);
|
||||
const [plaqueErreur, setPlaqueErreur] = useState(false);
|
||||
const [declaratif, setDeclaratif] = useState(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupPlaque() {
|
||||
setPlaqueLoading(true);
|
||||
setPlaqueErreur(false);
|
||||
const res = await fetch(`/api/plaque?immat=${encodeURIComponent(plaqueInput)}`).catch(() => null);
|
||||
setPlaqueLoading(false);
|
||||
if (res?.ok) {
|
||||
const data = await res.json();
|
||||
if (data.vehicule) {
|
||||
setAnswers((s) => ({ ...s, vehicules: [...s.vehicules, { ...data.vehicule, plaque: plaqueInput }] }));
|
||||
setPlaqueInput("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPlaqueErreur(true);
|
||||
}
|
||||
|
||||
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" });
|
||||
setDeclaratif(false);
|
||||
}
|
||||
|
||||
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">Tape sa plaque, on s'occupe du reste : modèle, contrôle technique, entretien.</p>
|
||||
{answers.vehicules.map((v, i) => (
|
||||
<p className="suggestion" key={i}>
|
||||
✓ {v.marque} {v.modele} {v.annee ? `(${v.annee})` : ""}
|
||||
{v.source !== "declaratif" && v.date_premiere_immat ? " — CT calculé depuis sa 1re immatriculation" : ""}
|
||||
</p>
|
||||
))}
|
||||
{!declaratif ? (
|
||||
<>
|
||||
<input type="text" placeholder="AB-123-CD" value={plaqueInput}
|
||||
onChange={(e) => setPlaqueInput(e.target.value)} style={{ maxWidth: "220px" }} />
|
||||
{plaqueErreur && <p className="muted small">Plaque non reconnue — vérifie le format, ou décris ta voiture toi-même.</p>}
|
||||
<p style={{ marginTop: "0.75rem" }}>
|
||||
<button className="btn btn-secondary" disabled={plaqueLoading || plaqueInput.length < 6} onClick={lookupPlaque}>
|
||||
{plaqueLoading ? "Recherche…" : "Ajouter cette voiture"}
|
||||
</button>{" "}
|
||||
<button className="btn btn-ghost" onClick={() => setDeclaratif(true)}>Je préfère ne pas donner la plaque</button>
|
||||
</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</button>{" "}
|
||||
<button className="btn btn-ghost" onClick={() => setDeclaratif(false)}>Annuler</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>
|
||||
);
|
||||
}
|
||||
14
src/db/index.ts
Normal file
14
src/db/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
let pool: Pool | null = null;
|
||||
|
||||
export function db() {
|
||||
if (!pool) {
|
||||
pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
|
||||
}
|
||||
return drizzle(pool, { schema });
|
||||
}
|
||||
|
||||
export * from "./schema";
|
||||
38
src/db/schema.ts
Normal file
38
src/db/schema.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
/** Le foyer, objet central (§ 6.3). Phase 0 : pas de comptes, un foyer anonyme par quiz complété. */
|
||||
export const households = pgTable("households", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** Réponses brutes du quiz (audit + recalcul). */
|
||||
quizAnswers: jsonb("quiz_answers").notNull(),
|
||||
});
|
||||
|
||||
/** Instances génériques : un pilier n'est pas une table, c'est un asset_type (§ 20.2). */
|
||||
export const assets = pgTable("assets", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id").references(() => households.id).notNull(),
|
||||
assetType: text("asset_type").notNull(),
|
||||
label: text("label").notNull(),
|
||||
attributes: jsonb("attributes").notNull(),
|
||||
provenance: text("provenance").notNull(), // quiz | detection_plaque | detection_adresse
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/** Intentions de payer (Phase 0) : email fondateur déposé après clic sur le prix. */
|
||||
export const leads = pgTable("leads", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id").references(() => households.id),
|
||||
email: text("email").notNull(),
|
||||
source: text("source").notNull(), // activer_veille | autre
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/** Funnel Phase 0 (métriques § 13) — sobre, pas de tracking tiers. */
|
||||
export const events = pgTable("events", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id"),
|
||||
name: text("name").notNull(), // quiz_start | quiz_screen | quiz_complete | hub_view | ics_download | cta_activate
|
||||
props: jsonb("props"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
32
src/engine/applicability.ts
Normal file
32
src/engine/applicability.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Asset, Condition, DeadlineTemplate } from "@/knowledge/schema";
|
||||
|
||||
/** Évaluation du DSL d'applicabilité (§ 20.1) — 100 % déterministe, zéro LLM. */
|
||||
function matchCondition(value: unknown, cond: Condition): boolean {
|
||||
if (typeof cond === "string" || typeof cond === "number" || typeof cond === "boolean") {
|
||||
return value === cond;
|
||||
}
|
||||
if ("in" in cond) return cond.in.includes(value as string | number);
|
||||
if ("contains" in cond) return Array.isArray(value) && value.includes(cond.contains);
|
||||
if ("exists" in cond) return cond.exists ? value !== undefined && value !== null : value == null;
|
||||
if ("gte" in cond) return typeof value === "number" && value >= cond.gte;
|
||||
if ("lte" in cond) return typeof value === "number" && value <= cond.lte;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isApplicable(template: DeadlineTemplate, asset: Asset): boolean {
|
||||
const { asset_type, when } = template.applicabilite;
|
||||
if (asset.type !== asset_type) return false;
|
||||
if (!when) return true;
|
||||
return Object.entries(when).every(([attr, cond]) => matchCondition(asset.attributes[attr], cond));
|
||||
}
|
||||
|
||||
/** État désiré = templates actifs × assets du foyer (cœur du futur moteur de réconciliation). */
|
||||
export function applicableDeadlines(templates: DeadlineTemplate[], assets: Asset[]) {
|
||||
const result: { template: DeadlineTemplate; asset: Asset }[] = [];
|
||||
for (const template of templates) {
|
||||
for (const asset of assets) {
|
||||
if (isApplicable(template, asset)) result.push({ template, asset });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
53
src/engine/hub.ts
Normal file
53
src/engine/hub.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { Asset, DeadlineTemplate, Pilier } from "@/knowledge/schema";
|
||||
import { loadTemplates } from "@/knowledge/loader";
|
||||
import { applicableDeadlines } from "./applicability";
|
||||
import { computeWindow, type Window } from "./windows";
|
||||
|
||||
export type HubDeadline = {
|
||||
templateId: string;
|
||||
pilier: Pilier;
|
||||
titre: string;
|
||||
description: string;
|
||||
enjeu: string;
|
||||
enjeuType: DeadlineTemplate["enjeu"]["type"];
|
||||
assetLabel: string;
|
||||
window: Window;
|
||||
};
|
||||
|
||||
export type Hub = {
|
||||
total: number;
|
||||
prochaines: HubDeadline[];
|
||||
parPilier: Record<Pilier, HubDeadline[]>;
|
||||
};
|
||||
|
||||
const PILIER_ORDER: Pilier[] = ["maison", "vehicules", "papiers", "animaux"];
|
||||
|
||||
/** Construit le hub figé : un état, pas une liste de tâches (§ 6.1). */
|
||||
export function buildHub(assets: Asset[], now = new Date()): Hub {
|
||||
const matches = applicableDeadlines(loadTemplates(), assets);
|
||||
const deadlines: HubDeadline[] = matches.map(({ template, asset }) => ({
|
||||
templateId: template.id,
|
||||
pilier: template.pilier,
|
||||
titre: template.contenus.hub.titre,
|
||||
description: template.contenus.hub.description,
|
||||
enjeu: template.enjeu.resume,
|
||||
enjeuType: template.enjeu.type,
|
||||
assetLabel: asset.label,
|
||||
window: computeWindow(template, asset, now),
|
||||
}));
|
||||
|
||||
const dated = deadlines
|
||||
.filter((d) => d.window.start)
|
||||
.sort((a, b) => a.window.start!.getTime() - b.window.start!.getTime());
|
||||
|
||||
const parPilier = Object.fromEntries(PILIER_ORDER.map((p) => [p, [] as HubDeadline[]])) as Record<Pilier, HubDeadline[]>;
|
||||
for (const d of deadlines) parPilier[d.pilier].push(d);
|
||||
for (const p of PILIER_ORDER) {
|
||||
parPilier[p].sort((a, b) => {
|
||||
if (a.window.start && b.window.start) return a.window.start.getTime() - b.window.start.getTime();
|
||||
return a.window.start ? -1 : b.window.start ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
return { total: deadlines.length, prochaines: dated.slice(0, 3), parPilier };
|
||||
}
|
||||
35
src/engine/ics.ts
Normal file
35
src/engine/ics.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Hub } from "./hub";
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function escapeText(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
/** Export .ics statique — l'objet généreux du gratuit (§ 10.1). Fenêtres datées uniquement. */
|
||||
export function hubToIcs(hub: Hub, foyerId: string): string {
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//fokan//veille du foyer//FR",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"X-WR-CALNAME:Échéances du foyer",
|
||||
];
|
||||
const all = Object.values(hub.parPilier).flat();
|
||||
for (const d of all) {
|
||||
if (!d.window.start) continue;
|
||||
const start = fmtDate(d.window.start);
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${d.templateId}-${foyerId}@fokan`,
|
||||
`DTSTART;VALUE=DATE:${start}`,
|
||||
`SUMMARY:${escapeText(d.titre)}${d.assetLabel ? escapeText(` — ${d.assetLabel}`) : ""}`,
|
||||
`DESCRIPTION:${escapeText(`${d.enjeu} (fenêtre : ${d.window.label})`)}`,
|
||||
"END:VEVENT",
|
||||
);
|
||||
}
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
95
src/engine/quiz-to-assets.ts
Normal file
95
src/engine/quiz-to-assets.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { z } from "zod";
|
||||
import type { Asset } from "@/knowledge/schema";
|
||||
|
||||
/** Réponses du quiz (8 écrans — docs/phase-0/quiz.md). Tout est optionnel : « je ne sais pas » est premium. */
|
||||
export const QuizAnswers = z.object({
|
||||
adresse: z.object({
|
||||
label: z.string(),
|
||||
departement: z.string(),
|
||||
loi_montagne: z.boolean(),
|
||||
}).nullable().optional(),
|
||||
logement: z.object({
|
||||
type: z.enum(["maison", "appartement"]),
|
||||
statut: z.enum(["proprietaire", "locataire"]),
|
||||
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(),
|
||||
}),
|
||||
vehicules: z.array(z.object({
|
||||
plaque: z.string().optional(),
|
||||
marque: z.string().optional(),
|
||||
modele: z.string().optional(),
|
||||
annee: z.number().optional(),
|
||||
carburant: z.enum(["essence", "diesel", "electrique", "hybride"]).optional(),
|
||||
date_premiere_immat: z.string().optional(),
|
||||
critair: z.string().optional(),
|
||||
source: z.enum(["api", "mock", "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([]),
|
||||
animaux: z.array(z.object({
|
||||
espece: z.enum(["chien", "chat", "autre"]),
|
||||
age: z.enum(["jeune", "adulte", "senior", "nsp"]),
|
||||
})).default([]),
|
||||
papiers_anciens: z.enum(["oui", "non", "nsp"]).default("nsp"),
|
||||
cesu: z.boolean().default(false),
|
||||
});
|
||||
export type QuizAnswers = z.infer<typeof QuizAnswers>;
|
||||
|
||||
/** Le quiz devient des assets — l'inférence, jamais l'interrogatoire (§ 4.2). */
|
||||
export function quizToAssets(answers: QuizAnswers): Asset[] {
|
||||
const assets: Asset[] = [];
|
||||
|
||||
assets.push({
|
||||
type: "foyer",
|
||||
label: "Le foyer",
|
||||
attributes: {
|
||||
cesu: answers.cesu,
|
||||
departement: answers.adresse?.departement,
|
||||
},
|
||||
});
|
||||
|
||||
assets.push({
|
||||
type: "logement",
|
||||
label: answers.logement.type === "maison" ? "La maison" : "L'appartement",
|
||||
attributes: {
|
||||
...answers.logement,
|
||||
chauffage: answers.logement.chauffage.filter((c) => c !== "nsp"),
|
||||
departement: answers.adresse?.departement,
|
||||
},
|
||||
});
|
||||
|
||||
answers.vehicules.forEach((v, i) => {
|
||||
assets.push({
|
||||
type: "vehicule",
|
||||
label: v.marque ? `${v.marque} ${v.modele ?? ""}`.trim() : `Véhicule ${i + 1}`,
|
||||
attributes: { ...v, loi_montagne: answers.adresse?.loi_montagne ?? false },
|
||||
});
|
||||
});
|
||||
|
||||
for (let i = 0; i < answers.adultes; i++) {
|
||||
assets.push({
|
||||
type: "personne",
|
||||
label: i === 0 ? "Toi" : `Adulte ${i + 1}`,
|
||||
attributes: { role: "adulte", papiers_anciens: answers.papiers_anciens },
|
||||
});
|
||||
}
|
||||
answers.enfants.forEach((tranche, i) => {
|
||||
assets.push({
|
||||
type: "personne",
|
||||
label: `Enfant ${i + 1}`,
|
||||
attributes: { role: "enfant", tranche, papiers_anciens: answers.papiers_anciens },
|
||||
});
|
||||
});
|
||||
|
||||
answers.animaux.forEach((a, i) => {
|
||||
const noms = { chien: "Le chien", chat: "Le chat", autre: "L'animal" };
|
||||
assets.push({
|
||||
type: "animal",
|
||||
label: answers.animaux.length > 1 ? `${noms[a.espece]} ${i + 1}` : noms[a.espece],
|
||||
attributes: a,
|
||||
});
|
||||
});
|
||||
|
||||
return assets;
|
||||
}
|
||||
82
src/engine/windows.ts
Normal file
82
src/engine/windows.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { Asset, DeadlineTemplate } from "@/knowledge/schema";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type Window = {
|
||||
/** Premier jour de la fenêtre (pour tri et .ics). Null = fenêtre libre, apprise à l'usage. */
|
||||
start: Date | null;
|
||||
/** Libellé humain : « octobre 2026 », « avant mars 2027 », « à caler ensemble »… */
|
||||
label: string;
|
||||
confidence: "connue" | "estimee" | "libre";
|
||||
};
|
||||
|
||||
const MOIS = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
];
|
||||
|
||||
function nextSeasonStart(saison: number[], from: Date): Date {
|
||||
const year = from.getFullYear();
|
||||
const sorted = [...saison].sort((a, b) => a - b);
|
||||
for (const m of sorted) {
|
||||
const candidate = new Date(year, m - 1, 1);
|
||||
if (candidate >= from) return candidate;
|
||||
}
|
||||
return new Date(year + 1, sorted[0] - 1, 1);
|
||||
}
|
||||
|
||||
function labelFor(d: Date): string {
|
||||
return `${MOIS[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** Stratégie CT : fenêtre calculée depuis la 1re immatriculation (D-002). */
|
||||
function ctWindow(asset: Asset, now: Date): Window {
|
||||
const premiereImmat = asset.attributes.date_premiere_immat
|
||||
? new Date(String(asset.attributes.date_premiere_immat))
|
||||
: asset.attributes.annee
|
||||
? new Date(Number(asset.attributes.annee), 6, 1)
|
||||
: null;
|
||||
if (!premiereImmat || isNaN(premiereImmat.getTime())) {
|
||||
return { start: null, label: "dès qu'on connaît l'année du véhicule", confidence: "libre" };
|
||||
}
|
||||
// 1er CT avant le 4e anniversaire, puis cycles de 2 ans.
|
||||
const due = new Date(premiereImmat);
|
||||
due.setFullYear(due.getFullYear() + 4);
|
||||
while (due < now) due.setFullYear(due.getFullYear() + 2);
|
||||
const confidence = due.getFullYear() - premiereImmat.getFullYear() === 4 ? "connue" : "estimee";
|
||||
return {
|
||||
start: new Date(due.getFullYear(), due.getMonth(), 1),
|
||||
label: `avant ${MOIS[due.getMonth()]} ${due.getFullYear()}`,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeWindow(template: DeadlineTemplate, asset: Asset, now = new Date()): Window {
|
||||
if (template.strategy === "ct_vehicule") return ctWindow(asset, now);
|
||||
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" };
|
||||
}
|
||||
const { saison, freq, interval_years } = template.recurrence;
|
||||
if (saison?.length) {
|
||||
const start = nextSeasonStart(saison, now);
|
||||
return { start, label: labelFor(start), confidence: "estimee" };
|
||||
}
|
||||
if (freq === "once") {
|
||||
return { start: null, label: "une fois — on t'aidera à la caler", confidence: "libre" };
|
||||
}
|
||||
const cadence =
|
||||
freq === "yearly" ? "1 fois par an" :
|
||||
freq === "biennial" ? "tous les 2 ans" :
|
||||
freq === "quarterly" ? "tous les 3 mois" :
|
||||
freq === "monthly" ? "tous les mois" :
|
||||
interval_years ? `tous les ${interval_years} ans` : "récurrent";
|
||||
return { start: null, label: `${cadence} — date apprise à l'usage`, confidence: "libre" };
|
||||
}
|
||||
29
src/knowledge/loader.ts
Normal file
29
src/knowledge/loader.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { DeadlineTemplate } from "./schema";
|
||||
|
||||
let cache: DeadlineTemplate[] | null = null;
|
||||
|
||||
/** Charge et valide toutes les fiches YAML. Une fiche invalide → throw (jamais silencieux). */
|
||||
export function loadTemplates(): DeadlineTemplate[] {
|
||||
if (cache) return cache;
|
||||
const dir = path.join(process.cwd(), "knowledge", "templates");
|
||||
const templates: DeadlineTemplate[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".yaml"))) {
|
||||
const raw = parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
||||
if (!Array.isArray(raw)) throw new Error(`${file} : attendu une liste de fiches`);
|
||||
for (const item of raw) {
|
||||
const parsed = DeadlineTemplate.safeParse(item);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Fiche invalide dans ${file} (${item?.id ?? "sans id"}) : ${parsed.error.message}`);
|
||||
}
|
||||
if (seen.has(parsed.data.id)) throw new Error(`ID dupliqué : ${parsed.data.id}`);
|
||||
seen.add(parsed.data.id);
|
||||
templates.push(parsed.data);
|
||||
}
|
||||
}
|
||||
cache = templates;
|
||||
return templates;
|
||||
}
|
||||
66
src/knowledge/schema.ts
Normal file
66
src/knowledge/schema.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Schémas de la base de connaissance (version Phase 0, sous-ensemble du § 20.1).
|
||||
* Une fiche invalide casse le build (scripts/validate-knowledge.ts), pas la prod.
|
||||
*/
|
||||
|
||||
export const Pilier = z.enum(["maison", "vehicules", "papiers", "animaux"]);
|
||||
export type Pilier = z.infer<typeof Pilier>;
|
||||
|
||||
export const AssetType = z.enum(["foyer", "logement", "vehicule", "personne", "animal"]);
|
||||
export type AssetType = z.infer<typeof AssetType>;
|
||||
|
||||
/** Condition du DSL d'applicabilité — volontairement minimal (§ 20.1). */
|
||||
const Condition = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.object({ in: z.array(z.union([z.string(), z.number()])) }).strict(),
|
||||
z.object({ contains: z.union([z.string(), z.number()]) }).strict(),
|
||||
z.object({ exists: z.boolean() }).strict(),
|
||||
z.object({ gte: z.number() }).strict(),
|
||||
z.object({ lte: z.number() }).strict(),
|
||||
]);
|
||||
export type Condition = z.infer<typeof Condition>;
|
||||
|
||||
export const Applicabilite = z.object({
|
||||
asset_type: AssetType,
|
||||
when: z.record(z.string(), Condition).optional(),
|
||||
});
|
||||
|
||||
export const Recurrence = z.object({
|
||||
freq: z.enum(["yearly", "biennial", "quarterly", "monthly", "once", "custom"]),
|
||||
interval_years: z.number().optional(),
|
||||
/** Mois (1-12) de la fenêtre saisonnière. Absent = fenêtre libre. */
|
||||
saison: z.array(z.number().min(1).max(12)).optional(),
|
||||
});
|
||||
|
||||
export const DeadlineTemplate = z.object({
|
||||
id: z.string().regex(/^[a-z0-9-]+$/, "id en kebab-case stable à vie"),
|
||||
version: z.number().int().positive(),
|
||||
pilier: Pilier,
|
||||
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(),
|
||||
enjeu: z.object({
|
||||
type: z.enum(["legal", "legal+assurance", "securite", "argent", "confort", "administratif"]),
|
||||
resume: z.string().max(200),
|
||||
}),
|
||||
contenus: z.object({
|
||||
hub: z.object({ titre: z.string().max(80), description: z.string().max(300) }),
|
||||
}),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
export type DeadlineTemplate = z.infer<typeof DeadlineTemplate>;
|
||||
|
||||
export const Knowledge = z.object({ templates: z.array(DeadlineTemplate) });
|
||||
export type Knowledge = z.infer<typeof Knowledge>;
|
||||
|
||||
/** Un asset instancié (couche 2) — un pilier n'est pas une table, c'est un asset_type. */
|
||||
export type Asset = {
|
||||
type: z.infer<typeof AssetType>;
|
||||
label: string;
|
||||
attributes: Record<string, unknown>;
|
||||
};
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue