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
Textdepuisreact-native→ utiliserTextde@bricks-common/uimmo/components/Text - DO NOT importer
Imagedepuisexpo-imagepourclassName→Imagede@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/...(horssrc, 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/uimmotout court n'est pas exporté), pour que TurboSnap trace un graphe statique sans tout relier. - DO NOT utiliser
Box→ utiliserViewdereact-nativeavecclassName - DO NOT utiliser
TouchableArea→ utiliserPressabledereact-nativeavecclassName - 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 leScopedTheme(useModalThemehérite d'Uniwind), pas de wrapModalProviderni hook API - DO NOT utiliser
Popoverpour une courte explication (onglet désactivé, icône info…) →Tooltipuimmo (@bricks-common/uimmo/components/Tooltip) : hover sur desktop, click/press quand hover n'est pas dispo.Popoverreste pour contenu riche / actions - DO NOT utiliser
Alert.alertpour une confirmation →ConfirmModaluimmo viaopenModal(ConfirmModal, { title, description, confirmLabel, onConfirm }) - DO NOT créer de modale de confirmation custom pour un simple confirm/cancel →
ConfirmModaluimmo (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 danssections/ - DO NOT wrapper le contenu d'une modale dans un
ScrollView/FlatListRN →Modalwrappe avecModalScrollView. 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) dansget…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/formatPercentagedepuis@bricks-common/helpers→ hooksuseCurrencyFormatter/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éintroduireexpo-calendar/legacysauf migration temporaire - DO NOT utiliser
axios/fetchdirectement →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 webline-height: 35est un multiplicateur (×14px = 490px),text-[32]est supprimé parcn()(tailwind-merge le confond avectext-[#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 poursize-[65],h-[37],rounded-[12] - DO NOT utiliser
style={{}}inline →classNameavec Uniwind - DO NOT éditer
uniwind-theme.cssmanuellement →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-liftupdate()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()→ utiliserrouter.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 lesasdangereux et garantit une valeur typée même si l'URL est trafiquée
Styling¶
cn()(tailwind-merge + clsx) pour combiner les classes conditionnellesmatch()de ts-pattern pour les classes variant-based (pas d'objet Record)Image,LinearGradientetSafeAreaViewdepuis@bricks-common/uimmo/helpers(withUniwind—classNameUniwind)- Shadows : tokens
shadow-xsàshadow-lgsur fond clair. SurdefaultHeading/ dark, ces tokens (noir 4–8%) sont invisibles.theme.colors.blackest 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ériquesz-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 levariant-*(variant-heading-md text-white) : sur Android lecolordu variant gagne s'il est après. Si le variant change à un breakpoint, répéter la couleur à ce breakpoint (xl:text-white) — unvariant-*préfixé écrase untext-*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'idlike) ; 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.localerestent à l'init app / Storybook (dateInit) — pas d'extend ni de locale dans le helper. Ne pas envelopper le relatif dansitemDate(« 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
UIsans split smart/dumb —FundraisingAmountStep, pasFundraisingAmountStepUI.*UIseulement 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+OpenModalplay) — 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 niuseEffectauto-open.DefaultDOITplay:waitForOpenModaltimeout 5000 sur un contenu métier (buttonCTA/option, sinon titreparagraph—findByTextencapsulé, 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
constnommée (ex:const ICON_ASPECT_RATIO = 1.5) - Utiliser
Dividerde uimmo pour les séparations (pas de bordures custom) - DO NOT recopier
FormField/ResponsiveInputs/Dropzone/CheckboxCard/SelectDatePicker/EmptyState/PlaceholderBoxdans 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 deatoms//templates/. Shared plat (shared/components/{Name}/). Storybooktitle= path disque - Tests Storybook :
findByRole("button")(pasfindByText()). Ne pas testeraria-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), paswithin(canvasElement)— la modale est mountée hors du canvas, le scope par defaut ne la voit pas. Title : stripUI(…/sections/NavBar, pasNavBarUI) - Canvas Chromatic stories de modales PDP : wrapper
h-full w-fulldanscreateOpenModalButton+ metalayout: 'fullscreen'(openModalStoryParameters) —centeredshrink-wrap le bouton ;h-100/w-100sont 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.setThemeleaks 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.tsxinjecte les sections connectees (<BalanceBlock />,<TransactionsBlock />) en props/slots d'unContainerqui ne gere que le layout. Les Stories se font sur le Container (en lui passant desBlockUIdumb 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.tsxporte 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/, PDPAccountScreen→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
excludeStoriesdans lemetades*UI.stories.tsxPattern A des qu'il y a un export helper (*Preview,mock*, decorator…) ou unexport 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
*UIPropsdansexcludeStoriesquand le fichier finit parexport type { MySectionUIProps } - Pas necessaire sur les stories modal/screen directes sans export helper (ex:
ChangePasswordModal.stories.tsxqui 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)ouopenModal(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
BottomSheetModaldirectement - Props utiles :
title,onBack(étape nested : flèche gauche header, hardware back Android) - Liste virtuelle →
ModalList. Contenu scrollable →Modal. DO NOT imbriquerScrollView/FlatListRN - Sans
title: croix flottante surModalWindow(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, doncbgobligatoire - Modales accountStatus : pas de
titlechrome sauf étape aide (error / lock) →titlehelp. Heading dans le body. Croix flottante desktop si pas de titre - Freeze :
HelpCenterContactRowsinline (pas d'étape aide, pas de footer). Error / lock :HelpCenterContentin-sheet,Modaltitlehelp +onBack(flèche header). DO NOT footer Retour. DO NOThideTitle. DO NOTopenModal(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.titlechrome +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 leerror: 'i18n.key'se passe au constructeur (z.enum,z.string, ...) :.refinesur 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 dez.iso - DO saisir les dates calendaires via
DateTextInput(value formYearMonthDayDate | undefined) — pas deTextInput+ masque ; wire jour =YearMonthDayDate(passthrough) ; conversion ISO datetime uniquement si le contrat l'exige (fundsNeededByDate,invoiceDate) - DO picker calendrier en modal via
SelectDatePickeruimmo (commeAutocompletePicker) — pas deSelectTrigger+openModallocal.minDate/modalTitleau call-site - DO picker catégorie PDP via
SelectCategoryPicker(@shared/components) — options et copy internes, pas de tableauoptionsau 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 unPartial<>sur tout l'objet, qui force desvalue ?? ''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 validatorsidonttrustlikethat; pour les validatorszod,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?.messageest rendu direct (error={error?.message}). DO NOT re-traduire le message au niveau du champ (pas de helper typegetInputErrorMessagequi re-préfixeerrors.→ double clé / message brut) - DO NOT garder les erreurs derrière
isSubmitted(error={isSubmitted ? error?.message : undefined},{isSubmitted && errors.root ? …}) → afficher directementerror?.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/isSubmittingviauseFormContext()pour piloter UI oushouldValidate— 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 NOTwatch()pour ça — React Compiler ne le track pas, les dérivés (CTA, montant restant) restent stale - Utiliser le prop
errorduTextInput(pas deHintTextmanuel) - Bouton submit :
loading={isSubmitting}, jamaisdisabled={!isDirty} - Config useForm :
mode: "onSubmit",reValidateMode: "onChange"(pré-requis pour pouvoir afficher les erreurs sans guardisSubmitted) - Erreurs root API :
errors.root.messagerendu direct ; auto-clear viauseClearRootErrorOnChange(form)plutôt qu'unclearErrors('root')manuel en tête de submit - DO NOT rendre
errors.root(ni une autre erreur cross-champ) DANS lerenderd'un<Controller>→ un Controller ne re-render que sur changement de SON champ, donc unsetError('root')ne s'affiche pas. Rendreerrors.rootau niveau du form (hors Controller) - DO câbler
field.ref(deController/useController) jusqu'à l'élément natif focusable du champ → leshouldFocusErrornatif 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
refsur 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 surfield.ref. Typerref?: Ref<View>ouref?: Ref<ComponentRef<typeof TextInput>>. ComposantPressableciblé par le focus → ajouteraccessibilityRole="button". Réf commitError focus submit(projects/app-pdp-financement/src/modules/financing-request/presentation/) - DO exposer une seule
refsurAmountInput/NumberInput/DateTextInput: handle (setValue/setValueFromPrevious) +focus()(pour RHFshouldFocusError). Pas decontrollerRef/inputRefpublic — le focus natif est mergé dans ce handle. Les inputs sous-jacents (TextInput,UnderlineTextInput) exposent aussiref(pasinputRef) vers le champ natif - Screen smart → UI : passer la mutation brute en
submitAction+ unonSuccess(PAS unonSubmitqui combine action + effets). Le smart ne met pas de try/catch — la gestion d'erreur vit dans lecatchde l'UI (root par défaut, ou champ-spécifique comme SignupUSER_ALREADY_EXISTS→setError('email')). Passer la mutation brute signale explicitement que l'erreur est traitée en aval.submitActiontypé(data) => Promise<unknown>(ouPromise<TResult>sionSuccessa besoin du résultat).onSuccessoptionnel quand le succès est interne à l'UI (ex. ForgotPasswordsetEmailSent(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) }) } })—onSuccessdans letrypour que ses erreurs post-succès s'affichent comme erreur de form - PDP step autosave: flush pending
useDelayedwork on unmount so a last edit is not lost when the step unmounts;cancelAutoSaveon submit/skip blocks that flush and laterschedulebut keeps pending args.resumeAutoSaveon failure only lifts the block so unmount can flush. Skip resume when the step cache is alreadycompleted(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/ousrc/modules/{module}/services/, nommésuse{Action}.ts - Query keys centralisées par module dans
services/queryKeys.ts useQuery/useMutation/useInfiniteQueryavecprocessHttpResult+customAxios— les mutations aussi : sans lui, l'erreur reste unAxiosErrorbrut etbuildApiErrorMessageretombe sur « Request failed with status code 409 »- Codes d'erreur API :
getApiErrorCodelitdata.error(code kebab deapiErrcôté back) puisdata.message(code better-authauth.*). Un nouveau code métier affiché à l'utilisateur → ajouter la clé dansapiErrorsdefr.json, sinon message générique - Endpoints depuis
@bricks-common/api-communication - DO nommer le solde wallet
availableBalancedans le code app (analogue APIavailableWithdrawableBalance). Mapper le champ HTTP GETlemonwayBalanceseulement à la frontière du hook — ne pas le propager dans les props métier - Early returns pour loading/error : skeleton (pas spinner), puis
ErrorScreenaveconRetry={refetch} - Après les guards,
dataest garanti non-undefined - DO NOT guarder un screen authentifié sur
useMe().isPending/useFeatureFlag().isLoading—UserProvideretFeatureFlagsProviderbloquent plus haut. Liredata/isEnableddirectement - Cache :
staleTime: 0par défaut.refetchOnMount: falsesi données déjà fetchées plus haut.refetchOnWindowFocus: truepour données globales (user, FF). What's New unread : fetch on mount à la racine auth + écran What's New ; pastilles / guards ailleursrefetchOnMount: false(lecture cache). GET latest :showErrorNotification: false— pastille, pas un toast si le fetch fail.lastSeenau blur viauseFocusEffectseulement après affichage de la liste (native Stack ne unmount pas). Invest : MMKVprefs(logoutclearAllla session seulement), clé scopeduserId. 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
keyPrefixpour scope let()au module :useTranslation("translation", { keyPrefix: "modules.{module}.screens.{screen}" }) - Interpolation
{{value}}, pluralisation_one/_other Transpour phrases avec formatage (bold, liens). Pas de split de string en morceaux séparésuseCurrencyFormatter/usePercentageFormatterpour 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 workflowi18n-push-to-simplelocalizesync vers le cloud au merge surdevelop
Routing¶
- Fichiers route dans
src/app/: exportent les screens uniquement (export { Screen as default }) - DO NOT named-export
ErrorBoundarydepuis un barrel screen ou un fichier route — le filet Expo est le rootsrc/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 :
RootProviderwraps children,RootContentutilise les hooks Stack.Protectedavecguard={boolean}:true= accessible- DO NOT déclarer un
Stack.Screennon 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 deuseLocalSearchParams()brut + castas - Return post-login (invitation PDP) : MMKV
setPostLoginRedirect/consumePostLoginRedirect— DO NOT passerredirectToen query. Prefill email =/login?email=(canal séparé).consume()au passage sessionfalse → truedans 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 retournenullquand il n'est pas focused (sinon Expo Router empile les screens sans les démonter). No-op sur natif. Ne pas wrapper les_layout.tsxni les routes purementRedirect app-pdp-financementsuit 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 dansproject.role, jamais passé en option.funnel.steps= statuses API (+ override offre apporteur) ;getPhase1/getPhase2/getAll= ordre. Redirects viagetCurrentStep.isProjectFinished=signaturecompleted. Ne pas relireproject.stepshors du funnel. Listes de projets (ongoing / index redirect) : filtrer surproject.status, pas sur le funnel. Apporteur stripsfinalization/signature(etborrowerdeferred en phase 2) et traite offerdraftcommecompleted.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, sansuseProjectOffer). 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 avecisStepCompleted: une step completed n’est plus accessible. Le funnel phase 2 ne change que via la réponsecomplete/ submit. Emprunteur :completeProjectBorrower/submitProjectFinancingRequest= HTTP only, exportés paruseCompleteProjectBorrower. Cache dans chaqueuse— skip =submit; différé = save +complete+ unlock finalization (endpoint complete sans body). Complete phase 1 patche borrowercompletedjuste aprèscomplete, avantsubmit— sinon un submit failed rejoue le complete (409). Analyse pending : masquer le résumé société sifunnel.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 :
useStateparent + props, ou Context si profond - Global client : Zustand (
src/store/ousrc/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,useCallbackmanuellement — 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/useCallbackexistants quand on touche un fichier - Opt-out si un composant pose probleme :
"use no memo"en premiere ligne de la fonction - Images :
Imagede@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
safede Uniwind (ex:pt-safe,pb-safe) plutôt queSafeAreaBox .web.tsxpour code spécifique webisAndroid,isIOSdepuis@constants/platformpour checks plateforme- Responsive styling : classes Uniwind breakpoint (
hidden md:flex,p-md xl:p-xl). Préférer les classes breakpoint au hookuseBreakpointquand possible - Responsive rendering (show/hide composant entier) :
useEqualOrAboveBp('lg')ouuseEqualOrAboveBp('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