app/_layout.tsx
Annotation
Ce fichier est le point d'entrée principal de l'application mobile, configurant l'environnement global. Il initialise des services essentiels comme Sentry pour la gestion des erreurs, Reactotron pour le débogage, et i18n pour l'internationalisation. Il expose le composant racine de l'application, `RootLayout`, qui gère l'hydratation des différents stores (authentification, thème, historique, etc.) et configure les fournisseurs de contexte comme React Query et Gesture Handler. Ce layout est le composant parent de toutes les routes de l'application, assurant que les données et les services de base sont prêts avant le rendu des pages spécifiques.
Concepts détectés — comprends la théorie
Hooks React
26 occurrencesCe fichier utilise des hooks React. Les hooks sont la façon moderne de gérer l'état et les effets dans React. Voir l'architecture mobile pour le pattern complet.
Voir l'article général
UI React Native
3 occurrencesComposants UI React Native (View, Text, etc.). Différents de l'HTML web — on rend du natif.
Voir l'article général
Routing Expo (mobile)
1 occurrenceCe fichier utilise le routing Expo (file-based). Convention : `_layout.tsx` pour les layouts, `[param].tsx` pour les routes dynamiques.
Voir l'article général
1 export
Code source· tsx· tronqué à 200 lignes sur 407
// @ts-ignore
import "./../ReactotronConfig"
import "../ReactotronConfig"
// V1.8 POC i18n — init react-i18next UNE fois au boot (avant tout render),
// pour que `useTranslation()` rende immédiatement avec les bonnes strings.
import "@/lib/i18n";
import React, { useEffect } from "react";
import { Text, TouchableOpacity, Linking, Alert, View } from "react-native";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar";
import * as SplashScreen from "expo-splash-screen";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { useAuthStore } from "@/store/authStore";
import { useHistoryStore } from "@/store/historyStore";
import { useFavorisStore } from "@/store/favorisStore";
import { useThemeStore } from "@/store/themeStore";
import { useVilleStore } from "@/store/villeStore";
import { useNetworkStore } from "@/store/networkStore";
import { useOfflineQueueStore } from "@/store/offlineQueueStore";
import { useTheme } from "@/hooks/useTheme";
import { useNotifications } from "@/hooks/useNotifications";
import { useOfflineSync } from "@/hooks/useOfflineSync";
import { OfflineBanner } from "@/components/ui/OfflineBanner";
import { haptics } from "@/lib/haptics";
import { initSentry, wrapRoot } from "@/lib/sentry";
// Init Sentry au plus tôt (avant tout render) pour capturer les erreurs
// d'hydratation, fetch initial vitrines, deep links.
initSentry();
// Splash screen natif iOS — masquer manuellement seulement quand auth+theme
// hydratés (sinon flash UI stale avant que le tenant/token soit en mémoire).
SplashScreen.preventAutoHideAsync().catch(() => {});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
networkMode: "always",
},
},
});
function AuthHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useAuthStore((s) => s.hydrate);
const hydrated = useAuthStore((s) => s.hydrated);
const themeHydrated = useThemeStore((s) => s.hydrated);
useEffect(() => {
const controller = new AbortController();
fetch("https://wari.pro/api/mobile/vitrines?limit=1&page=1", {
method: "GET",
signal: controller.signal,
}).catch(() => {});
(async () => { await hydrate(); })();
return () => controller.abort();
}, [hydrate]);
// Splash natif tenu visible jusqu'à hydratation auth+theme. Évite flash
// d'UI stale (mode client visible 200-500ms avant que tenant gérant soit
// chargé sur 4G BF).
useEffect(() => {
if (hydrated && themeHydrated) {
SplashScreen.hideAsync().catch(() => {});
}
}, [hydrated, themeHydrated]);
return <>{children}</>;
}
function ThemeHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useThemeStore((s) => s.hydrate);
useEffect(() => { hydrate(); }, []);
return <>{children}</>;
}
function HistoryHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useHistoryStore((s) => s.hydrate);
useEffect(() => { hydrate(); }, []);
return <>{children}</>;
}
function VilleHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useVilleStore((s) => s.hydrate);
useEffect(() => { hydrate(); }, []);
return <>{children}</>;
}
function FavorisHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useFavorisStore((s) => s.hydrate);
const syncFromServer = useFavorisStore((s) => s.syncFromServer);
const hydrated = useFavorisStore((s) => s.hydrated);
const token = useAuthStore((s) => s.token);
const role = useAuthStore((s) => s.user?.role);
useEffect(() => { hydrate(); }, []);
useEffect(() => {
// Favoris = feature CLIENT uniquement. Skip sync si TENANT_ADMIN
// pour eviter le 401 fantome dans les logs.
if (hydrated && token && role === 'CLIENT') syncFromServer();
}, [token, role, hydrated, syncFromServer]);
return <>{children}</>;
}
function ScrimBackButton() {
// BUG-129 fix : router.back() est no-op si pas d'historique (deep link
// direct vers /vitrine/[slug] depuis push notif, partage URL, etc.).
// Fallback vers /(tabs) pour ne pas bloquer l'utilisateur sur la page.
return (
<TouchableOpacity
onPress={() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(tabs)" as never);
}
}}
activeOpacity={0.75}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.55)",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "#fff", fontSize: 20, fontWeight: "600", marginTop: -2 }}>‹</Text>
</TouchableOpacity>
);
}
function NotificationsHydrator({ children }: { children: React.ReactNode }) {
useNotifications();
return <>{children}</>;
}
/**
* Hydrate la queue offline depuis AsyncStorage + souscrit aux changements
* de connectivité (NetInfo). Déclenche aussi le hook `useOfflineSync` qui
* auto-flush la queue dès que la connexion revient.
*
* Critique pour le scale international : sans ça on perd des commandes en
* zone rurale Afrique / métro EU / sous-sol US.
*/
function OfflineQueueHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useOfflineQueueStore((s) => s.hydrate);
const initNetwork = useNetworkStore((s) => s.init);
useEffect(() => {
hydrate();
const unsub = initNetwork();
return () => unsub();
}, [hydrate, initNetwork]);
useOfflineSync();
return <>{children}</>;
}
// Universal Links iOS + App Links Android — wari.pro/* + *.wari.pro/*
// Co-existe avec ReservationDeepLinkHandler (qui gate sur wari://reservation/*).
// Patterns supportés :
// https://wari.pro/produit/abc → /produit/abc
// https://wari.pro/prestation/abc → /prestation/abc
// https://wari.pro/vitrine/cils-or → /vitrine/cils-or
// https://wari.pro/conversation/xyz → /conversation/xyz
// https://wari.pro/messages → /messages
// https://cils-or.wari.pro → /vitrine/cils-or
// https://cils-or.wari.pro/produit/abc → /produit/abc
function UniversalLinkHandler({ children }: { children: React.ReactNode }) {
useEffect(() => {
const handleUrl = (url: string) => {
// Skip wari://* — déjà géré par ReservationDeepLinkHandler
if (!/^https?:\/\//i.test(url)) return;
const match = url.match(/^https?:\/\/([^/]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/i);
if (!match) return;
const host = match[1].toLowerCase();
const path = match[2] || "/";
// Filtre wari.pro + sous-domaines uniquement
const hostMatch = host.match(/^(?:([^.]+)\.)?wari\.pro$/i);
if (!hostMatch) return;
const subdomain = hostMatch[1];
// Subdomain vitrine (cils-or.wari.pro)
if (subdomain && subdomain !== "www") {
// Produit/prestation sur subdomain → écran dédié (les IDs sont globaux)
if (path.startsWith("/produit/") || path.startsWith("/prestation/")) {
router.push(path as never);
return;
}
// Fallback : vitrine du subdomain (avec ou sans path)
router.push(`/vitrine/${subdomain}` as never);
return;// @ts-ignore
import "./../ReactotronConfig"
import "../ReactotronConfig"
// V1.8 POC i18n — init react-i18next UNE fois au boot (avant tout render),
// pour que `useTranslation()` rende immédiatement avec les bonnes strings.
import "@/lib/i18n";
import React, { useEffect } from "react";
import { Text, TouchableOpacity, Linking, Alert, View } from "react-native";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar";
import * as SplashScreen from "expo-splash-screen";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { useAuthStore } from "@/store/authStore";
import { useHistoryStore } from "@/store/historyStore";
import { useFavorisStore } from "@/store/favorisStore";
import { useThemeStore } from "@/store/themeStore";
import { useVilleStore } from "@/store/villeStore";
import { useNetworkStore } from "@/store/networkStore";
import { useOfflineQueueStore } from "@/store/offlineQueueStore";
import { useTheme } from "@/hooks/useTheme";
import { useNotifications } from "@/hooks/useNotifications";
import { useOfflineSync } from "@/hooks/useOfflineSync";
import { OfflineBanner } from "@/components/ui/OfflineBanner";
import { haptics } from "@/lib/haptics";
import { initSentry, wrapRoot } from "@/lib/sentry";
// Init Sentry au plus tôt (avant tout render) pour capturer les erreurs
// d'hydratation, fetch initial vitrines, deep links.
initSentry();
// Splash screen natif iOS — masquer manuellement seulement quand auth+theme
// hydratés (sinon flash UI stale avant que le tenant/token soit en mémoire).
SplashScreen.preventAutoHideAsync().catch(() => {});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
networkMode: "always",
},
},
});
function AuthHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useAuthStore((s) => s.hydrate);
const hydrated = useAuthStore((s) => s.hydrated);
const themeHydrated = useThemeStore((s) => s.hydrated);
useEffect(() => {
const controller = new AbortController();
fetch("https://wari.pro/api/mobile/vitrines?limit=1&page=1", {
method: "GET",
signal: controller.signal,
}).catch(() => {});
(async () => { await hydrate(); })();
return () => controller.abort();
}, [hydrate]);
// Splash natif tenu visible jusqu'à hydratation auth+theme. Évite flash
// d'UI stale (mode client visible 200-500ms avant que tenant gérant soit
// chargé sur 4G BF).
useEffect(() => {
if (hydrated && themeHydrated) {
SplashScreen.hideAsync().catch(() => {});
}
}, [hydrated, themeHydrated]);
return <>{children}</>;
}
function ThemeHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useThemeStore((s) => s.hydrate);
useEffect(() => { hydrate(); }, []);
return <>{children}</>;
}
function HistoryHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useHistoryStore((s) => s.hydrate);
useEffect(() => { hydrate(); }, []);
return <>{children}</>;
}
function VilleHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useVilleStore((s) => s.hydrate);
useEffect(() => { hydrate(); }, []);
return <>{children}</>;
}
function FavorisHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useFavorisStore((s) => s.hydrate);
const syncFromServer = useFavorisStore((s) => s.syncFromServer);
const hydrated = useFavorisStore((s) => s.hydrated);
const token = useAuthStore((s) => s.token);
const role = useAuthStore((s) => s.user?.role);
useEffect(() => { hydrate(); }, []);
useEffect(() => {
// Favoris = feature CLIENT uniquement. Skip sync si TENANT_ADMIN
// pour eviter le 401 fantome dans les logs.
if (hydrated && token && role === 'CLIENT') syncFromServer();
}, [token, role, hydrated, syncFromServer]);
return <>{children}</>;
}
function ScrimBackButton() {
// BUG-129 fix : router.back() est no-op si pas d'historique (deep link
// direct vers /vitrine/[slug] depuis push notif, partage URL, etc.).
// Fallback vers /(tabs) pour ne pas bloquer l'utilisateur sur la page.
return (
<TouchableOpacity
onPress={() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(tabs)" as never);
}
}}
activeOpacity={0.75}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.55)",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "#fff", fontSize: 20, fontWeight: "600", marginTop: -2 }}>‹</Text>
</TouchableOpacity>
);
}
function NotificationsHydrator({ children }: { children: React.ReactNode }) {
useNotifications();
return <>{children}</>;
}
/**
* Hydrate la queue offline depuis AsyncStorage + souscrit aux changements
* de connectivité (NetInfo). Déclenche aussi le hook `useOfflineSync` qui
* auto-flush la queue dès que la connexion revient.
*
* Critique pour le scale international : sans ça on perd des commandes en
* zone rurale Afrique / métro EU / sous-sol US.
*/
function OfflineQueueHydrator({ children }: { children: React.ReactNode }) {
const hydrate = useOfflineQueueStore((s) => s.hydrate);
const initNetwork = useNetworkStore((s) => s.init);
useEffect(() => {
hydrate();
const unsub = initNetwork();
return () => unsub();
}, [hydrate, initNetwork]);
useOfflineSync();
return <>{children}</>;
}
// Universal Links iOS + App Links Android — wari.pro/* + *.wari.pro/*
// Co-existe avec ReservationDeepLinkHandler (qui gate sur wari://reservation/*).
// Patterns supportés :
// https://wari.pro/produit/abc → /produit/abc
// https://wari.pro/prestation/abc → /prestation/abc
// https://wari.pro/vitrine/cils-or → /vitrine/cils-or
// https://wari.pro/conversation/xyz → /conversation/xyz
// https://wari.pro/messages → /messages
// https://cils-or.wari.pro → /vitrine/cils-or
// https://cils-or.wari.pro/produit/abc → /produit/abc
function UniversalLinkHandler({ children }: { children: React.ReactNode }) {
useEffect(() => {
const handleUrl = (url: string) => {
// Skip wari://* — déjà géré par ReservationDeepLinkHandler
if (!/^https?:\/\//i.test(url)) return;
const match = url.match(/^https?:\/\/([^/]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/i);
if (!match) return;
const host = match[1].toLowerCase();
const path = match[2] || "/";
// Filtre wari.pro + sous-domaines uniquement
const hostMatch = host.match(/^(?:([^.]+)\.)?wari\.pro$/i);
if (!hostMatch) return;
const subdomain = hostMatch[1];
// Subdomain vitrine (cils-or.wari.pro)
if (subdomain && subdomain !== "www") {
// Produit/prestation sur subdomain → écran dédié (les IDs sont globaux)
if (path.startsWith("/produit/") || path.startsWith("/prestation/")) {
router.push(path as never);
return;
}
// Fallback : vitrine du subdomain (avec ou sans path)
router.push(`/vitrine/${subdomain}` as never);
return;