apps/web — Next.js 16 + React 19
App Router, Turbopack en dev (port 3000). Deux mondes de routes cohabitent, distingués par leur emplacement.
Routes publiques vs auth-gated
- Public →
apps/web/app/[locale]/. Toujours préfixé/fr|/nl|/enpour le SEO. Pathnames localisés (/about↔/a-propos). - Auth-gated →
apps/web/app/app/. Pas de préfixe : le cookieNEXT_LOCALEécrit par le middleware intl tient la langue. middleware.tscombine Auth0 (sur/auth/*,/app/*) et next-intl (routes publiques).
Rendu de la landing (server/client split)
La home (app/[locale]/page.tsx) est optimisée pour le mobile : seules les
parties interactives sont hydratées.
LandingClient("use client") orchestre la recherche (idle → loading → results) et reçoit les sections statiques enchildren.LandingSections(Server Component) rend tout ce qui est sous le hero (« comment ça marche », services, FAQ, CTA, footer) → hors du bundle client. La seule île interactive sous le hero estLandingFaqItem(accordéon).LandingIdlene garde que le hero (logo, recherche, exemples) + le slot{children}. Le chevron de scroll est une ancre#comment-ca-marche(html.scroll-smooth).- La géolocalisation est demandée paresseusement à la 1re interaction de recherche (focus/submit), pas au montage — pas de prompt de permission au chargement.
i18n (next-intl v4)
Source de vérité = apps/web/messages/fr.json. apps/web/i18n/messages.d.ts
augmente le type de t() contre ce fichier → une clé inconnue casse au
typecheck.
- Server :
const t = await getTranslations("Namespace"). - Client :
const t = useTranslations("Namespace"). - Liens locale-aware : importer
Link,useRouter,redirectdepuis@/i18n/navigation, jamaisnext/link/next/navigation(perte du préfixe et des pathnames localisés). - Workflow : éditer
fr.json→pnpm --filter @pambe/web i18n:translate(Mistral remplit nl/en) →pnpm --filter @pambe/web i18n:check.
Deux providers (payload scopé)
Le catalogue est servi par deux NextIntlClientProvider pour garder les
pages publiques (SEO/perf) légères :
- Racine (
app/layout.tsx) — ne ship que les namespaces utilisés par les Client Components publics ; exclut les namespaces serveur-only et auth-gated-only (dontDashboard). - Auth-gated (
app/app/layout.tsx) — ré-injecte le catalogue complet. next-intl remplace (ne fusionne pas), donc le sous-arbre/app/*a tout.
Conséquence : un composant partagé public ne doit pas lire un namespace
auth-gated. C'est pourquoi shareText/shareTitle vivent dans ServiceDetail,
copied/failed dans Share, et la note (notRated/reviewCount) dans un
namespace Rating dédié. messages.d.ts typant sur fr.json, une clé
mal placée casse au typecheck.
Détails normatifs : Conventions → i18n.
Le kit dashboard
apps/web/components/dashboard/* est un kit partagé par /app/tasks et
/app/services. Même pattern : un feed de story-cards en grille + barre de
filtres, détail ouvert dans un Drawer (vaul). Les tokens de motion
(TRANSITION, HOVER_LIFT, SOFT_SHADOW, BRAND_GRADIENT, …) vivent dans
apps/web/lib/motion.ts et sont toujours réutilisés plutôt qu'inlinés.
StatusBadge est la source de vérité des pills de statut des demandes
(a-valider / en-cours / terminee). Un service, lui, est
active/inactive (toggle prestataire) — ne jamais propager un statut de
demande sur un service. Les KPIs « N à valider · N en cours » d'une carte service
sont des compteurs des demandes liées, pas un statut du service.
Règle backend-honest
Chaque élément visible du dashboard trace vers un vrai champ de
lib/dashboard/data.ts ou lib/dashboard/services-data.ts. Pas de badge /
compteur / KPI inventé. Les pages se resynchronisent via router.refresh().