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
  • DO NOT importer Image depuis expo-image pour className → Image de @bricks-common/uimmo/helpers (withUniwind). Le brut n'applique pas Uniwind
  • DO NOT colocaliser des PNG/JPG à côté d'un composant (./assets/) → assets/images/{domain}/ + @invest-assets/... (hors src, convention Expo)
  • 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 hook API (React Query / customAxios) dans sections / composants — Screen ou provider only. Les props restent live.
  • DO NOT utiliser un state local pour les modals → useModals().openModal(). Exception : contenu live (polling) → JSX local sous le ScopedTheme (useModalTheme hérite d'Uniwind), pas de wrap ModalProvider ni hook API
  • DO NOT utiliser Popover pour une courte explication (onglet désactivé, icône info…) → Tooltip uimmo (@bricks-common/uimmo/components/Tooltip) : hover sur desktop, click/press quand hover n'est pas dispo. Popover reste pour contenu riche / actions
  • 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 créer components/blocks/ si le module a déjà sections/ — même rôle (région d'écran) ; la UI va dans sections/
  • DO NOT wrapper le contenu d'une modale dans un ScrollView / FlatList RN → Modal wrappe avec ModalScrollView. Liste virtuelle : ModalList
  • DO NOT hardcoder de texte → clés i18n. Exceptions : préfixe / ponctuation autour d'une valeur déjà formatée (/ 500 000 €) — pas une clé "/ {{amount}}" ; copy métier fundraising (title, headline, quote, body) dans get…SectionData — spécifique campagne, pas chrome. t() = chrome only
  • 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 de valeur arbitraire sans unité pour font-size / line-height / margin / padding / gap (text-[32], leading-[35], mb-[18]) → sur web line-height: 35 est un multiplicateur (×14px = 490px), text-[32] est supprimé par cn() (tailwind-merge le confond avec text-[#hex]), le spacing sort sur l'échelle Tailwind. Préférer les variants / tokens ; si une valeur arbitraire est inévitable, mettre l'unité : text-[32px] leading-[35px] (Uniwind convertit en dp sur natif). Les crochets sans unité restent OK pour size-[65], h-[37], rounded-[12]
  • 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-0 → inset-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). Investisseur : @hooks/useValidatedSearchParams + idtlt. PDP : @shared/router/useValidatedSearchParams + zod. É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)
  • Image, LinearGradient et SafeAreaView depuis @bricks-common/uimmo/helpers (withUniwind — className Uniwind)
  • Shadows : tokens shadow-xs à shadow-lg sur fond clair. Sur defaultHeading / dark, ces tokens (noir 4–8%) sont invisibles. theme.colors.black est du navy (blue.950), pas du #000 — ombre Figma noire : rgba(0, 0, 0, 0.5) en dur
  • 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}
  • Mention de conditions / disclaimer sous un texte courant : un cran plus petit (variant-text-xs) et couleur atténuée — elle ne doit pas se lire comme la suite du corps de texte
  • Couleurs texte : text-default-heading, text-default-text, text-disabled-text, text-alert-text. Placer la couleur après le variant-* (variant-heading-md text-white) : sur Android le color du variant gagne s'il est après. Si le variant change à un breakpoint, répéter la couleur à ce breakpoint (xl:text-white) — un variant-* préfixé écrase un text-* sans le même breakpoint
  • 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
  • Feature UI partagée entre apps (What's New, etc.) → uimmo jusqu'au bloc dumb (GroupedHighlights + GroupedHighlightsLoading / HighlightCardLoading / EmojiReactionsLoading), jamais le screen ni un hook API. Fixtures stories dans uimmo (fixtures.ts), réutilisées par les stories screen des apps. Skeleton de page = même chrome que le ScreenUI (container partagé) + loading groupé, pas de chrome carte dupliqué. Primitives réutilisables (galerie, réactions emoji) = composants uimmo standalone, composés par le bloc feature. Réaction What's New = l'emoji (pas d'id like) ; allowlist d'affichage dans uimmo (DISPLAYED_WHATS_NEW_REACTIONS), pas dans le contrat API
  • What's New date d'item : < 7 jours calendaires → fromNow + Tooltip de la date complète (itemDate) ; sinon date complète seule. relativeTime + dayjs.locale restent à l'init app / Storybook (dateInit) — pas d'extend ni de locale dans le helper. Ne pas envelopper le relatif dans itemDate (« Le il y a 3 jours »)
  • 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
  • DO NOT suffixer UI sans split smart/dumb — FundraisingAmountStep, pas FundraisingAmountStepUI. *UI seulement si un smart frère existe (FundraisingPurchaseModal + FundraisingPurchaseModalUI)
  • DO NOT splitter banner/modale accountStatus en *UI + smart. Fichiers à plat (FrozenBanner + FrozenModal). Stories sur la banner (Default + OpenModal play) — pas de stories dédiées à la modale (exportée pour le wall).
  • DO NOT réutiliser le wording incomplete (« quelques clics de pouvoir investir ») sur RecertNeeded — l'user peut déjà investir (verified+update, pas outdated).
  • Modale métier PDP : canvas = bouton « Ouvrir » (createOpenModalButton / waitForOpenModal) — jamais de mount canvas ni useEffect auto-open. Default DOIT play : waitForOpenModal timeout 5000 sur un contenu métier (button CTA/option, sinon titre paragraph — findByText encapsulé, le rôle n'a pas de nom ARIA) — pas « Fermer » (absent en sheet dismissible + title). Chromatic snapshot l'état ouvert. Pas de paire Default fermé + OpenModal/ChooseStep/InfoStep. Stories extra seulement pour d'autres états (FormStep, Flow*, Empty, WithSelection…)
  • 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)
  • DO NOT recopier FormField / ResponsiveInputs / Dropzone / CheckboxCard / SelectDatePicker / EmptyState / PlaceholderBox dans une app — ils vivent dans uimmo
  • PDP modules : screens/ layouts/ sections/ modals/ components/ à la racine du module. Route → screens/ ; wrappe les routes OU scaffold *Container → layouts/ ; openModal → modals/ ; région d'écran → sections/ ; leaf UI → components/. Doute → components/ (les autres buckets affinent la lecture d'un screen, pas un mur). Pas de atoms/ / templates/. Shared plat (shared/components/{Name}/). Storybook title = path disque
  • 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. Title : strip UI (…/sections/NavBar, pas NavBarUI)
  • Canvas Chromatic stories de modales PDP : wrapper h-full w-full dans createOpenModalButton + meta layout: 'fullscreen' (openModalStoryParameters) — centered shrink-wrap le bouton ; h-100/w-100 sont restricted (pas des tokens thème)

Storybook

MCP Storybook (storybook-dev) : docs composants, instructions stories, run-story-tests ciblé, previews — voir storybook-mcp.mdc. Autodocs est opt-in : tags: ['autodocs'] sur le meta (réf. Button.stories.tsx). Chromatic publie ce Storybook : garder RDT, heap Node 6144 (CI + Docker) — le défaut 2 Go OOM. skip: Docs = pas de snapshot visuel, les pages Docs restent dans le build. Vite suffixe ?v= aux filenames Babel : le plugin worklets (0.8.3) readFileSync ce path pour les sourcemaps → ENOENT / crash esbuild. Storybook web : ['react-native-worklets/plugin', { disableSourceMaps: true }].

  • 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
  • DO opt-in light/dark Storybook via parameters: { themeSwitcher: true } — Theme control shows only on those stories. Uniwind.setTheme leaks across stories`
  • DO NOT poser themeSwitcher / globals: { theme: 'dark' } sur un composant qui wrap déjà ScopedTheme theme="dark" (ex. FundraisingUpcomingCard, FundraisingContainer) — le dark est intrinsèque, le switcher / globals ne fait que mentir

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 value-importer depuis un fichier service qui init customAxios (classe d'erreur instanceof, helpers) dans un composant storifie — mock Storybook loadEnv sans network.apiURL → Chromatic crash. Extraire hors du service (cf. publishNewsUploadError, finalizationDocumentUploadError) ; types injectés dans un types.ts à part. DO NOT importer un barrel de screen depuis un composant storifié — le barrel réexporte smart / customAxios et Vite les évalue tous. Pointer le fichier feuille (…/NewProjectForm, …/MoreScreenUI). DO NOT modifier le code app (ex. ThemeProvider qui importe DefaultTheme depuis expo-router) pour contourner un mock Storybook incomplet — compléter le mock (expoRouterMock re-exporte depuis @react-navigation/native)

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 AccountScreen → SecuritySection + 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 (écran) : args mockés en succès (submitAction: fn(async () => { await sleep(MOCK_DELAY_MS); return ... })), pas de play — rendu interactif. Modale PDP : Default.play ouvre (cf. Components) - 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)). Modale : cliquer d'abord le bouton canvas openModal - PDP wizard screen : FlowError client = tout le form en erreur. FlowError API submit au screen seulement s'il n'existe pas déjà sur une section CTA

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, onBack (étape nested : flèche gauche header, hardware back Android)
  • Liste virtuelle → ModalList. Contenu scrollable → Modal. DO NOT imbriquer ScrollView / FlatList RN
  • Sans title : croix flottante sur ModalWindow (desktop) uniquement — bottom sheet : handle / swipe / backdrop. Ne pas réinventer un close dans le body
  • CTAs : enfant <ModalFooter> (sticky + safe area). DO NOT inliner les boutons dans le body — en bottom-sheet le footer overlay le contenu, donc bg obligatoire
  • Modales accountStatus : pas de title chrome sauf étape aide (error / lock) → title help. Heading dans le body. Croix flottante desktop si pas de titre
  • Freeze : HelpCenterContactRows inline (pas d'étape aide, pas de footer). Error / lock : HelpCenterContent in-sheet, Modal title help + onBack (flèche header). DO NOT footer Retour. DO NOT hideTitle. DO NOT openModal(HelpCenterModal) par-dessus une modale déjà ouverte
  • CTA depuis un écran (auth « Besoin d'aide ? », Lemonway compte doublon) : openModal(HelpCenterModal) tous breakpoints, pas Zendesk. title chrome + HelpCenterContent (heading body) — même contenu que l'étape aide

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/sections/PresentationSection/utils.ts
  • DO factoriser les règles date future/past via shared/date/dateComparisons (predicates) + shared/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)
  • DO picker calendrier en modal via SelectDatePicker uimmo (comme AutocompletePicker) — pas de SelectTrigger + openModal local. minDate / modalTitle au call-site
  • DO picker catégorie PDP via SelectCategoryPicker (@shared/components) — options et copy internes, pas de tableau options au call-site. Ne pas le mettre dans uimmo.
  • Un champ sans valeur « vide » naturelle (enum, montant) démarre à undefined, alors que les champs texte démarrent à ''. Le type des valeurs du formulaire ne doit donc rendre optionnels que ces champs-là — jamais un Partial<> sur tout l'objet, qui force des value ?? '' partout et des casts au submit. Le validator reste strict et le resolver garde la charge de la complétude. Réf : CompanyInfoSection/utils.ts (CompanyFormValues)
  • 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
  • DO NOT lire formState.isSubmitted / isSubmitting via useFormContext() pour piloter UI ou shouldValidate — le snapshot contexte reste souvent faux si le parent n'y souscrit pas → useFormState({ control }) (réf. PhotosSection, BankInfoSection, FinalizationCTASection)
  • DO lire les valeurs RHF qui pilotent le render avec useWatch({ control, name }). DO NOT watch() pour ça — React Compiler ne le track pas, les dérivés (CTA, montant restant) restent stale
  • 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, SelectDatePicker, SelectCategoryPicker…) 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_EXISTS → setError('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
  • PDP step autosave: flush pending useDelayed work on unmount so a last edit is not lost when the step unmounts; cancelAutoSave on submit/skip blocks that flush and later schedule but keeps pending args. resumeAutoSave on failure only lifts the block so unmount can flush. Skip resume when the step cache is already completed (complete ran, submit failed — PATCH 409). Do not let the debounce timer fire after unmount with a stale closure.

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 — les mutations aussi : sans lui, l'erreur reste un AxiosError brut et buildApiErrorMessage retombe sur « Request failed with status code 409 »
  • Codes d'erreur API : getApiErrorCode lit data.error (code kebab de apiErr côté back) puis data.message (code better-auth auth.*). Un nouveau code métier affiché à l'utilisateur → ajouter la clé dans apiErrors de fr.json, sinon message générique
  • Endpoints depuis @bricks-common/api-communication
  • DO nommer le solde wallet availableBalance dans le code app (analogue API availableWithdrawableBalance). Mapper le champ HTTP GET lemonwayBalance seulement à la frontière du hook — ne pas le propager dans les props métier
  • Early returns pour loading/error : skeleton (pas spinner), puis ErrorScreen avec onRetry={refetch}
  • Après les guards, data est garanti non-undefined
  • DO NOT guarder un screen authentifié sur useMe().isPending / useFeatureFlag().isLoading — UserProvider et FeatureFlagsProvider bloquent plus haut. Lire data / isEnabled directement
  • 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). What's New unread : fetch on mount à la racine auth + écran What's New ; pastilles / guards ailleurs refetchOnMount: false (lecture cache). GET latest : showErrorNotification: false — pastille, pas un toast si le fetch fail. lastSeen au blur via useFocusEffect seulement après affichage de la liste (native Stack ne unmount pas). Invest : MMKV prefs (logout clearAll la session seulement), clé scoped userId. PDP : MMKV session, logout ne le clear pas
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
  • DO NOT extraire en i18n une chaîne qui n’est que de la ponctuation + interpolation ("/ {{amount}}") — inliner / {amount} autour de la valeur déjà formatée
  • 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 })
  • DO NOT named-export ErrorBoundary depuis un barrel screen ou un fichier route — le filet Expo est le root src/app/_layout.tsx (PDP : @modules/shell/providers/ErrorBoundary). Un export par screen ferait croire à une convention que le reste de l’app n’applique pas
  • Layouts (_layout.tsx) : config navigation, providers, headers
  • Pattern Provider/Content : RootProvider wraps children, RootContent utilise les hooks
  • Stack.Protected avec guard={boolean} : true = accessible
  • DO NOT déclarer un Stack.Screen non protégé avant (public) / (authenticated) — Expo Router prend le premier écran disponible comme /
  • 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) — investisseur @hooks + idtlt, PDP @shared/router + zod. Pas de useLocalSearchParams() brut + cast as
  • Return post-login (invitation PDP) : MMKV setPostLoginRedirect / consumePostLoginRedirect — DO NOT passer redirectTo en query. Prefill email = /login?email= (canal séparé). consume() au passage session false → true dans le root layout — les screens login vivent sous (public) et se démontent dès que la session apparaît
  • Funnel /financing-request : y entrer uniquement après création d'un projet ou acceptation d'invitation — pas au login/signup
  • Tout screen route doit être wrappé avec withUnmountUnfocusedScreen (@invest-shared/utils/navigation/withUnmountUnfocusedScreen ; PDP : @shared/router/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) : getFunnel(project) — le rôle est lu dans project.role, jamais passé en option. funnel.steps = statuses API (+ override offre apporteur) ; getPhase1/getPhase2/getAll = ordre. Redirects via getCurrentStep. isProjectFinished = signature completed. Ne pas relire project.steps hors du funnel. Listes de projets (ongoing / index redirect) : filtrer sur project.status, pas sur le funnel. Apporteur strips finalization/signature (et borrower deferred en phase 2) et traite offer draft comme completed. borrower.isDeferred → emprunteur en phase 2 après offer (sauf apporteur : funnel s'arrête à offer). Offre apporteur : fork avant le smart fetch (ProjectOfferScreen → ProjectOfferApporteurScreenUI, sans useProjectOffer). Chrome FM (nav, layouts) : useIsApporteurAffaires() — membership du projet courant, avec repli sur le portefeuille dans le scope « tous les projets », où aucun rôle unique ne s'applique.
  • Phase 2 = forward-only (steps completed locked) → DO NOT forcer steps.<phase2Step>.status = 'draft' dans un patch cache autosave/upload (valable phase 1 seulement). DO NOT guard le submit avec isStepCompleted : une step completed n’est plus accessible. Le funnel phase 2 ne change que via la réponse complete / submit. Emprunteur : completeProjectBorrower / submitProjectFinancingRequest = HTTP only, exportés par useCompleteProjectBorrower. Cache dans chaque use — skip = submit ; différé = save + complete + unlock finalization (endpoint complete sans body). Complete phase 1 patche borrower completed juste après complete, avant submit — sinon un submit failed rejoue le complete (409). Analyse pending : masquer le résumé société si funnel.steps.borrower.isDeferred (champs vides).
// Bad — bypass funnel or re-derive the role at call-sites
const analysisStatus = project.steps.analysis?.status

// Good
const funnel = getFunnel(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
  • Images : Image de @bricks-common/uimmo/helpers (expo-image + withUniwind, 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