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 - 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 utiliser
BricksBottomSheetou state local pour les modals →useModals().openModal() - 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 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/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
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)(@hooks/useValidatedSearchParams) avec un validatoridonttrustlikethat. É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)LinearGradientetSafeAreaViewdepuis@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ériquesz-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
constnommée (ex:const ICON_ASPECT_RATIO = 1.5) - Utiliser
Dividerde uimmo pour les séparations (pas de bordures custom) - 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
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.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 : 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)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,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 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/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 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) 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 - 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…) 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
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- Endpoints depuis
@bricks-common/api-communication - Early returns pour loading/error : skeleton (pas spinner), puis
ErrorScreenaveconRetry={refetch} - Après les guards,
dataest garanti non-undefined - Cache :
staleTime: 0par défaut.refetchOnMount: falsesi données déjà fetchées plus haut.refetchOnWindowFocus: truepour 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
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
- 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 }) - Layouts (
_layout.tsx) : config navigation, providers, headers - Pattern Provider/Content :
RootProviderwraps children,RootContentutilise les hooks Stack.Protectedavecguard={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 validatoridonttrustlikethat. Pas deuseLocalSearchParams()brut + castas(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 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) :
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 viagetCurrentStep. Ne pas relireproject.stepsni appelergetFunnel(..., { isApporteurAffaires })hors du hook. Listes de projets (ongoing / index redirect) : filtrer surproject.status, pas sur le funnel. Apporteur stripsfinalization/signatureet traite offerdraftcommecompleted.borrower.isDeferred→ emprunteur en phase 2 après offer. Offre apporteur : fork avant le smart fetch (ProjectOfferScreen→ProjectOfferApporteurScreenUI, sansuseProjectOffer).
// 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 :
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 expo-imagepour 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
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