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>
34 KiB
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 neutre — ink-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_namefixe :kankwa-db— PostgreSQLkankwa-api— FastAPI sur127.0.0.1:8000kankwa-frontend— build React servi parfrontend/scripts/static-server.mjssur127.0.0.1:3001kankwa-scraper— FlareSolverr conservé mais non appelé tant qu’un proxy d’egress anti-SSRF n’épingle pas sa résolution
- Caddy route :
kankwa-frontend:3001etkankwa-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.mjsrejoue chaque page publique dans un Chromium headless aprèsvite build. Bilingue : chaque route deseo-routes.jsonest rendue deux fois (page PuppeteerfrPage/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 seulindex.htmlchacune. 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 d’attente dans le HTML statique. - Source unique des pages publiques :
frontend/src/shared/config/seo-routes.json(pages statiques) +landings.json(pages d’acquisition). Lus par le pré-rendu,api/routers/sitemap.py(hreflang alternates fr/en) etfrontend/scripts/static-server.mjs. Ajouter une page là = elle est pré-rendue et inscrite dans le sitemap. Le blog etarticles.jsonont été retirés : ne pas recréer un second système éditorial. - Landing pages d'acquisition (G6) :
landings.jsondéclare pour chaque page son chemin par langue, son cluster (organiser/outils/comparatif), sonprimaryService, 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 dansi18n/locales/{fr,en}/landing.json. Ne jamais créer de composant par landing ni écrire ses routes à la main :App.tsxetNavbar.tsxles génèrent depuis la config. Les entréeseditorial: trueajoutent des sections longues, des dates et un JSON-LDArticledans ce même template. Le CTA passe paruseCreateEventLink(primaryService)et la preuve produit est générée depuisServiceProductPreview— même brique que les vitrines. Une landing mono-module conserve l'aperçu détaillé de ce service ; dès quemodulesen déclare plusieurs, la preview compose exactement cette liste ordonnée avec les libellés courtspreview.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 enrel="nofollow noopener noreferrer"; ni prix ni chiffre d'usage recopié. Les vitrines listent les guides associés à leur service et chargent donc également le namespacelanding. - Templates Hub par cas d’usage (GRW-02) : les entrées
organiserdelandings.jsonportenthubTemplate.eventTypeet sont l’unique registre des templates. Leurkey, leursmodulesordonnés et leurshort_titlebilingue sont relus parHubTemplateFields, partagé entreCreateHubModalet/hub/new, puis par l’onboarding ; ne jamais créer un second catalogue Hub. Le type choisi filtre les cas d’usage compatibles et un changement de type efface le template devenu incohérent. Le CTA SEO transmettemplate=<key>dansnext. Un template conseille le type et l’ordre des outils, et peut les poser vides en un lot explicite (cf.## Activation produit) : il ne crée jamais d’item, de date ou de participant artificiel. Le namespacelandingreste différé et se charge à l’ouverture du sélecteur privé. - Serveur statique maison :
frontend/scripts/static-server.mjs(remplaceserve). Il litfrontend/locales.jsonet résout, dans l’ordre : fichier exact >index.<locale>.htmlselon le domaine >index.htmlde la langue par défaut >app.html(shell SPA). Un domaine inconnu utilise la locale par défaut.serveappliquait ses rewrites avant le lookup fichier et court-circuitait le pré-rendu. - Meta :
usePageMetagère titre, description, canonical, alternates, OG/Twitter et noindex.useLandingSeo(landing, faq)publie le graphe JSON-LD commun (WebPage + BreadcrumbList + FAQPage) et ajouteArticlepour une landing éditoriale. - Validation SEO obligatoire :
npm run check:prerender, exécuté parnpm run build, relit les 64 HTML générés et contrôleh1,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 d’un CTA de création dans le HTML statique, le développement des pages éditoriales, le tableau et le liennofollowdes comparatifs, et l’unicité desog:titlepar langue. Toute nouvelle page publique doit satisfaire ce contrôle. - Bannières de partage :
/og/<landing-key>.<lang>.png1200×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, + headerX-Robots-Tagcô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 verskankwa-api:8000. Depuis SEO-LP-18, la réponse dépend duHosttransmis :.frne publie que ses<loc>FR et.comuniquement ses<loc>EN ; leshreflangré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.tsest la source légère des identifiants, labels, chemins et statuts utilisés par la navigation, la homepage et le compte.services.tsxreste 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.lazydepuis 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 duimport.meta.globeager et chargé parloadNamespace(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.SeoLandinget les six vitrines utilisent ce mécanisme pourlanding. C’est 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-communa depuis perdu ce statut :fete-ecoleest la seule aujourd'hui), une mesure comparable sur leHEADimmédiatement antérieur donne 152,37 → 150,03 Ko gzip (−2,34 Ko). Comparer toujours à un build de référence duHEADcourant, jamais entre méthodes différentes.
Activation produit
- Le CTA public principal de la homepage est « Créer mon événement » et cible
/hub/newaprès authentification. Les vitrines emploient un libellé contextualisé mais gardent la même création de Hub. La destination internenextdoit ê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=1est consommé puis retiré de l’URL ; ne pas réintroduire de checklist ou d’action 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 l’onboarding.
- Depuis une landing événementielle,
template=<landing-key>suit le même trajet. La page/hub/newet le modal du dashboard partagent le sélecteur type → cas d’usage : ils proposent les 15 entréesorganisercompatibles, préremplissent leureventTypeet affichent les modules issus de la landing ; après création, l’onboarding les place en tête tout en laissant l’utilisateur configurer chaque ressource. - Pose en lot des outils d’un template :
TemplateStarterPanelpropose de créer d’un coup les modules conseillés, vides, viaPOST /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 n’est inventé. Le lot est atomique :enforce_premium_capacityréserve la capacité pour l’ensemble, 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 l’action, pas après l’échec.serviceMetadata.starterdésigne les services créables vides et doit rester aligné surBATCHABLE_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é dansnext, validé contreserviceMetadata, puis transmis à l’onboarding. Le module demandé apparaît en premier et est signalé, sans ouvrir automatiquement sa modale. /demoest la démonstration publique bilingue, sans compte ni données artificielles persistées ; la route appartient àseo-routes.jsonet doit rester pré-rendue.GuestConversionCTAest la source commune du CTA invité→organisateur. Il s’affiche uniquement après la réussite de l’action invitée et jamais pour un utilisateur connecté.- La homepage présente d’abord le scénario Hub « un seul lien » ; les noms des modules restent secondaires. L’absence d’installation et de compte invité obligatoire doit être visible avant le premier scroll. Son seul objectif de conversion est la création d’un événement ; la connexion reste dans la navigation.
- La homepage ne montre qu’une 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 ;/demoreste accessible directement et depuis le sitemap. - Les six vitrines reposent sur
ServiceVitrineetServiceProductPreview: récit problème→résultat, preuve propre au service, un seul objectif de conversion et aucun lien/democoncurrent. Les aperçus hors Hub sont des représentations fidèles avec données d’exemple, pas des captures ni des métriques d’usage. - Les captures produit versionnées dans
frontend/public/marketing/proviennent de composants réels.npm run capture:marketingrégénère les vues Hub depuis/demo, puis parcourt automatiquement le produit cartésienlandings.json × locales.jsonpour écrire les previews contextualisées danspublic/marketing/seo/et leur manifeste de dimensionslanding-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 blocpreviewbilingue ; 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.mjsrégénère aussimarketing-asset-version.jsonà partir du contenu exact de tous les WebP.marketingAssetUrlajoute 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.tsest 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 utilitairesprimary-*de chaque espace sans dupliquer les composants ; la Navbar colore le service actif. Les couleurs sémantiques restent universelles. Les surfaces portalisées commeModalreçoivent explicitement leserviceIdmétier, afin que les modales génériques restent sur la marque.- La capture Hub dans
ServiceProductPreviewdoit 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/growthn'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-contributionsest la vue unique des participations d'un utilisateur et doit couvrir tous les services. Kdo et Kontrib rattachent paruser_idquand l'action est faite connectée, et stockentparticipant_emailuniquement pour un invité ; Kount, Kal et Kwiz n'ont pas deuser_idsur 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 lescancel_token: ne rapprocher que suremail_verified, en minuscules, et ne jamais laisser une adresse absente produire unIS NULL. Un montant est toujours accompagné de sacurrency— le front formate viaformatCurrency()et n'a aucun symbole à deviner. Tout nouveau service partageable doit ajouter son type ici, sa fonction d'annulation dansTYPE_CANCELet ses libellés bilingues ; la couleur du badge vient deserviceThemes.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.tsest le registre des protocoles. Pourguest_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.jsonest la source unique du frontend pour le code, le domaine, la localeIntl, 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-i18nextcharge au build tous les fichiersfrontend/src/i18n/locales/<langue>/<namespace>.jsonviaimport.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 dansLAZY_NAMESPACES(aujourd’huilanding) sont exclus du chargement eager et arrivent parloadNamespace(namespace, lang)pour la seule langue active — cf.## Performance frontend. Un test qui rend une de ces routes doit l'appeler dans sonbeforeEachet le rappeler après unchangeLanguage(). - Validation obligatoire :
npm run check:i18ncontrôlelocales.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 l’absence de clés historiques_plural. Ce contrôle est exécuté automatiquement avant TypeScript, Vite et le pré-rendu parnpm run build. - Pluriels CLDR : avec i18next v24, utiliser les suffixes
_one/_otherdans 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 qu’une préférence locale obsolète ne puisse pas remettre une page anglaise en français. Pas de préfixe d’URL ;switchLang()persiste le choix et redirige vers le domaine déclaré. Pour une landing, la Navbar lui passe le chemin traduit viagetLocalizedLandingPath(); sur localhost, React Router change la route en mémoire. - Formats régionaux : dates, calendriers et montants doivent utiliser
getIntlLocale(); ne jamais coderfr-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 viacurrentLang()) et viaPATCH /auth/lang(appelé par le sélecteur de langue de la Navbar si connecté). Au login/getMe,syncLangFromUser()aligne l’UI sur la préférence stockée seulement lorsqu’aucun domaine canonique ne fixe déjà la langue. Tous les emails transactionnels (shared/email/sender.py) prennentlang: str = "fr", câblé aveclang=<user>.langpartout où unUserréel est en scope ; les flux invité/anonyme restent en fr (pas de signal de langue disponible).- Ajout d’une langue : ajouter son entrée dans
frontend/locales.jsonet 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
idpour 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. Lesget_owned_*de chaqueservice.pydé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és — source unique dansshared/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/usagedoit refléter exactement ce set (counted=True). - Premium : abonnement payant (
is_premium/premium_untilsur 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), clientshared/payments/stripe_client.py./checkoutrenvoie unclient_secret; front :PremiumCheckoutModal.tsx(@stripe/react-stripe-js),onComplete→ pollingis_premium.is_premiummodifié uniquement par le webhook signé. Prix : 1,99 €/mois + 19,99 €/an.is_premium_active(user)(premium_check.py) = flag ETpremium_untilnon expiré. - Devise Kount :
currencyest une devise ISO ou une unité libre de 1 à 10 caractères, validée parservices/kount/schemas.pyconformé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()utiliseIntl.NumberFormatpour les codes reconnus, puis un nombre localisé suivi de l’unité libre en repli ; aucun symbole (€,$…) ne doit être codé en dur.