Aller au contenu

Front-end Conventions

Regle absolue

Ne JAMAIS refactoriser du code existant sauf demande explicite. Les conventions ci-dessous s'appliquent au nouveau code uniquement.

DO NOT

  • DO NOT importer Text depuis react-native → utiliser Text de @bricks-common/uimmo/components/Text
  • Importer uimmo uniquement par subpath niveau 1 (@bricks-common/uimmo/components/HintText, @bricks-common/uimmo/components/buttons, @bricks-common/uimmo/components/modals, @bricks-common/uimmo/helpers) — il n'y a pas de barrel racine (@bricks-common/uimmo tout court n'est pas exporté), pour que TurboSnap trace un graphe statique sans tout relier.
  • DO NOT utiliser Box → utiliser View de react-native avec className
  • DO NOT utiliser TouchableArea → utiliser Pressable de react-native avec className
  • DO NOT utiliser BricksBottomSheet ou state local pour les modals → useModals().openModal()
  • DO NOT utiliser Alert.alert pour une confirmation → ConfirmModal uimmo via openModal(ConfirmModal, { title, description, confirmLabel, onConfirm })
  • DO NOT créer de modale de confirmation custom pour un simple confirm/cancel → ConfirmModal uimmo (modale module dédiée uniquement si le flow dépasse ce pattern)
  • DO NOT wrapper le contenu d'une modale dans un ScrollView → le composant modal de uimmo gère le scroll nativement
  • DO NOT hardcoder de texte → clés i18n
  • DO NOT appeler le MCP SimpleLocalize pour créer/mettre à jour/publier des traductions → éditer les JSON locaux uniquement (sync CI automatique, cf. section i18n)
  • DO NOT importer formatCurrency/formatPercentage depuis @bricks-common/helpers → hooks useCurrencyFormatter/usePercentageFormatter
  • DO utiliser l’API OO expo-calendar (createCalendar, getCalendars, calendar.createEvent, event.openInCalendar, …) — le root SDK 56+ throw sur les free functions legacy ; ne pas réintroduire expo-calendar/legacy sauf migration temporaire
  • DO NOT utiliser axios/fetch directement → processHttpResult + customAxios
  • DO NOT inliner les query keys → query keys centralisées par module (queryKeys.ts)
  • DO NOT dupliquer les données React Query dans Zustand, ou le state useForm dans useState
  • DO NOT hardcoder de valeurs de shadow → tokens du thème (shadow-xs à shadow-lg)
  • DO NOT utiliser d'unités en pixel → valeurs sans unité (référentiel React Native)
  • DO NOT utiliser style={{}} inline → className avec Uniwind
  • DO NOT éditer uniwind-theme.css manuellement → npm run regenerate:css -w @bricks-common-front/theme
  • DO NOT mettre de logique métier dans les fichiers de route (src/app/)
  • DO NOT définir les headers dans le JSX des screens → les définir dans les layouts ou dans le screen container
  • DO NOT utiliser space-lift update() pour muter des objets → produce() d'immer
  • DO NOT utiliser les classes de position -0 → tokens -none (left-none, right-none, etc.)
  • DO NOT utiliser inset-0inset-none
  • DO NOT utiliser router.push() → utiliser router.navigate() (push empile un doublon si l'écran est déjà dans la stack)
  • DO NOT utiliser useLocalSearchParams() brut pour récupérer des search/query params → useValidatedSearchParams(validator, default) (@hooks/useValidatedSearchParams) avec un validator idonttrustlikethat. Évite les as dangereux et garantit une valeur typée même si l'URL est trafiquée

Styling

  • cn() (tailwind-merge + clsx) pour combiner les classes conditionnelles
  • match() de ts-pattern pour les classes variant-based (pas d'objet Record)
  • LinearGradient et SafeAreaView depuis @bricks-common/uimmo/helpers
  • Shadows : uniquement tokens shadow-xs, shadow-sm, shadow-md, shadow-lg, shadow-none
  • Z-index : z-status-bar, z-app-screen-header (tokens), ou numériques z-1, z-10
  • Typography : variant-heading-{xs|sm|md}, variant-text-{sm|md}
  • Couleurs texte : text-default-heading, text-default-text, text-disabled-text, text-alert-text
  • Trop de classes visuelles (bg, border, shadow, overflow) = extraire un composant. Positionnement inline OK (p, m, flex, gap)
  • Après modification du thème : npm run regenerate:css -w @bricks-common-front/theme
import { Pressable, View } from "react-native"
import { Text } from "@bricks-common/uimmo/components/Text"
import { cn } from "@bricks-common/uimmo/helpers"

<View className={cn("p-lg gap-md", isActive && "bg-brandPrimary")}>
  <Text className="variant-heading-md text-default-heading">Titre</Text>
</View>

Components

  • Shared (réutilisable) : Dumb + Storybook obligatoire → ComponentName/index.ts + ComponentName.tsx + ComponentName.stories.tsx
  • Screen complexe : 3 fichiers — Screen.tsx (smart, hooks) + ScreenUI.tsx (dumb, props) + ScreenLoading.tsx (skeleton). Storybook sur le UI (voir section Storybook ci-dessous pour le choix du pattern)
  • CRUD simple : un seul fichier suffit
  • Préférer les composants Dumb (props) pour garder le contrôle côté parent (ex: éviter qu'un composant return null tout seul si le parent a besoin de contrôler le layout)
  • Les composants Smart sont OK quand ça simplifierait vraiment trop de tout passer en props
  • Si deux blocs visuellement identiques → extraire dans un composant partagé
  • Pas de magic numbers → extraire en const nommée (ex: const ICON_ASPECT_RATIO = 1.5)
  • Utiliser Divider de uimmo pour les séparations (pas de bordures custom)
  • Tests Storybook : findByRole("button") (pas findByText()). Ne pas tester aria-busy (Button d'uimmo ne l'expose pas)
  • Tests Storybook ciblant une modale (ou tout contenu rendu en portal hors du canvas) : within(canvasElement.ownerDocument.body), pas within(canvasElement) — la modale est mountée hors du canvas, le scope par defaut ne la voit pas

Storybook

MCP Storybook (storybook-dev) : docs composants, instructions stories, run-story-tests ciblé, previews — voir storybook-mcp.mdc.

  • DO ancrer les fixtures date-sensibles sur parameters.mockingDate (defaultMockingDate) — dates dérivées du mock (pas du wall clock), sinon les stories future-only cassent avec le temps

Regle de base : une Story render correctement uniquement si le composant story-ifie est 100% dumb. Des qu'un enfant appelle l'API, un hook connecte (user, feature flags, etc.) ou un store global, la Story casse.

DO NOT appeler une API authentifiee (customAxios, Mapbox proxy, etc.) depuis un composant storifie — injecter le fetch en prop (comme upload / searchAction) et mocker dans la Story. Une API publique ouverte pouvait vivre inline ; plus maintenant.

DO NOT mettre en Story un ScreenUI dont des sous-sections appellent encore l'API ou des hooks connectes — la Story render pas. Choisir un des deux patterns ci-dessous.

Choisir le pattern selon la maniere dont la data est fetchee dans le screen :

  • Pattern A — Container + slots (sections independantes) : quand plusieurs sections ont chacune leur propre fetch React Query. Le Screen.tsx injecte les sections connectees (<BalanceBlock />, <TransactionsBlock />) en props/slots d'un Container qui ne gere que le layout. Les Stories se font sur le Container (en lui passant des BlockUI dumb en slots) + chaque section UI independamment. Optionnel : une Story "screen reconstitue" qui rejoue toutes les sections UI ensemble. Exemples : projects/front-mobile-app/src/modules/wallet/screens/WalletScreen/, projects/front-mobile-app/src/modules/security/
  • Pattern B — Smart parent + dumb sections : le Screen.tsx porte hooks/data ; sections (et sous-blocs) 100% props. Un seul fetch, ou plusieurs sources quand les sous-blocs restent volontairement UI-only sous une section (evite des smarts intermediaires). Exemples : projects/front-mobile-app/src/modules/boostedBalance/screens/BoostedBalanceScreen/, projects/front-mobile-app/src/modules/project/screens/ProjectScreen/, PDP AccountScreenSecuritySection + TwoFactor/ChangePassword/ConnectedDevices

Pattern A resiste mieux quand des sections evoluent/fetchent independamment. Pattern B quand on veut une seule frontiere smart (screen). Preférer A si 2+ sections smarts independantes ; B si le screen (ou une section UI) compose des sous-blocs dumb.

Pour les sections d'un screen Pattern A : exporter un *Preview depuis *UI.stories.tsx, utiliser securityContainerDecorator('slotName') (ou equivalent) + excludeStories sur les helpers exportes. Ne jamais monter le smart component dans une Story.

  • DO ajouter excludeStories dans le meta des *UI.stories.tsx Pattern A des qu'il y a un export helper (*Preview, mock*, decorator…) ou un export type { *Props } — Storybook les affiche sinon comme stories dans la sidebar
  • DO lister explicitement chaque nom exporte non-story : excludeStories: ['MySectionPreview', 'MySectionUIProps']
  • DO copier le pattern de reference : projects/front-mobile-app/src/modules/security/components/TwoFactorAuthSection/TwoFactorAuthSectionUI.stories.tsx
  • DO NOT oublier *UIProps dans excludeStories quand le fichier finit par export type { MySectionUIProps }
  • Pas necessaire sur les stories modal/screen directes sans export helper (ex: ChangePasswordModal.stories.tsx qui ne exporte que des stories)
export const MySectionPreview = (props: Partial<MySectionUIProps>) => (/* ... */)

const meta: Meta<typeof MySectionUI> = {
  component: MySectionUI,
  excludeStories: ['MySectionPreview', 'MySectionUIProps'],
  // ...
}

export type { MySectionUIProps }

Stories de formulaire (flows)

Un *ScreenUI/modal de formulaire (dumb, qui reçoit submitAction/onSuccess en props — cf. section Forms) se story-ifie en un flow jouable par chemin : la Story prouve que l'erreur apparaît bien suite au submit, pas avant. Réf : projects/app-pdp-financement/src/modules/auth/screens/LoginScreen/LoginScreenUI.stories.tsx et .../SignupScreen/SignupScreenUI.stories.tsx.

Stories à couvrir : - Default : args mockés en succès (submitAction: fn(async () => { await sleep(MOCK_DELAY_MS); return ... })), pas de play — juste rendu interactif - FlowSuccess : fill in play from empty (not initialData/initialValues) so the happy path is proven end-to-end — prefill only for Default/Filled/error setup ; puis waitFor sur submitAction + onSuccess - Un FlowError* par chemin d'erreur possible, jamais un seul "catch-all" : - validation cliente (ex. FlowErrorInvalidEmail, FlowErrorPasswordsMismatch) → assert le message + expect(args.submitAction).not.toHaveBeenCalled() - erreur API mappée champ-spécifique (ex. FlowErrorUserAlreadyExists) → override submitAction qui throw { data: { message: 'auth.CODE' } }, assert le message sous le champ ciblé par setError - erreur root générique (FlowErrorGeneric) → throw { status: 500 }, assert le message générique (root HintText)

Mécanique : - Simuler une erreur API/Better Auth en overridant submitAction dans les args de la Story : fn(async () => { await sleep(MOCK_DELAY_MS); throw { data: { message: 'auth.INVALID_EMAIL_OR_PASSWORD' } } }) (code mappé i18n) ou throw { status: 500 } (générique) - Helper local partagé fillAndSubmit(canvasElement, { ...overrides, beforeSubmit }) : beforeSubmit assert l'absence du message AVANT submit (expect(canvas.queryByText(msg)).not.toBeInTheDocument()), puis après submit expect(await canvas.findByText(msg)).toBeInTheDocument() assert sa présence — c'est ce qui prouve que l'erreur est déclenchée par le submit - Scope within(canvasElement.ownerDocument.body) (le form/modale render hors canvas) - Cibler le submit par findByRole('button', { name }) ; si un autre bouton porte le même label (ex. switch de l'AuthHeader), prendre le dernier match (findAllByRole(...).at(-1))

Modals

  • Ouvrir : useModals().openModal(MyModal) ou openModal(MyModal, { props })
  • Fermer : useCloseModal()
  • Confirmation simple (titre + description + confirm/cancel) : openModal(ConfirmModal, { title, description, confirmLabel, onConfirm })
  • Utiliser le composant modal adaptatif de uimmo (s'adapte mobile/web). Ne pas utiliser BottomSheetModal directement
  • Props utiles : title, isDismissible, footer, hideHandlebar

Forms

  • Validator de formulaire ≠ validator d'API : ils ont deux jobs (UX i18n par champ vs safety du wire). DO NOT importer le schéma API (@bricks-common/api-communication-*) pour le passer à useForm — redéclarer le schéma front même s'il duplique des valeurs (enums, brands). Avec zod le error: 'i18n.key' se passe au constructeur (z.enum, z.string, ...) : .refine sur un schéma partagé n'override pas le message par défaut. Réf : projects/app-pdp-financement/src/modules/financing-request/presentation/components/sections/PresentationSection/utils.ts
  • DO factoriser les règles date future/past via shared/utils/date/dateComparisons (predicates) + shared/utils/form/dateValidators (builders Zod) — pas de refine dayjs inline ni d'extension de z.iso
  • DO saisir les dates calendaires via DateTextInput (value form YearMonthDayDate | undefined) — pas de TextInput + masque ; wire jour = YearMonthDayDate (passthrough) ; conversion ISO datetime uniquement si le contrat l'exige (fundsNeededByDate, invoiceDate)
  • schemaResolver(validator, errorT) avec validators idonttrustlikethat ; pour les validators zod, zodSchemaResolver(schema, errorT) (même rôle : traduit le code de validation en message dans le resolver)
  • errorT = useTranslation("translation", { keyPrefix: "errors" }) (ou la ns de l'app, ex. "appPdpFinancement")
  • Le resolver traduit déjà : error?.message est rendu direct (error={error?.message}). DO NOT re-traduire le message au niveau du champ (pas de helper type getInputErrorMessage qui re-préfixe errors. → double clé / message brut)
  • DO NOT garder les erreurs derrière isSubmitted (error={isSubmitted ? error?.message : undefined}, {isSubmitted && errors.root ? …}) → afficher directement error?.message / errors.root. mode: "onSubmit" + reValidateMode: "onChange" garantissent déjà qu'aucune erreur n'apparaît avant la 1re soumission, donc le guard est redondant
  • Utiliser le prop error du TextInput (pas de HintText manuel)
  • Bouton submit : loading={isSubmitting}, jamais disabled={!isDirty}
  • Config useForm : mode: "onSubmit", reValidateMode: "onChange" (pré-requis pour pouvoir afficher les erreurs sans guard isSubmitted)
  • Erreurs root API : errors.root.message rendu direct ; auto-clear via useClearRootErrorOnChange(form) plutôt qu'un clearErrors('root') manuel en tête de submit
  • DO NOT rendre errors.root (ni une autre erreur cross-champ) DANS le render d'un <Controller> → un Controller ne re-render que sur changement de SON champ, donc un setError('root') ne s'affiche pas. Rendre errors.root au niveau du form (hors Controller)
  • DO câbler field.ref (de Controller/useController) jusqu'à l'élément natif focusable du champ → le shouldFocusError natif de RHF focus/scroll vers le 1er champ invalide au submit, mais seulement si le ref est branché. Sans ref, l'erreur s'affiche mais l'utilisateur ne voit pas où. render={({ field: { onChange, value, ref } }) => <TextInput ref={ref} … />}
  • DO exposer un prop ref sur chaque composant input custom (NumberStepper, SelectTrigger, PhotoPicker, AutocompletePicker…) et le forwarder à l'élément natif sous-jacent (TextInput/View/Pressable) pour rester branchable sur field.ref. Typer ref?: Ref<View> ou ref?: Ref<ComponentRef<typeof TextInput>>. Composant Pressable ciblé par le focus → ajouter accessibilityRole="button". Réf commit Error focus submit (projects/app-pdp-financement/src/modules/financing-request/presentation/)
  • DO exposer une seule ref sur AmountInput / NumberInput / DateTextInput : handle (setValue / setValueFromPrevious) + focus() (pour RHF shouldFocusError). Pas de controllerRef / inputRef public — le focus natif est mergé dans ce handle. Les inputs sous-jacents (TextInput, UnderlineTextInput) exposent aussi ref (pas inputRef) vers le champ natif
  • Screen smart → UI : passer la mutation brute en submitAction + un onSuccess (PAS un onSubmit qui combine action + effets). Le smart ne met pas de try/catch — la gestion d'erreur vit dans le catch de l'UI (root par défaut, ou champ-spécifique comme Signup USER_ALREADY_EXISTSsetError('email')). Passer la mutation brute signale explicitement que l'erreur est traitée en aval. submitAction typé (data) => Promise<unknown> (ou Promise<TResult> si onSuccess a besoin du résultat). onSuccess optionnel quand le succès est interne à l'UI (ex. ForgotPassword setEmailSent(true)). Réf : projects/app-pdp-financement/src/modules/auth/screens/LoginScreen/
  • Pattern UI : const submit = handleSubmit(async (data) => { try { const r = await submitAction(data); await onSuccess?.(r, data) } catch (e) { setError('root', { message: buildApiErrorMessage(e) }) } })onSuccess dans le try pour que ses erreurs post-succès s'affichent comme erreur de form

Data Fetching

  • Hooks dans src/api/ ou src/modules/{module}/services/, nommés use{Action}.ts
  • Query keys centralisées par module dans services/queryKeys.ts
  • useQuery / useMutation / useInfiniteQuery avec processHttpResult + customAxios
  • Endpoints depuis @bricks-common/api-communication
  • Early returns pour loading/error : skeleton (pas spinner), puis ErrorScreen avec onRetry={refetch}
  • Après les guards, data est garanti non-undefined
  • Cache : staleTime: 0 par défaut. refetchOnMount: false si données déjà fetchées plus haut. refetchOnWindowFocus: true pour données globales (user, FF)
const { data, isLoading, isError, refetch } = useProperty(id)
if (isLoading) return <PropertySkeleton />
if (isError || !data) return <ErrorScreen onRetry={refetch} />
// data garanti non-undefined ✅

i18n

  • Clés hiérarchiques par module : modules.{module}.screens.{screen}.{key}, common.{action}, {feature}.{element}
  • Toujours utiliser keyPrefix pour scope le t() au module : useTranslation("translation", { keyPrefix: "modules.{module}.screens.{screen}" })
  • Interpolation {{value}}, pluralisation _one/_other
  • Trans pour phrases avec formatage (bold, liens). Pas de split de string en morceaux séparés
  • useCurrencyFormatter / usePercentageFormatter pour tout formatage localisé
  • Storybook : namespace explicite "translation" (défaut = "uimmo")
  • Jamais concaténer des morceaux de string, jamais hardcoder la locale
  • Source de vérité = fichiers locaux (locales/*.json, uimmo/i18n, translations-web). DO NOT créer/pousser/publier via MCP SimpleLocalize — le workflow i18n-push-to-simplelocalize sync vers le cloud au merge sur develop

Routing

  • Fichiers route dans src/app/ : exportent les screens uniquement (export { Screen as default })
  • Layouts (_layout.tsx) : config navigation, providers, headers
  • Pattern Provider/Content : RootProvider wraps children, RootContent utilise les hooks
  • Stack.Protected avec guard={boolean} : true = accessible
  • Headers : dans les layouts ou dans le screen container (avec le web, les headers dans les layouts sont pas toujours adaptés)
  • Search/query params : obligatoire useValidatedSearchParams(validator, default) (@hooks/useValidatedSearchParams) avec un validator idonttrustlikethat. Pas de useLocalSearchParams() brut + cast as (l'URL est user-controlled, jamais à trusted aveuglément)
  • Tout screen route doit être wrappé avec withUnmountUnfocusedScreen (@invest-shared/utils/navigation/withUnmountUnfocusedScreen) → sur web le screen retourne null quand il n'est pas focused (sinon Expo Router empile les screens sans les démonter). No-op sur natif. Ne pas wrapper les _layout.tsx ni les routes purement Redirect
  • app-pdp-financement suit la même structure de routing Expo Router
import { LoginScreen } from '@invest-modules/auth/screens/LoginScreen'
import { withUnmountUnfocusedScreen } from '@invest-shared/utils/navigation/withUnmountUnfocusedScreen'

export default withUnmountUnfocusedScreen(LoginScreen)
import { object, union } from 'idonttrustlikethat'
import { uuid } from '@bricks-common/api-communication'
import { useValidatedSearchParams } from '@hooks/useValidatedSearchParams'

const paramsValidator = object({
  tab: union('revenues', 'properties').optional().default('revenues'),
  propertyId: uuid.optional(),
})

const PARAMS_DEFAULT = { tab: 'revenues' as const }

const { tab, propertyId } = useValidatedSearchParams(paramsValidator, PARAMS_DEFAULT)

State

  • Funnel multi-phases (ex. espace financement) : useFunnel(project) — apporteur lu dans le provider, pas re-passé aux call-sites. funnel.steps = statuses (API + signature front-only + override offre apporteur) ; getPhase1/getPhase2/getAll = ordre. Redirects via getCurrentStep. Ne pas relire project.steps ni appeler getFunnel(..., { isApporteurAffaires }) hors du hook. Listes de projets (ongoing / index redirect) : filtrer sur project.status, pas sur le funnel. Apporteur strips finalization/signature et traite offer draft comme completed. borrower.isDeferred → emprunteur en phase 2 après offer. Offre apporteur : fork avant le smart fetch (ProjectOfferScreenProjectOfferApporteurScreenUI, sans useProjectOffer).
// Bad — bypass funnel or pass apporteur manually at call-sites
const analysisStatus = project.steps.analysis?.status

// Good
const funnel = useFunnel(project)
const isOfferDone = funnel.isCompleted('offer')
const disabledSteps = funnel.getDisabledSteps()

// Good — project list (macro status, not steps)
projects.filter((p) => p.status !== 'completed')
  • Données serveur : React Query (jamais dupliquer dans Zustand)
  • UI local : useState
  • Partagé parent-enfant : useState parent + props, ou Context si profond
  • Global client : Zustand (src/store/ ou src/modules/{module}/store/)
  • Formulaires : useForm (react-hook-form), jamais dupliquer dans useState
  • Stockage persistant : MMKV pour clé-valeur rapide, AsyncStorage pour cache React Query

Performance

  • React Compiler est active — il memoize automatiquement composants, callbacks et valeurs au build time
  • DO NOT ajouter memo, useMemo, useCallback manuellement — le compiler s'en charge
  • DO NOT wrapper un composant avec React.memo()
  • Fonctions et objets inline en props sont OK — le compiler les memoize
  • Retirer progressivement les memo/useMemo/useCallback existants quand on touche un fichier
  • Opt-out si un composant pose probleme : "use no memo" en premiere ligne de la fonction
  • expo-image pour images (cache intégré)
  • Éviter gradients pleine hauteur dans ScrollView → borner à une hauteur fixe
  • triggerHapticFeedback() sur interactions (import depuis @bricks-common/uimmo)

Cross-Platform & Responsive

  • Safe areas : utiliser la value safe de Uniwind (ex: pt-safe, pb-safe) plutôt que SafeAreaBox
  • .web.tsx pour code spécifique web
  • isAndroid, isIOS depuis @constants/platform pour checks plateforme
  • Responsive styling : classes Uniwind breakpoint (hidden md:flex, p-md xl:p-xl). Préférer les classes breakpoint au hook useBreakpoint quand possible
  • Responsive rendering (show/hide composant entier) : useEqualOrAboveBp('lg') ou useEqualOrAboveBp('xl') — uniquement quand on doit conditionner le rendu
  • Container max-width web : className="w-full max-w-container-2xl mx-auto px-2xl xl:px-5xl"
  • Navigation : router.navigate() standard, useGoBack(fallbackRoute) pour retour safe
  • Helpers réutilisables cross-app → les mettre dans un package @bricks-common (pas dupliquer entre apps)
  • Layout RN = moteur Yoga, pas CSS. En cas de probleme de layout, penser Yoga (flexbox RN) avant de tenter des approches CSS web