kankwa/CLAUDE.md
Gautier Stefanini 626e510767 feat(seo): auditer les vingt landings et ouvrir l'intention EVJF/EVG
Audit page par page des landings et vitrines (SEO-LP-23) : cohérence
problématique → solution → preuve, propreté des modules et des CTA,
alignement FR/EN.

- soiree-jeux et sortie-entre-amis annoncent désormais la page de
  l'événement et non « le vote » : Kwiz est hors BATCHABLE_SERVICE_TYPES
  et ne peut pas être posé vide, le CTA promettait donc une ressource que
  le parcours ne crée pas ;
- repas place Kontrib en tête, conformément à son H1, sa problématique et
  son CTA qui promettent tous la liste ;
- suppression de la clé morte partage-depenses.modules.kontrib (FR/EN) ;
- dix méta ramenées dans leurs bornes ;
- previews EN localisées : Leeds et le Lake District remplacent Lyon et
  Annecy, kount.vitrine.preview_title suit ;
- la priorité sitemap devient une règle de cluster documentée dans
  landings.ts plutôt qu'un jugement page par page ; six liens
  réciproques portent le minimum entrant à deux.

Nouvelle landing evjf-evg (SEO-LP-24), une seule page pour les deux
intentions comme voyage/vacances-entre-amis. Son angle propre est la
personne fêtée : résultats Kwiz masqués jusqu'à publication et choix des
personnes concernées par chaque dépense dans Kount. La FAQ traite
frontalement l'absence d'encaissement.

La landing mariage est écartée et la décision consignée : sans RSVP,
sans liste d'invités ni encaissement, les deux tâches dominantes de
cette intention restent hors de portée.

Catalogue : 21 landings, 42 URL, 15 cas d'usage Hub, 64 pages pré-rendues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:31:05 +00:00

34 KiB
Raw Permalink Blame History

Kankwa — Contexte projet

Travail croisé Codex / Claude Code

Les deux agents utilisent les mêmes sources de vérité :

  • AGENTS.md — point d'entrée Codex et règles communes ;
  • docs/audits/2026-08-code-produit.md — audit daté et preuves ;
  • docs/audits/2026-08-style-visuel-produit.md — audit visuel validé après G4 ;
  • docs/DESIGN_SYSTEM.md — direction visuelle et contrat de design de G8 ;
  • docs/ROADMAP.md — source unique des priorités et de l'avancement ;
  • docs/AI_COLLABORATION.md — protocole de prise en charge et de passation.

Avant tout chantier issu de l'audit, lire la roadmap et passer l'item concerné à IN_PROGRESS. Ne le marquer DONE qu'avec ses critères d'acceptation satisfaits et des preuves renseignées. Mettre à jour la roadmap et la documentation technique dans la même modification que le code.

Socle visuel livré : G8 et ses items VIS-00 à VIS-09 sont DONE depuis le 12 août 2026. Tokens, primitives, shells, Hub, cinq services et surfaces publiques G4/G6 sont alignés ; les contrats ciblés et la recette globale sont décrits dans docs/TESTING.md. Toute évolution de style doit préserver ces contrats et suivre docs/DESIGN_SYSTEM.md. G3 reste TODO et indépendant de la refonte.

Surfaces publiques (VIS-08) : la homepage (Landing.tsx), le template des six vitrines (ServiceVitrine.tsx) et celui des 42 URL SEO (SeoLanding.tsx) utilisent les tokens max-w-public/px-gutter, max-w-editorial, surface-*, ink-*, line-*, rayons et élévations du système. Ne pas réintroduire une largeur, une gouttière ou un gris Tailwind historique dans ces trois sources ; le contrat publicVisualContract.test.ts le verrouille. Les couleurs calculées par serviceThemes.ts restent autorisées pour l'identité et les décors, jamais pour remplacer un rôle sémantique. Récit, preuve, CTA, previews et configuration SEO de G4/G6 restent des invariants.

Disponibilités et sondages (VIS-07) : une réponse à trois états n'est pas un bouton bascule — aria-pressed, binaire, ne lui convient pas ; c'est le nom accessible qui énonce laquelle des trois est retenue, après la date, l'horaire et le lieu (poll_public.slot_label). « Peut-être » ne prend jamais warning-* : une réponse valide n'appelle aucune correction, elle porte l'accent du service en aplat clair. Une sélection binaire (option Kwiz, date du calendrier) porte en revanche aria-pressed, et son bord épaissi se pose sur les deux états pour ne pas décaler la mise en page d'un pixel. Un contrôle piloté au geste doit rester atteignable au clavier : onClick gardé par e.detail === 0 quand un onPointerUp sert déjà le geste, role="slider" + flèches pour une poignée, et un bouton nommé explicite quand une interaction n'a aucun équivalent clavier — plutôt que rendre activable un conteneur qui héberge déjà un bouton. Une configuration figée se dit une fois en clair et passe par disabled (absorbé par .input), jamais par un voile opacity-*. Toute heure passe par slotGridUtils (formatMinutes/formatHour/formatTime, résolues par getIntlLocale()) : ne jamais réécrire un 09h30 local. Les barres de résultat et de classement sont aria-hidden ; décompte, pourcentage et score restent dans le fil de lecture, en tabular-nums. Le marqueur « (vous) » vient de common.json (participants.you_marker), commun à tous les services.

Frais partagés (VIS-06) : un montant est une donnée neutreink-soft et tabular-nums, jamais teinté par la palette du service, qui reste réservée aux actions et aux accents de texte (ink-accent). Toute somme et tout pourcentage passent par formatCurrency()/formatPercentage(), unité libre comprise : ne jamais coder un symbole ni une décimale à la main. Un état de configuration (dépense ventilée, répartition personnalisée) n'est pas un avertissement : il se lit par son libellé et prend au plus l'accent de service, jamais warning-*, qui appellerait une correction inexistante. Le solde se groupe en card-summary, les membres et la répartition par défaut en card-configuration. Un choix rendu par des boutons (payeur, membres concernés) porte aria-pressed et son groupe role="group" + aria-labelledby sur un <p id> — un <label> ne nomme pas un bouton. Un total hors bornes annonce le surplus, jamais un manque négatif.

États métier (VIS-05) : un état se lit par un badge textuel sémantique, pas par une couleur ni par un voile. badge-service porte la métadonnée de service (mode collecte), badge-success l'état positif atteint (réservé, financé, couvert) ; les alias badge-indigo/badge-green/badge-gray sont hérités et ne doivent plus être employés. Ne jamais atténuer une carte entière pour signaler un état : à 60 % d'opacité tout le contenu tombe sous 4,5:1 (jusqu'à 2,29:1 sur un badge), donc l'item traité devient moins lisible que l'item à traiter alors que son contenu reste utile. L'opacité reste admise pour un état transitoire non essentiel (chargement, scraping, pointer-events-none). ProgressBar confirme toujours en chiffres.

Assemblage Hub (VIS-04) : ModuleGroup est la seule composition d'un groupe de modules (scope de thème local, icône canonique, titre de section, décompte) et ServiceToolTile le seul choix d'outil ; les quatre lots de migration suivants les réutilisent. Une colonne d'assemblage ne porte qu'une action principale ; le démarrage d'un Hub vide est de l'aide contextuelle (card-notice), jamais un aplat de service. Un template conseille tous ses modules, pas seulement le premier : le badge « Conseillé » couvre l'ensemble du cas d'usage retenu, plus le service= demandé depuis une vitrine. Le type d'événement est une métadonnée (badge-meta) : les catalogues common.json n'ont plus d'emoji et EVENT_TYPE_ICONS n'existe plus. alert-warning porte la restriction actionnable (capacité freemium), card-notice l'aide permanente.

Shells (VIS-03) : frontend/src/shared/config/shellVariant.ts est la source unique du contexte. getShellVariant(pathname, authenticated) renvoie public ou workspace et shellContainerClass() en dérive shell-container + la largeur ; Navbar, bandeau de confidentialité et Footer l'appellent, leur fond restant pleine largeur. Une racine de service (/hub, /kdo…) dépend aussi de la session : ServiceRoute y rend la vitrine hors session et le tableau de bord en session. Ne jamais recalculer un conteneur de chrome dans un composant. Le rail de collaboration passe par CollaborationPanel (card-collaboration), les pages de lecture longue par page-editorial, la saisie de code par VerifyCodeForm / VerifyCodeStep. Les actions de gestion du masthead sont des icônes nommées groupées à droite (prop actions de ServicePageHeader + onEdit en rouage), avec aria-label et title — jamais un bouton texte posé sous la description. Partager est un seul contrôle partout, ShareButton : rail de partage et carte de chaque ressource partageable d'un tableau de bord ; CopyButton n'existe plus. La police de titre est portée par les rôles text-display/text-page-title/text-section-title, jamais par une page. Ces rôles sont des utilitaires Tailwind issus de la clé fontSize : le préfixe text- est obligatoire. Écrire page-title ou section-title seul produit une classe inexistante, silencieuse au build comme aux tests.

Primitives partagées (VIS-02) : frontend/src/index.css expose btn-quiet, btn-icon, card-notice, card-interactive, card-summary, card-configuration et les badges sémantiques (badge-service/success/warning/danger/info/meta). Field.tsx et Badge.tsx sont exportés depuis shared/components/index.ts. Modal, EmptyState, MetaChip, ConfirmModal, ProgressBar, ResourceCard utilisent les tokens ink-*, surface-*, line-*, rounded-control/card, duration-micro/control. Toute nouvelle page ou composant doit utiliser ces primitives plutôt que de recréer ses propres classes génériques. VIS-03 y ajoute .link (lien textuel souligné), card-collaboration, les primitives éditoriales et ProgressBar tone="quota" ; .btn-primary prend son fond au cran 700 car le blanc sur primary-600 de marque ne tient pas AA, et l'anneau de focus est posé une seule fois en couche base sur :focus-visible.

Tokens de design (VIS-01) : frontend/tailwind.config.ts et frontend/src/index.css portent les sept rôles typographiques, les neutres nommés par rôle (ink-*, surface-*, line-*), les cinq espacements de composition résolus par variable CSS, les quatre conteneurs, les quatre rayons, les trois élévations et les trois durées. Un besoin répété s'absorbe dans un token existant ; en ajouter un suppose de modifier docs/DESIGN_SYSTEM.md et frontend/src/shared/config/designTokens.test.ts dans la même modification. Contrainte de contraste vérifiée par ce test : tout rôle de texte tient 4,5:1 sur les surfaces claires, ink-faint n'est pas un rôle de texte, et sur une surface teintée le rôle remonte d'un cran. La route interne /style-tile démontre le contrat (noindex, hors sitemap et pré-rendu) ; la police Bricolage Grotesque est retenue et auto-hébergée mais n'est appliquée nulle part ailleurs avant VIS-02/03.

Règle du remplissage : seul un état sémantique remplit une surface de couleur. L'identité de service marque un bord, elle n'occupe pas le fond — trois palettes frôlent un token sémantique et les deux nuances claires de Kal sont exactement info-50/info-100. L'aide contextuelle utilise surface-notice (sable de marque, universel), jamais info-*, réservé à l'alerte transitoire. surface-accent n'est admis que sur un élément dont les frères non sélectionnés sont visibles, jamais sur un bloc isolé.

Plateforme de services du quotidien (wishlist, sondages, Secret Santa, partage de frais...). Alternatives éthiques aux outils grand public, hébergées sur serveur privé, RGPD-friendly. Modèle : abonnement Premium + freemium (3 services actifs max).

Contexte complet : /home/miaw/.claude/projects/-home-miaw-kankwa/memory/PLATFORM_CONCEPT.md


Stack

Couche Techno
Backend Python 3.12 + FastAPI (async)
BDD PostgreSQL 16 + SQLAlchemy 2.0 async + Alembic
Auth JWT + bcrypt + magic link
Emails SMTP via aiosmtplib (Infomaniak par défaut, cf. shared/email/client.py)
Paiements Stripe
Frontend React 18 + Vite + Tailwind CSS + React Router v7
Scraping HTTPX avec résolution DNS épinglée + extruct + price-parser
Reverse proxy Caddy (externe, container sur ai-net)
Conteneurs Docker + Docker Compose

Infrastructure Docker

  • Réseau : ai-net (externe, déjà existant)
  • 4 conteneurs avec container_name fixe :
    • kankwa-db — PostgreSQL
    • kankwa-api — FastAPI sur 127.0.0.1:8000
    • kankwa-frontend — build React servi par frontend/scripts/static-server.mjs sur 127.0.0.1:3001
    • kankwa-scraper — FlareSolverr conservé mais non appelé tant quun proxy degress anti-SSRF népingle pas sa résolution
  • Caddy route : kankwa-frontend:3001 et kankwa-api:8000
  • Tout en prod directement — pas de mode dev, pas de hot-reload

Architecture monorepo

kankwa/
├── api/            ← FastAPI app (main.py, config.py, routers/)
├── models/         ← Modèles SQLAlchemy (un fichier par entité)
├── services/       ← Logique métier par service (wishlist/, poll/, etc.)
├── shared/         ← Briques communes (auth/, database/, email/, payments/, rate_limit/)
├── alembic/        ← Toutes les migrations BDD
├── frontend/       ← React app (src/shared/, src/services/)
└── docker-compose.yml

Règle clé : chaque brique dans shared/ est codée une fois, jamais dupliquée entre services.


Phases de développement

  • Phase 1 — Fondations : shared/, models/user.py, auth, email, features génériques (QR code, commentaires, co-owner)
  • Phase 2 — Kdo : premier service MVP, générateur de revenus affiliation, scraping URL HTTP(S) épinglé, image produit
  • Phase 3 — Hub : hub central (projets/events), RSVP retiré
  • Phase 4 — Kontrib : différenciateur #1 vs Tilune
  • Phase 5 — Kount : différenciateur #2, capter fuyards Splitwise
  • Phase 6 — Kast : Secret Santa, prêt pour Noël
  • Phase 7 — Kwiz : sondages, compléter le hub
  • Phase 8 — Kal : disponibilités, compléter le hub
  • Phase 9 — Premium : Stripe Checkout + Customer Portal, abonnement mensuel/annuel
  • Phase 10 — LLM : Ollama local, zéro coût marginal, données jamais partagées

Ne jamais sauter une phase. Valider chaque phase avant de passer à la suivante.


SEO / visibilité

  • Pré-rendu au build (SSG), pas de SSR : frontend/scripts/prerender.mjs rejoue chaque page publique dans un Chromium headless après vite build. Bilingue : chaque route de seo-routes.json est rendue deux fois (page Puppeteer frPage / enPage, localStorage isolé) → dist/<route>/index.html (fr, défaut) + dist/<route>/index.en.html (en). Les landing pages ont des chemins distincts par langue, donc un seul index.html chacune. Les crawlers sans JS (Facebook, WhatsApp, LinkedIn, Bing, GPTBot…) reçoivent du vrai HTML dans la bonne langue. Le pré-rendu attend #root h1, pas le premier enfant de #root : sans cela une page dont le contenu est différé figerait son écran dattente dans le HTML statique.
  • Source unique des pages publiques : frontend/src/shared/config/seo-routes.json (pages statiques) + landings.json (pages dacquisition). Lus par le pré-rendu, api/routers/sitemap.py (hreflang alternates fr/en) et frontend/scripts/static-server.mjs. Ajouter une page là = elle est pré-rendue et inscrite dans le sitemap. Le blog et articles.json ont été retirés : ne pas recréer un second système éditorial.
  • Landing pages d'acquisition (G6) : landings.json déclare pour chaque page son chemin par langue, son cluster (organiser/outils/comparatif), son primaryService, les modules montrés et le maillage (related). Un seul template, shared/components/SeoLanding.tsx + hooks/useLandingSeo.ts, rend les quarante-deux URL ; tout le contenu, y compris le scénario de preview contextualisé, vit dans i18n/locales/{fr,en}/landing.json. Ne jamais créer de composant par landing ni écrire ses routes à la main : App.tsx et Navbar.tsx les génèrent depuis la config. Les entrées editorial: true ajoutent des sections longues, des dates et un JSON-LD Article dans ce même template. Le CTA passe par useCreateEventLink(primaryService) et la preuve produit est générée depuis ServiceProductPreview — même brique que les vitrines. Une landing mono-module conserve l'aperçu détaillé de ce service ; dès que modules en déclare plusieurs, la preview compose exactement cette liste ordonnée avec les libellés courts preview.modules.<id>. Hub n'est jamais un module de cette grille : le badge n'identifie que le conteneur d'un template événementiel. Les comparatifs se limitent à des axes structurels durables, affichent leur date de vérification et renvoient au concurrent en rel="nofollow noopener noreferrer" ; ni prix ni chiffre d'usage recopié. Les vitrines listent les guides associés à leur service et chargent donc également le namespace landing.
  • Templates Hub par cas dusage (GRW-02) : les entrées organiser de landings.json portent hubTemplate.eventType et sont lunique registre des templates. Leur key, leurs modules ordonnés et leur short_title bilingue sont relus par HubTemplateFields, partagé entre CreateHubModal et /hub/new, puis par lonboarding ; ne jamais créer un second catalogue Hub. Le type choisi filtre les cas dusage compatibles et un changement de type efface le template devenu incohérent. Le CTA SEO transmet template=<key> dans next. Un template conseille le type et lordre des outils, et peut les poser vides en un lot explicite (cf. ## Activation produit) : il ne crée jamais ditem, de date ou de participant artificiel. Le namespace landing reste différé et se charge à louverture du sélecteur privé.
  • Serveur statique maison : frontend/scripts/static-server.mjs (remplace serve). Il lit frontend/locales.json et résout, dans lordre : fichier exact > index.<locale>.html selon le domaine > index.html de la langue par défaut > app.html (shell SPA). Un domaine inconnu utilise la locale par défaut. serve appliquait ses rewrites avant le lookup fichier et court-circuitait le pré-rendu.
  • Meta : usePageMeta gère titre, description, canonical, alternates, OG/Twitter et noindex. useLandingSeo(landing, faq) publie le graphe JSON-LD commun (WebPage + BreadcrumbList + FAQPage) et ajoute Article pour une landing éditoriale.
  • Validation SEO obligatoire : npm run check:prerender, exécuté par npm run build, relit les 64 HTML générés et contrôle h1, lang, canonical, hreflang et Open Graph. Pour les landings il vérifie en plus le graphe JSON-LD complet, la bannière générée, la présence dun CTA de création dans le HTML statique, le développement des pages éditoriales, le tableau et le lien nofollow des comparatifs, et lunicité des og:title par langue. Toute nouvelle page publique doit satisfaire ce contrôle.
  • Bannières de partage : /og/<landing-key>.<lang>.png 1200×630 générées au build, une par landing et par langue.
  • Pages privées : toute page accessible par lien de partage doit passer noindex: true à usePageMeta, + header X-Robots-Tag côté Caddy (cf. Caddyfile.example) pour les crawlers sans JS.
  • Le sitemap est servi par l'API (/sitemap.xml), pas en statique — Caddy route ce chemin vers kankwa-api:8000. Depuis SEO-LP-18, la réponse dépend du Host transmis : .fr ne publie que ses <loc> FR et .com uniquement ses <loc> EN ; les hreflang réciproques restent cross-domain. Le serveur frontend applique la même sélection à robots.txt, afin que chaque domaine annonce son propre /sitemap.xml.

Performance frontend

  • frontend/src/shared/config/serviceMetadata.ts est la source légère des identifiants, labels, chemins et statuts utilisés par la navigation, la homepage et le compte. services.tsx reste le registre fonctionnel des routes/cartes/actions Hub. Ne jamais importer le registre fonctionnel pour afficher seulement une métadonnée.
  • Les modales et cartes propres aux services sont chargées avec React.lazy depuis leurs configs.
  • Catalogues i18n différés : un namespace volumineux réservé à quelques routes publiques est déclaré dans LAZY_NAMESPACES (src/i18n/config.ts), exclu du import.meta.glob eager et chargé par loadNamespace(namespace, lang) en même temps que le composant de la route. Seule la langue active est chargée ; un changement de langue recharge le bundle correspondant. SeoLanding et les six vitrines utilisent ce mécanisme pour landing. Cest le réflexe à appliquer à tout futur gros catalogue.
  • Mesures (chunk initial gzip, en séquence) : 147,54 avant PERF-01 → 141,52 (G5) → 145,33 (MKT-07) → 146,07 → 146,17 (palettes) → 147,37 après le premier lot G6 → 145,71 après l'extension événementielle G6. Pour le retrait du blog et les deux landings éditoriales d'alors (cadeau-commun a depuis perdu ce statut : fete-ecole est la seule aujourd'hui), une mesure comparable sur le HEAD immédiatement antérieur donne 152,37 → 150,03 Ko gzip (2,34 Ko). Comparer toujours à un build de référence du HEAD courant, jamais entre méthodes différentes.

Activation produit

  • Le CTA public principal de la homepage est « Créer mon événement » et cible /hub/new après authentification. Les vitrines emploient un libellé contextualisé mais gardent la même création de Hub. La destination interne next doit être conservée dans tous les flux (mot de passe, code email et magic link) et validée comme chemin local pour empêcher les redirections ouvertes.
  • Un Hub vide présente un seul état de démarrage compact avec les cinq modules disponibles. Après le premier module, une seule action « Ajouter un outil » ouvre le sélecteur. Le marqueur onboarding=1 est consommé puis retiré de lURL ; ne pas réintroduire de checklist ou daction de partage en doublon.
  • La colonne latérale du Hub reste la source unique pour le lien public, les co-gestionnaires et les commentaires. Ces trois panneaux ne doivent pas être déplacés ni fusionnés avec lonboarding.
  • Depuis une landing événementielle, template=<landing-key> suit le même trajet. La page /hub/new et le modal du dashboard partagent le sélecteur type → cas dusage : ils proposent les 15 entrées organiser compatibles, préremplissent leur eventType et affichent les modules issus de la landing ; après création, lonboarding les place en tête tout en laissant lutilisateur configurer chaque ressource.
  • Pose en lot des outils dun template : TemplateStarterPanel propose de créer dun coup les modules conseillés, vides, via POST /hub/{id}/modules (services/hub/modules.py). Seul le titre est dérivé (onboarding.module_title.* + nom du Hub) ; aucun item, aucune date, aucun participant nest inventé. Le lot est atomique : enforce_premium_capacity réserve la capacité pour lensemble, donc un template ne se pose jamais à moitié. Les cases sont pré-cochées jusquà la capacité freemium restante, les suivantes restent visibles et désactivées avec le lien Kankwa+ — le coût est montré avant laction, pas après léchec. serviceMetadata.starter désigne les services créables vides et doit rester aligné sur BATCHABLE_SERVICE_TYPES ; Kwiz en est exclu car un sondage exige ses options et reste une création en un clic.
  • Depuis une vitrine, service=<id> est conservé dans next, validé contre serviceMetadata, puis transmis à lonboarding. Le module demandé apparaît en premier et est signalé, sans ouvrir automatiquement sa modale.
  • /demo est la démonstration publique bilingue, sans compte ni données artificielles persistées ; la route appartient à seo-routes.json et doit rester pré-rendue.
  • GuestConversionCTA est la source commune du CTA invité→organisateur. Il saffiche uniquement après la réussite de laction invitée et jamais pour un utilisateur connecté.
  • La homepage présente dabord le scénario Hub « un seul lien » ; les noms des modules restent secondaires. Labsence dinstallation et de compte invité obligatoire doit être visible avant le premier scroll. Son seul objectif de conversion est la création dun événement ; la connexion reste dans la navigation.
  • La homepage ne montre quune preuve produit, intégrée au hero avec bascule invité/organisateur. Elle ne renvoie pas vers /demo, afin de ne pas doubler une démonstration déjà visible ; /demo reste accessible directement et depuis le sitemap.
  • Les six vitrines reposent sur ServiceVitrine et ServiceProductPreview : récit problème→résultat, preuve propre au service, un seul objectif de conversion et aucun lien /demo concurrent. Les aperçus hors Hub sont des représentations fidèles avec données dexemple, pas des captures ni des métriques dusage.
  • Les captures produit versionnées dans frontend/public/marketing/ proviennent de composants réels. npm run capture:marketing régénère les vues Hub depuis /demo, puis parcourt automatiquement le produit cartésien landings.json × locales.json pour écrire les previews contextualisées dans public/marketing/seo/ et leur manifeste de dimensions landing-preview-dimensions.json. Une future landing est donc couverte sans modifier le script : elle doit seulement déclarer son chemin, ses modules ordonnés et son bloc preview bilingue ; les tests exigent un libellé court pour chaque module dès qu'il y en a plusieurs et couvrent les grilles jusqu'à cinq modules. Les previews de vitrines mono-service ne répètent pas la marque sur chaque item ; les captures SEO contextualisées et les cartes qui présentent un service ou une landing utilisent le SVG canonique et sa couleur. Les surfaces capturées n'utilisent jamais d'emoji comme icône. npm run check:marketing, inclus dans le build, vérifie présence, WebP, poids et concordance exacte des dimensions ; le SSG contrôle que chaque HTML référence le bon asset.
  • capture-marketing.mjs régénère aussi marketing-asset-version.json à partir du contenu exact de tous les WebP. marketingAssetUrl ajoute cette empreinte aux URLs Hub et SEO ; ne jamais référencer directement une capture marketing sans ce helper, sinon les navigateurs peuvent conserver une ancienne image.
  • serviceThemes.ts est la source unique des palettes Hub, Kdo, Kal, Kwiz, Kontrib et Kount. ServiceThemeScope, posé au niveau des routes métier et publiques, propage la palette aux utilitaires primary-* de chaque espace sans dupliquer les composants ; la Navbar colore le service actif. Les couleurs sémantiques restent universelles. Les surfaces portalisées comme Modal reçoivent explicitement le serviceId métier, afin que les modales génériques restent sur la marque.
  • La capture Hub dans ServiceProductPreview doit conserver son ratio intrinsèque (largeur fluide, hauteur automatique) pour ne jamais être recadrée sur mobile.
  • Les blocs de confiance doivent rester alignés sur le code et la politique de confidentialité. Ne jamais ajouter de compteur, logo client ou témoignage sans preuve publiable ; la capture réelle du produit est la preuve par défaut.

Boucles de croissance

  • Mesure invité→organisateur : services/growth n'accepte que quatre événements typés et aucune propriété libre. L'UUID first-party expire après 13 mois sans renouvellement ; l'opposition dans la page confidentialité le supprime et bloque toute mesure. Les événements serveur sont dédupliqués et purgés avant 25 mois. Ne jamais ajouter d'email, de contenu métier ou d'identifiant publicitaire à ce contrat. Les agrégats vivent dans /api/admin/stats.
  • Contributions dans le Hub : /hub/my-contributions est la vue unique des participations d'un utilisateur et doit couvrir tous les services. Kdo et Kontrib rattachent par user_id quand l'action est faite connectée, et stockent participant_email uniquement pour un invité ; Kount, Kal et Kwiz n'ont pas de user_id sur leurs participants et ne se rattachent que par l'email. Ce repli par email rattrape une action faite en invité avant l'inscription : tout service ouvrant une action invitée doit donc conserver l'adresse saisie. La réponse expose les cancel_token : ne rapprocher que sur email_verified, en minuscules, et ne jamais laisser une adresse absente produire un IS NULL. Un montant est toujours accompagné de sa currency — le front formate via formatCurrency() et n'a aucun symbole à deviner. Tout nouveau service partageable doit ajouter son type ici, sa fonction d'annulation dans TYPE_CANCEL et ses libellés bilingues ; la couleur du badge vient de serviceThemes.ts, pas d'un token sémantique choisi à la main.
  • Duplication Hub : copier uniquement la structure organisateur. Kdo exclut réservations/contributions ; Kontrib exclut déclarations/suggestions ; Kount exclut membres/dépenses/répartitions ; Kal exclut participants/réponses ; Kwiz exclut participants/votes. Les tokens sont neufs, la date est vide et les sondages sont rouverts. Toute extension doit compléter le test PostgreSQL.
  • Relance post-événement : préférence désactivée par défaut, consentement et désinscription datés, confirmation publique en deux temps. La boucle horaire réserve au plus une relance par événement entre J+1 et J+7 et marque avant SMTP afin de privilégier l'absence de répétition.
  • A/B : frontend/src/shared/config/experiments.ts est le registre des protocoles. Pour guest_cta_copy_v1, conserver le contrôle jusqu'à 686 expositions uniques par variante et un gain significatif bilatéral à 5 % ; ne jamais présenter un résultat avant le seuil ni modifier une expérience sous la même clé. Décision : docs/decisions/2026-08-growth-loops.md ; procédure : docs/runbooks/ab-experiments.md.

i18n bilingue FR/EN

  • Registre des locales : frontend/locales.json est la source unique du frontend pour le code, le domaine, la locale Intl, la locale Open Graph, le nom natif, le drapeau et la langue par défaut. Il est consommé par la configuration i18n, la Navbar, les métadonnées et le serveur statique de production.
  • Catalogues auto-découverts : react-i18next charge au build tous les fichiers frontend/src/i18n/locales/<langue>/<namespace>.json via import.meta.glob. Ajouter un namespace ne nécessite donc aucun import ni registre TypeScript supplémentaire, mais le même fichier et les mêmes clés doivent exister dans chaque langue. Les namespaces listés dans LAZY_NAMESPACES (aujourdhui landing) sont exclus du chargement eager et arrivent par loadNamespace(namespace, lang) pour la seule langue active — cf. ## Performance frontend. Un test qui rend une de ces routes doit l'appeler dans son beforeEach et le rappeler après un changeLanguage().
  • Validation obligatoire : npm run check:i18n contrôle locales.json, la parité des namespaces et des clés entre langues (tableaux comparés élément par élément, donc une FAQ ou une étape manquante est détectée), ainsi que labsence de clés historiques _plural. Ce contrôle est exécuté automatiquement avant TypeScript, Vite et le pré-rendu par npm run build.
  • Pluriels CLDR : avec i18next v24, utiliser les suffixes _one/_other dans les catalogues et appeler uniquement la clé de base avec { count }. Ne jamais sélectionner manuellement une clé _plural.
  • Détection (detectLang()) : domaine canonique (kankwa.fr→fr, kankwa.com→en) > localStorage.kankwa_lang > locale par défaut du registre. Le domaine est prioritaire afin quune préférence locale obsolète ne puisse pas remettre une page anglaise en français. Pas de préfixe dURL ; switchLang() persiste le choix et redirige vers le domaine déclaré. Pour une landing, la Navbar lui passe le chemin traduit via getLocalizedLandingPath() ; sur localhost, React Router change la route en mémoire.
  • Formats régionaux : dates, calendriers et montants doivent utiliser getIntlLocale() ; ne jamais coder fr-FR, un symbole monétaire ou un ordre jour/mois directement dans un composant. Pour Kount, passer systématiquement la devise/unité du groupe à formatCurrency().
  • User.lang (colonne BDD, défaut "fr") : écrite à la création de compte (register/magic-link, valeur envoyée par le front via currentLang()) et via PATCH /auth/lang (appelé par le sélecteur de langue de la Navbar si connecté). Au login/getMe, syncLangFromUser() aligne lUI sur la préférence stockée seulement lorsquaucun domaine canonique ne fixe déjà la langue. Tous les emails transactionnels (shared/email/sender.py) prennent lang: str = "fr", câblé avec lang=<user>.lang partout où un User réel est en scope ; les flux invité/anonyme restent en fr (pas de signal de langue disponible).
  • Ajout dune langue : ajouter son entrée dans frontend/locales.json et un jeu complet de catalogues. Le runtime et le serveur statique sont génériques ; le pré-rendu, le sitemap et les emails restent actuellement conçus explicitement pour le couple FR/EN et doivent être étendus ensemble si une troisième langue est ajoutée.
  • Voir ## SEO / visibilité ci-dessus pour le pré-rendu bilingue des pages publiques.

Patterns transverses

  • share_token : chaque ressource partageable a un UUID distinct de son id pour les URLs publiques
  • event_id nullable : toutes les ressources peuvent être rattachées à un Hub (NULL = mode autonome)
  • Ownership : toute mutation vérifie owner (resource.user_id == current_user.id) OU co-owner (has_resource_access), retourne 403 sinon. Les get_owned_* de chaque service.py délèguent à get_owned_resource() (shared/auth/co_owner.py) qui encapsule les deux checks.
  • Suppression de ressource : toujours appeler cleanup_resource_ownership(type, id, db) (co-owners + invitations n'ont pas de FK, donc pas de CASCADE BDD).
  • Annulation invité : lookup par token factorisé via get_by_token_or_404() (shared/db_helpers.py) ; l'email/label reste propre à chaque service.
  • Freemium : limite MAX_FREE_ACTIVE_SERVICES (=3) sur les services facturéssource unique dans shared/auth/premium_check.py (enforce_premium_limit(user, db)). Comptés : Kdo + Kontrib + Kount + Kal + Kwiz. Hub illimité (inutile seul, agrège d'autres services). /me/usage doit refléter exactement ce set (counted=True).
  • Premium : abonnement payant (is_premium/premium_until sur User) lève la limite. Phase 9 — Stripe Embedded Checkout (modale in-app, ui_mode=embedded + redirect_on_completion=never) + Customer Portal (redirect). Routes /api/billing/{config,checkout,portal,webhook} (api/routers/billing.py), client shared/payments/stripe_client.py. /checkout renvoie un client_secret ; front : PremiumCheckoutModal.tsx (@stripe/react-stripe-js), onComplete → polling is_premium. is_premium modifié uniquement par le webhook signé. Prix : 1,99 €/mois + 19,99 €/an. is_premium_active(user) (premium_check.py) = flag ET premium_until non expiré.
  • Devise Kount : currency est une devise ISO ou une unité libre de 1 à 10 caractères, validée par services/kount/schemas.py conformément à la colonne BDD. Le frontend propose les devises courantes et une option « Autre », conserve les anciennes valeurs personnalisées et affiche un aperçu. formatCurrency() utilise Intl.NumberFormat pour les codes reconnus, puis un nombre localisé suivi de lunité libre en repli ; aucun symbole (, $…) ne doit être codé en dur.