Aller au contenu principal

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

  • Publicapps/web/app/[locale]/. Toujours préfixé /fr|/nl|/en pour le SEO. Pathnames localisés (/about/a-propos).
  • Auth-gatedapps/web/app/app/. Pas de préfixe : le cookie NEXT_LOCALE écrit par le middleware intl tient la langue.
  • middleware.ts combine 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 en children.
  • 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 est LandingFaqItem (accordéon).
  • LandingIdle ne 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, redirect depuis @/i18n/navigation, jamais next/link / next/navigation (perte du préfixe et des pathnames localisés).
  • Workflow : éditer fr.jsonpnpm --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 (dont Dashboard).
  • 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.

Demande ≠ Service

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().