This commit is contained in:
Bastien COIGNOUX
2026-05-03 20:18:33 +02:00
parent ffc2e6b895
commit bd325fe456
113 changed files with 29532 additions and 220 deletions

41
mb-app/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

1
mb-app/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
mb-app/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

45
mb-app/app.json Normal file
View File

@ -0,0 +1,45 @@
{
"expo": {
"name": "mb-app",
"slug": "mb-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mbapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-notifications",
{
"color": "#1D4ED8"
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@ -0,0 +1,59 @@
import React from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Link, Tabs } from 'expo-router';
import { Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
headerRight: () => (
<Link href="/modal" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -0,0 +1,31 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

31
mb-app/app/(tabs)/two.tsx Normal file
View File

@ -0,0 +1,31 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

38
mb-app/app/+html.tsx Normal file
View File

@ -0,0 +1,38 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

40
mb-app/app/+not-found.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

88
mb-app/app/_layout.tsx Normal file
View File

@ -0,0 +1,88 @@
import 'react-native-gesture-handler';
import '../global.css';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { QueryClientProvider } from '@tanstack/react-query';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { AuthProvider } from '@/context/AuthContext';
import { queryClient } from '@/lib/query-client';
export { ErrorBoundary } from 'expo-router';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
...FontAwesome.font,
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
void SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
const navigationTheme = colorScheme === 'dark' ? DarkTheme : DefaultTheme;
const paperTheme =
colorScheme === 'dark'
? {
...MD3DarkTheme,
colors: {
...MD3DarkTheme.colors,
primary: '#3B82F6',
primaryContainer: '#1E3A8A',
},
}
: {
...MD3LightTheme,
colors: {
...MD3LightTheme.colors,
primary: '#1D4ED8',
primaryContainer: '#DBEAFE',
},
};
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={paperTheme}>
<AuthProvider>
<ThemeProvider value={navigationTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="auth" />
<Stack.Screen
name="modal"
options={{ presentation: 'modal', headerShown: true, title: 'Info' }}
/>
</Stack>
</ThemeProvider>
</AuthProvider>
</PaperProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}

View File

@ -0,0 +1,12 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'fade',
}}
/>
);
}

96
mb-app/app/auth/login.tsx Normal file
View File

@ -0,0 +1,96 @@
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/context/AuthContext';
import { useUiStore } from '@/stores/ui-store';
export default function LoginScreen() {
const { signIn } = useAuth();
const lastEmail = useUiStore((s) => s.lastAuthEmail);
const [email, setEmail] = useState(lastEmail);
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<SafeAreaView className="flex-1 bg-slate-50">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="flex-grow justify-center px-6 py-10"
>
<View className="mb-10">
<Text className="text-3xl font-bold text-slate-900">Connexion</Text>
<Text className="mt-2 text-base text-slate-600">
Espace marchand de biens accès sécurisé
</Text>
</View>
{error ? (
<Text className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</Text>
) : null}
<TextInput
label="E-mail"
mode="outlined"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
className="mb-3 bg-white"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Mot de passe"
mode="outlined"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
className="mb-6 bg-white"
style={{ backgroundColor: '#fff' }}
/>
<Button
mode="contained"
onPress={async () => {
setError(null);
setLoading(true);
const r = await signIn(email.trim(), password);
setLoading(false);
if (r.error) {
setError(r.error);
return;
}
useUiStore.getState().setLastAuthEmail(email.trim());
}}
loading={loading}
disabled={loading}
buttonColor="#1D4ED8"
textColor="#ffffff"
style={{ borderRadius: 10, paddingVertical: 4 }}
>
Se connecter
</Button>
<View className="mt-8 items-center">
<Text className="text-slate-600">Pas encore de compte ?</Text>
<Link href="/auth/register" asChild>
<Pressable className="mt-2 py-2">
<Text className="font-semibold text-primary">Créer un compte</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,127 @@
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/context/AuthContext';
export default function RegisterScreen() {
const { signUp } = useAuth();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
return (
<SafeAreaView className="flex-1 bg-slate-50">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="flex-grow px-6 py-8"
>
<View className="mb-8">
<Text className="text-3xl font-bold text-slate-900">Inscription</Text>
<Text className="mt-2 text-base text-slate-600">
Créez votre accès professionnel
</Text>
</View>
{error ? (
<Text className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</Text>
) : null}
{info ? (
<Text className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
{info}
</Text>
) : null}
<TextInput
label="Prénom"
mode="outlined"
value={firstName}
onChangeText={setFirstName}
autoComplete="given-name"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Nom"
mode="outlined"
value={lastName}
onChangeText={setLastName}
autoComplete="family-name"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="E-mail"
mode="outlined"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Mot de passe"
mode="outlined"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="new-password"
className="mb-6"
style={{ backgroundColor: '#fff' }}
/>
<Button
mode="contained"
onPress={async () => {
setError(null);
setInfo(null);
setLoading(true);
const r = await signUp({
email: email.trim(),
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
setLoading(false);
if (r.error) {
setError(r.error);
return;
}
setInfo(
'Si la confirmation e-mail est activée sur votre projet Supabase, vérifiez votre boîte avant de vous connecter.',
);
}}
loading={loading}
disabled={loading}
buttonColor="#1D4ED8"
textColor="#ffffff"
style={{ borderRadius: 10, paddingVertical: 4 }}
>
S&apos;inscrire
</Button>
<View className="mt-8 items-center">
<Link href="/auth/login" asChild>
<Pressable className="py-2">
<Text className="font-semibold text-primary">Retour à la connexion</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

21
mb-app/app/index.tsx Normal file
View File

@ -0,0 +1,21 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { useAuth } from '@/context/AuthContext';
export default function Index() {
const { initialized, session } = useAuth();
if (!initialized) {
return (
<View className="flex-1 items-center justify-center bg-slate-50">
<ActivityIndicator size="large" color="#1D4ED8" />
</View>
);
}
if (session?.user) {
return <Redirect href="/(tabs)" />;
}
return <Redirect href="/auth/login" />;
}

35
mb-app/app/modal.tsx Normal file
View File

@ -0,0 +1,35 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

9
mb-app/babel.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
};
};

View File

@ -0,0 +1,77 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View File

@ -0,0 +1,24 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
) {
return (
<Link
target="_blank"
{...props}
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View File

@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View File

@ -0,0 +1,45 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from './useColorScheme';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@ -0,0 +1,10 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { MonoText } from '../StyledText';
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -0,0 +1,4 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View File

@ -0,0 +1,12 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View File

@ -0,0 +1,19 @@
const tintColorLight = '#2f95dc';
const tintColorDark = '#fff';
export default {
light: {
text: '#000',
background: '#fff',
tint: tintColorLight,
tabIconDefault: '#ccc',
tabIconSelected: tintColorLight,
},
dark: {
text: '#fff',
background: '#000',
tint: tintColorDark,
tabIconDefault: '#ccc',
tabIconSelected: tintColorDark,
},
};

View File

@ -0,0 +1,4 @@
/** Constantes métier marchand de biens (France) — à enrichir avec le métier. */
export const FRAIS_NOTAIRE_ANCIEN_RATIO = 0.075;
export const FRAIS_NOTAIRE_NEUF_RATIO = 0.02;

View File

@ -0,0 +1,121 @@
import type { Session, User } from '@supabase/supabase-js';
import { useRouter, useSegments } from 'expo-router';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { supabase } from '@/services/supabase';
type AuthContextValue = {
initialized: boolean;
session: Session | null;
user: User | null;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signUp: (params: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [initialized, setInitialized] = useState(false);
const [session, setSession] = useState<Session | null>(null);
const segments = useSegments();
const router = useRouter();
useEffect(() => {
let cancelled = false;
void supabase.auth.getSession().then(({ data }) => {
if (!cancelled) {
setSession(data.session ?? null);
setInitialized(true);
}
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
});
return () => {
cancelled = true;
subscription.unsubscribe();
};
}, []);
useEffect(() => {
if (!initialized) return;
const root = segments[0];
if (!session?.user && root === '(tabs)') {
router.replace('/auth/login');
return;
}
if (session?.user && root === 'auth') {
router.replace('/(tabs)');
}
}, [initialized, session?.user, segments, router]);
const signIn = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
return {};
}, []);
const signUp = useCallback(
async (params: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => {
const { error } = await supabase.auth.signUp({
email: params.email,
password: params.password,
options: {
data: {
first_name: params.firstName,
last_name: params.lastName,
full_name: `${params.firstName} ${params.lastName}`.trim(),
},
},
});
if (error) return { error: error.message };
return {};
},
[],
);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
}, []);
const value = useMemo<AuthContextValue>(
() => ({
initialized,
session,
user: session?.user ?? null,
signIn,
signUp,
signOut,
}),
[initialized, session, signIn, signUp, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return ctx;
}

6
mb-app/env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_SUPABASE_URL?: string;
EXPO_PUBLIC_SUPABASE_ANON_KEY?: string;
}
}

3
mb-app/global.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});

6
mb-app/metro.config.js Normal file
View File

@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

1
mb-app/nativewind-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

11091
mb-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
mb-app/package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "mb-app",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.105.1",
"@tanstack/react-query": "^5.100.9",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.2.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-paper": "^5.15.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "~19.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@ -0,0 +1,22 @@
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import Constants from 'expo-constants';
const supabaseUrl =
process.env.EXPO_PUBLIC_SUPABASE_URL ??
Constants.expoConfig?.extra?.supabaseUrl ??
'';
const supabaseAnonKey =
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ??
Constants.expoConfig?.extra?.supabaseAnonKey ??
'';
export const supabase: SupabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

11
mb-app/stores/ui-store.ts Normal file
View File

@ -0,0 +1,11 @@
import { create } from 'zustand';
type UiState = {
lastAuthEmail: string;
setLastAuthEmail: (email: string) => void;
};
export const useUiStore = create<UiState>((set) => ({
lastAuthEmail: '',
setLastAuthEmail: (email) => set({ lastAuthEmail: email }),
}));

20
mb-app/tailwind.config.js Normal file
View File

@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./context/**/*.{js,jsx,ts,tsx}',
'./services/**/*.{js,jsx,ts,tsx}',
'./stores/**/*.{js,jsx,ts,tsx}',
'./lib/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
primary: '#1D4ED8',
},
},
},
plugins: [],
};

19
mb-app/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts",
"env.d.ts"
]
}

11
mb-app/types/database.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Types générés Supabase — remplacer par `supabase gen types typescript`
* une fois le schéma défini (cf. .cursorrules).
*/
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[];