chore: build
This commit is contained in:
@@ -172,12 +172,10 @@ export const resetAdminStats = async (
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
// ============================================
|
|
||||||
// STATISTIQUES MENSUELLES (jour par jour)
|
|
||||||
// ============================================
|
|
||||||
export interface MonthlyDayStat {
|
export interface MonthlyDayStat {
|
||||||
day: string; // "2026-06-05"
|
day: string;
|
||||||
label: string; // "05/06"
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 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
|
||||||
|
!certs/certificate.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { ActivityIndicator, View } from "react-native";
|
||||||
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import * as Updates from "expo-updates";
|
||||||
|
|
||||||
|
import { AuthProvider, useAuth } from "./src/auth/AuthContext";
|
||||||
|
import { CartProvider } from "./src/context/CartContext";
|
||||||
|
import { NotificationProvider } from "./src/context/NotificationContext";
|
||||||
|
import { ThemeProvider, useTheme } from "./src/context/ThemeContext";
|
||||||
|
import { initServerBaseUrl } from "./src/api/client";
|
||||||
|
|
||||||
|
import HomeScreen from "./src/screens/HomeScreen";
|
||||||
|
import ServerConfigScreen from "./src/screens/auth/ServerConfigScreen";
|
||||||
|
import ClientLoginScreen from "./src/screens/auth/ClientLoginScreen";
|
||||||
|
import ClientTwoFAScreen from "./src/screens/auth/ClientTwoFAScreen";
|
||||||
|
import ChangePasswordScreen from "./src/screens/auth/ChangePasswordScreen";
|
||||||
|
import ClientNavigator from "./src/navigation/ClientNavigator";
|
||||||
|
import type { RootStackParamList, ChangePasswordStackParamList } from "./src/navigation/types";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
const CPStack = createNativeStackNavigator<ChangePasswordStackParamList>();
|
||||||
|
|
||||||
|
function AuthStack() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
initialRouteName="ServerConfig"
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||||
|
headerTintColor: colors.textWhite,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ServerConfig"
|
||||||
|
component={ServerConfigScreen}
|
||||||
|
options={{ title: "Serveur API" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="home"
|
||||||
|
component={HomeScreen}
|
||||||
|
options={{ title: "Accueil" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="login"
|
||||||
|
component={ClientLoginScreen}
|
||||||
|
options={{ title: "Connexion" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="clientTwoFA"
|
||||||
|
component={ClientTwoFAScreen}
|
||||||
|
options={{ title: "Vérification" }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForceChangePasswordStack() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<CPStack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||||
|
headerTintColor: colors.textWhite,
|
||||||
|
gestureEnabled: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CPStack.Screen
|
||||||
|
name="ChangePassword"
|
||||||
|
component={ChangePasswordScreen}
|
||||||
|
options={{ title: "Changer le mot de passe", headerLeft: () => null }}
|
||||||
|
/>
|
||||||
|
</CPStack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootNavigator() {
|
||||||
|
const { isAuthenticated, isLoading, role, mustChangePassword } = useAuth();
|
||||||
|
const { colors, isDark } = useTheme();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && role === "client" && mustChangePassword) {
|
||||||
|
return <ForceChangePasswordStack />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && role === "client") {
|
||||||
|
return (
|
||||||
|
<CartProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ClientNavigator />
|
||||||
|
</NotificationProvider>
|
||||||
|
</CartProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthStack />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (__DEV__) return;
|
||||||
|
const checkForUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const update = await Updates.checkForUpdateAsync();
|
||||||
|
if (update.isAvailable) {
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
await Updates.reloadAsync();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore update errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkForUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
<RootNavigator />
|
||||||
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<ServerGate>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
</ServerGate>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores a previously saved backend server URL before any screen
|
||||||
|
// (including the auth restore flow) can issue an API call.
|
||||||
|
function ServerGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initServerBaseUrl().finally(() => setReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
updates: {
|
||||||
|
...config.updates,
|
||||||
|
...(updateUrl ? { url: updateUrl } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "OMNEX APP",
|
||||||
|
"slug": "omnex-plateform-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.uberstup.omnexclientpanel"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/icon.png",
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
},
|
||||||
|
"abiFilters": ["arm64-v8a"],
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"softwareKeyboardLayoutMode": "pan",
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.uberstup.omnexclientpanel",
|
||||||
|
"versionCode": 1,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.VIBRATE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/icon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-font",
|
||||||
|
"expo-updates",
|
||||||
|
[
|
||||||
|
"expo-build-properties",
|
||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"usesCleartextTraffic": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "989b175c-7ad5-4e23-a358-fb7cbe666177"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "xor290",
|
||||||
|
"runtimeVersion": "omnex-client-1.0.0",
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/989b175c-7ad5-4e23-a358-fb7cbe666177",
|
||||||
|
"codeSigningCertificate": "./certs/certificate.pem",
|
||||||
|
"codeSigningMetadata": {
|
||||||
|
"keyid": "main",
|
||||||
|
"alg": "rsa-v1_5-sha256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,18 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIC1TCCAb2gAwIBAgIJanxJRzHVCaXJMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||||
|
BAMTCU9NTkVYIEFQUDAeFw0yNjA4MDQxNjA5NDNaFw0zNjA4MDQxNjA5NDNaMBQx
|
||||||
|
EjAQBgNVBAMTCU9NTkVYIEFQUDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
|
||||||
|
ggEBALJDyD72HP/8iQUGiyKBCYQwKPoNtN3x3DKaS9WXRTwR0YOHq3BBvLkvpfc2
|
||||||
|
5WYFlGa5QdjPKdviZB/AsqSUAxCxX7JJJR+zLH7eLZxmrPt2E6FreFCLvQmkkY1W
|
||||||
|
5tSCzqYIM3Nhv0r3E+LDnZPx/r2YPxF6Xvqqy4G3h+7dmKb8wSdjbsbgC48tB+Vp
|
||||||
|
FRFU7eSTzXdOS+a8FJz1wltCYd3hgcLy+hUeuOivjPIc6Y4qMQ3uhywgazKXwpXK
|
||||||
|
lP1k+eKAYnHwIqPBiXpRA+PobFH+OdNMCRSIX429Hz9GsgxHCXAdrub1ddEMjR0n
|
||||||
|
FNdC/VN41m8nLeZiY/pLXJb/Dc0CAwEAAaMqMCgwDgYDVR0PAQH/BAQDAgeAMBYG
|
||||||
|
A1UdJQEB/wQMMAoGCCsGAQUFBwMDMA0GCSqGSIb3DQEBCwUAA4IBAQBnAg2gw5iw
|
||||||
|
GzAnDyBHzLUyvnvqWEU9bUaFujmNgHYntj6+PqEHIhMaTJtsA05UoZgoSO1Q6dEG
|
||||||
|
2RxLWPLUvmrOqeiGgJ5yLaGqFGz1VVQeHhlNaB2CZmv5EC8CmPh9ByBOCEGbITVv
|
||||||
|
ZZw0z6mbopUd8uW1+X9v6ih8DTt4pQQPb3pYGi6yDM+2OaKg90bfoFMF6e1uucmW
|
||||||
|
J9X9GdkkTWEMyctYacy5MjU4IuwHczBgar7tXX8CcZD26avhUCsAIczNkMWH4AZg
|
||||||
|
7egYW8YxaXdwwk+wi2ItUtAMBYIHGv/1NhfgWNE8wqiBJlJTQpoVQtpmZYgduElN
|
||||||
|
g3PQdvKp4Og2
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.0.0",
|
||||||
|
"appVersionSource": "local"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"autoIncrement": true,
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
|
||||||
|
},
|
||||||
|
"channel": "production-omnex-client"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||||
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||||
|
// the environment is set up appropriately
|
||||||
|
registerRootComponent(App);
|
||||||
Generated
+11049
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "omnex-plateform-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.14.0",
|
||||||
|
"@react-navigation/native": "^7.1.28",
|
||||||
|
"@react-navigation/native-stack": "^7.13.0",
|
||||||
|
"axios": "^1.13.4",
|
||||||
|
"expo": "~54.0.33",
|
||||||
|
"expo-av": "~16.0.8",
|
||||||
|
"expo-build-properties": "~1.0.10",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-device": "~8.0.10",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-location": "~19.0.8",
|
||||||
|
"expo-notifications": "~0.32.16",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.16",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"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-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@expo/ngrok": "^4.1.3",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"@react-navigation/bottom-tabs",
|
||||||
|
"@react-navigation/native",
|
||||||
|
"@react-navigation/native-stack"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,781 @@
|
|||||||
|
// ============================================
|
||||||
|
// api/api_TYPES.ts - TOUTES LES INTERFACES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
access_token?: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
user?: UserResponse;
|
||||||
|
[key: string]: any; // Pour les champs additionnels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Données utilisateur retournées après login/register
|
||||||
|
*/
|
||||||
|
export interface UserResponse {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nom?: string;
|
||||||
|
prenom?: string;
|
||||||
|
telephone?: string;
|
||||||
|
role?: string;
|
||||||
|
session_id?: string;
|
||||||
|
command?: number;
|
||||||
|
point_extra?: number;
|
||||||
|
amende?: number;
|
||||||
|
must_change_password?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête de login
|
||||||
|
*/
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse de login
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
access_token?: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
user?: UserResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête d'enregistrement client
|
||||||
|
*/
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
telephone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative: RegisterData (utilisée dans Register.tsx)
|
||||||
|
*/
|
||||||
|
export interface RegisterData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
telephone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'enregistrement
|
||||||
|
*/
|
||||||
|
export interface RegisterResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
access_token?: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
user?: UserResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête d'enregistrement admin
|
||||||
|
*/
|
||||||
|
export interface RegisterAdminRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: "admin" | "cabine" | "livreur";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🛒 PANIER - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item du panier côté frontend
|
||||||
|
*/
|
||||||
|
export interface CartItem {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
name_product: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
category: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item du panier retourné par l'API
|
||||||
|
*/
|
||||||
|
export interface BasketItem {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
username: string;
|
||||||
|
name_product?: string;
|
||||||
|
product_name?: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
image?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête d'ajout au panier
|
||||||
|
*/
|
||||||
|
export interface AddToBasketRequest {
|
||||||
|
username: string;
|
||||||
|
name_product: string;
|
||||||
|
category: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse du panier
|
||||||
|
*/
|
||||||
|
export interface BasketResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
panier?: BasketItem[];
|
||||||
|
data?: {
|
||||||
|
panier?: BasketItem[];
|
||||||
|
};
|
||||||
|
count?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête de suppression du panier
|
||||||
|
*/
|
||||||
|
export interface RemoveFromBasketRequest {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête de vidage du panier
|
||||||
|
*/
|
||||||
|
export interface ClearBasketRequest {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📦 COMMANDES - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête de checkout
|
||||||
|
*/
|
||||||
|
export interface CheckoutRequest {
|
||||||
|
username: string;
|
||||||
|
delivery_address: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data pour le checkout
|
||||||
|
*/
|
||||||
|
export interface CheckoutData {
|
||||||
|
username?: string;
|
||||||
|
delivery_address: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item de commande
|
||||||
|
*/
|
||||||
|
export interface OrderItem {
|
||||||
|
id: number;
|
||||||
|
command_id: number;
|
||||||
|
produit?: string;
|
||||||
|
product_name?: string;
|
||||||
|
name_product?: string;
|
||||||
|
prix?: number;
|
||||||
|
price?: number;
|
||||||
|
quantite?: number;
|
||||||
|
quantity?: number;
|
||||||
|
category?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détails d'une commande
|
||||||
|
*/
|
||||||
|
export interface OrderDetail {
|
||||||
|
id: number;
|
||||||
|
client_order_number?: number;
|
||||||
|
username: string;
|
||||||
|
status: string;
|
||||||
|
delivery_address?: string;
|
||||||
|
adresse?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
total?: number;
|
||||||
|
total_prix?: number;
|
||||||
|
livreur_assign?: string;
|
||||||
|
items?: OrderItem[];
|
||||||
|
|
||||||
|
// Infos client enrichies
|
||||||
|
client_id?: number;
|
||||||
|
client_prenom?: string;
|
||||||
|
client_nom?: string;
|
||||||
|
client_telephone?: string;
|
||||||
|
|
||||||
|
// Infos du formulaire checkout
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
// Infos livreur
|
||||||
|
livreur_username?: string;
|
||||||
|
livreur_distance?: number;
|
||||||
|
|
||||||
|
// Métadonnées
|
||||||
|
payment_method?: string;
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Proposition de modification d'adresse
|
||||||
|
proposed_address?: string | null;
|
||||||
|
address_proposal_status?: "none" | "pending" | "accepted" | "rejected";
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse des commandes
|
||||||
|
*/
|
||||||
|
export interface OrdersResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
commands?: OrderDetail[];
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informations de livraison assignée
|
||||||
|
*/
|
||||||
|
export interface AssignedDelivery {
|
||||||
|
username: string;
|
||||||
|
distance_km: number;
|
||||||
|
eta_minutes: number;
|
||||||
|
last_update?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position dans la file d'attente
|
||||||
|
*/
|
||||||
|
export interface QueueInfo {
|
||||||
|
position: number;
|
||||||
|
status: string;
|
||||||
|
estimated_wait: string;
|
||||||
|
total_in_queue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse de checkout
|
||||||
|
*/
|
||||||
|
export interface CheckoutResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
command_id?: number;
|
||||||
|
command?: {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
total?: number;
|
||||||
|
livreur_assign?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
delivery_address?: string;
|
||||||
|
assigned_to?: AssignedDelivery;
|
||||||
|
queue_info?: QueueInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requête d'approbation de livraison
|
||||||
|
*/
|
||||||
|
export interface ApproveDeliveryRequest {
|
||||||
|
rating?: number;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'approbation
|
||||||
|
*/
|
||||||
|
export interface ApproveDeliveryResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
points_earned?: number;
|
||||||
|
category?: string; // ✅ AJOUTER CETTE LIGNE
|
||||||
|
points_info?: string; // ✅ AJOUTER CETTE LIGNE
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📍 TRACKING & SUIVI - TYPES
|
||||||
|
// ============================================
|
||||||
|
export interface TrackingResponse {
|
||||||
|
success: boolean;
|
||||||
|
command_id: number;
|
||||||
|
status: string;
|
||||||
|
current_step: string;
|
||||||
|
livreur?: string;
|
||||||
|
livreur_username?: string;
|
||||||
|
livreur_distance?: number;
|
||||||
|
estimated_arrival?: string;
|
||||||
|
eta_minutes?: number;
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
delivery_address?: string;
|
||||||
|
incidents_detected?: number;
|
||||||
|
updated_at?: number;
|
||||||
|
created_at?: string;
|
||||||
|
message?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📦 PRODUITS - TYPES
|
||||||
|
// ============================================
|
||||||
|
export interface ProductPrice {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
active_price?: boolean;
|
||||||
|
}
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
stock: number;
|
||||||
|
unit?: string;
|
||||||
|
prices?: ProductPrice[];
|
||||||
|
media?: Array<{
|
||||||
|
// ✅ CHANGÉ
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
id?: number;
|
||||||
|
created_at?: string;
|
||||||
|
}>;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductsResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: Product[];
|
||||||
|
products?: Product[];
|
||||||
|
count?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse produit unique
|
||||||
|
*/
|
||||||
|
export interface ProductResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: Product;
|
||||||
|
product?: Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔐 SESSION - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Données de session côté frontend
|
||||||
|
*/
|
||||||
|
export interface SessionData {
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
user_id?: number;
|
||||||
|
session_id?: string;
|
||||||
|
created_at?: number;
|
||||||
|
expires_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Payload (claims)
|
||||||
|
*/
|
||||||
|
export interface JWTPayload {
|
||||||
|
client_id?: number;
|
||||||
|
user_id?: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
session_id: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
iss: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🎯 ERROR - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur API
|
||||||
|
*/
|
||||||
|
export interface ErrorResponse {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
message?: string;
|
||||||
|
details?: string;
|
||||||
|
status_code?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erreur de validation
|
||||||
|
*/
|
||||||
|
export interface ValidationError {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse de validation échouée
|
||||||
|
*/
|
||||||
|
export interface ValidationErrorResponse {
|
||||||
|
success: false;
|
||||||
|
errors: ValidationError[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📊 CONTEXTE PANIER - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État du contexte panier
|
||||||
|
*/
|
||||||
|
export interface CartContextState {
|
||||||
|
cartItems: CartItem[];
|
||||||
|
cartCount: number;
|
||||||
|
cartTotal: number;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions du contexte panier
|
||||||
|
*/
|
||||||
|
export interface CartContextActions {
|
||||||
|
addToCart: (item: Omit<CartItem, "id">) => Promise<void>;
|
||||||
|
removeFromCart: (id: number) => Promise<void>;
|
||||||
|
updateQuantity: (id: number, quantity: number) => Promise<void>;
|
||||||
|
clearCart: () => Promise<void>;
|
||||||
|
refreshCart: () => Promise<void>;
|
||||||
|
showToast: (
|
||||||
|
message: string,
|
||||||
|
type: "success" | "error" | "warning" | "info",
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast notification
|
||||||
|
*/
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔐 AUTHENTIFICATION UTILS - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat de validation du formulaire de login
|
||||||
|
*/
|
||||||
|
export interface LoginFormValidation {
|
||||||
|
valid: boolean;
|
||||||
|
errors: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat de validation du formulaire d'enregistrement
|
||||||
|
*/
|
||||||
|
export interface RegisterFormValidation {
|
||||||
|
valid: boolean;
|
||||||
|
errors: {
|
||||||
|
nom?: string;
|
||||||
|
prenom?: string;
|
||||||
|
telephone?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État du formulaire de login
|
||||||
|
*/
|
||||||
|
export interface LoginFormState {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État du formulaire d'enregistrement
|
||||||
|
*/
|
||||||
|
export interface RegisterFormState {
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
telephone: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État du formulaire de checkout
|
||||||
|
*/
|
||||||
|
export interface CheckoutFormState {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletedOrder {
|
||||||
|
id: number;
|
||||||
|
client_order_number?: number;
|
||||||
|
username: string;
|
||||||
|
status: string;
|
||||||
|
adresse: string;
|
||||||
|
total_prix: number;
|
||||||
|
referral_used?: number;
|
||||||
|
livreur_assign?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientStats {
|
||||||
|
username: string;
|
||||||
|
nom?: string;
|
||||||
|
prenom?: string;
|
||||||
|
telephone?: string;
|
||||||
|
total_commands: number;
|
||||||
|
points: number;
|
||||||
|
pool_points: number[];
|
||||||
|
pool_names: string[];
|
||||||
|
penalties: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryResponse {
|
||||||
|
success: boolean;
|
||||||
|
commands: CompletedOrder[];
|
||||||
|
count: number;
|
||||||
|
client_stats?: ClientStats;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ETAResponse {
|
||||||
|
success: boolean;
|
||||||
|
command_id: number;
|
||||||
|
eta_minutes: number;
|
||||||
|
estimated_arrival: string;
|
||||||
|
status: string;
|
||||||
|
livreur_distance?: number;
|
||||||
|
message?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🚫 ANNULATION DE COMMANDES - TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface CancelCommandRequest {
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PenaltyDetails {
|
||||||
|
points: number;
|
||||||
|
total_violations: number;
|
||||||
|
reason: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PenaltyWarning {
|
||||||
|
will_apply: boolean;
|
||||||
|
penalty_amount: number;
|
||||||
|
current_violations: number;
|
||||||
|
message: string;
|
||||||
|
scale: {
|
||||||
|
"1st_cancel": string;
|
||||||
|
"2nd_cancel": string;
|
||||||
|
"3rd_cancel": string;
|
||||||
|
"4th+_cancel": string;
|
||||||
|
your_next: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelCommandResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
warning?: boolean;
|
||||||
|
command_id?: number;
|
||||||
|
penalty?: PenaltyDetails;
|
||||||
|
penalty_warning?: PenaltyWarning;
|
||||||
|
details?: {
|
||||||
|
livreur?: string;
|
||||||
|
status?: string;
|
||||||
|
position_in_queue?: number;
|
||||||
|
queue_info?: any;
|
||||||
|
};
|
||||||
|
action_required?: string;
|
||||||
|
example?: any;
|
||||||
|
new_status?: string;
|
||||||
|
cancelled_by?: {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
reason?: string;
|
||||||
|
info?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancellationHistoryItem {
|
||||||
|
command_id: number;
|
||||||
|
cancelled_at: string;
|
||||||
|
reason: string;
|
||||||
|
penalty_applied: number;
|
||||||
|
had_livreur: boolean;
|
||||||
|
livreur_username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancellationHistoryResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
username: string;
|
||||||
|
history: CancellationHistoryItem[];
|
||||||
|
total_penalties: number;
|
||||||
|
warning?: string;
|
||||||
|
info?: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetOrderDetailsResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
order?: OrderDetailsData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderDetailsData extends OrderDetail {
|
||||||
|
products?: OrderProduct[];
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
delivery_time?: string;
|
||||||
|
delivery_notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderProduct {
|
||||||
|
id: number;
|
||||||
|
name_product: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PenaltyInfo {
|
||||||
|
username: string;
|
||||||
|
total_penalty: number;
|
||||||
|
cancellations_count: number;
|
||||||
|
has_penalties: boolean;
|
||||||
|
points?: number; // ✅ AJOUTER CETTE LIGNE (points weed/hash)
|
||||||
|
points_zipette?: number; // ✅ AJOUTER CETTE LIGNE (points zipette)
|
||||||
|
cancellation_history?: {
|
||||||
|
current_amende: number;
|
||||||
|
next_penalty: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PenaltiesResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: PenaltyInfo;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutCartData {
|
||||||
|
username?: string;
|
||||||
|
delivery_address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralBalanceResponse {
|
||||||
|
success: boolean;
|
||||||
|
balance: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutCartResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
invalid_address?: boolean;
|
||||||
|
suggested_address?: string;
|
||||||
|
command_id?: number;
|
||||||
|
delivery_address?: string;
|
||||||
|
command?: {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
livreur_assign?: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
assigned_to?: {
|
||||||
|
username: string;
|
||||||
|
distance_km: number;
|
||||||
|
eta_minutes: number;
|
||||||
|
};
|
||||||
|
queue_info?: {
|
||||||
|
position: number;
|
||||||
|
status: string;
|
||||||
|
estimated_wait: string;
|
||||||
|
};
|
||||||
|
referral_used?: number;
|
||||||
|
referral_balance?: number;
|
||||||
|
// Crypto payment fields
|
||||||
|
payment_method?: string;
|
||||||
|
payment_status?: string;
|
||||||
|
pay_address?: string;
|
||||||
|
pay_amount?: number;
|
||||||
|
pay_currency?: string;
|
||||||
|
price_amount?: number;
|
||||||
|
price_currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmReceptionResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
points_earned?: number;
|
||||||
|
category?: string; // 'total', 'zipette&co', 'weed&hash', 'mixed'
|
||||||
|
data?: {
|
||||||
|
category?: string;
|
||||||
|
points_earned?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
||||||
|
|
||||||
|
const SERVER_URL_KEY = "server_base_url";
|
||||||
|
|
||||||
|
export let API_BASE_URL = "https://mln-uber.club";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Updates the active backend URL for every subsequent request made
|
||||||
|
// through apiClient (used by the pre-login server config screen).
|
||||||
|
export const setApiBaseUrl = (url: string) => {
|
||||||
|
API_BASE_URL = url;
|
||||||
|
apiClient.defaults.baseURL = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoredServerUrl = () => AsyncStorage.getItem(SERVER_URL_KEY);
|
||||||
|
|
||||||
|
export const saveServerUrl = async (url: string) => {
|
||||||
|
await AsyncStorage.setItem(SERVER_URL_KEY, url);
|
||||||
|
setApiBaseUrl(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restores the previously saved server URL (if any) into apiClient.
|
||||||
|
// Must resolve before any screen can issue API calls.
|
||||||
|
export const initServerBaseUrl = async (): Promise<string | null> => {
|
||||||
|
const stored = await getStoredServerUrl();
|
||||||
|
if (stored) {
|
||||||
|
setApiBaseUrl(stored);
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
};
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(async (config) => {
|
||||||
|
const isAdminRoute =
|
||||||
|
config.url?.includes("/api/v2/") ||
|
||||||
|
config.url?.includes("/api/v1/cabine/") ||
|
||||||
|
config.url?.includes("/api/v1/livreur/");
|
||||||
|
|
||||||
|
const token = isAdminRoute ? await getAdminToken() : await getToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
console.log("Session expirée");
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
getToken, setToken as storeToken, removeToken,
|
||||||
|
getAdminToken, setAdminToken as storeAdminToken, removeAdminToken,
|
||||||
|
setUsername, removeUsername,
|
||||||
|
setAdminUsername, removeAdminUsername,
|
||||||
|
setRole as storeRole, getRole, removeRole,
|
||||||
|
clearAllAuth,
|
||||||
|
} from './tokenStorage';
|
||||||
|
import { extractUsernameFromToken, extractRoleFromToken, isTokenExpired } from './jwtUtils';
|
||||||
|
|
||||||
|
export type UserRole = 'client' | 'admin' | 'cabine' | 'livreur' | null;
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
username: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType extends AuthState {
|
||||||
|
loginClient: (token: string, mustChangePassword?: boolean) => Promise<void>;
|
||||||
|
loginAdmin: (token: string, role: 'admin' | 'cabine' | 'livreur') => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setMustChangePassword: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<AuthState>({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
role: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const restore = async () => {
|
||||||
|
try {
|
||||||
|
const savedRole = await getRole();
|
||||||
|
|
||||||
|
if (savedRole === 'client') {
|
||||||
|
const token = await getToken();
|
||||||
|
if (token && !isTokenExpired(token)) {
|
||||||
|
const uname = extractUsernameFromToken(token);
|
||||||
|
setState({
|
||||||
|
token,
|
||||||
|
username: uname,
|
||||||
|
role: 'client',
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (savedRole === 'admin' || savedRole === 'cabine' || savedRole === 'livreur') {
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (token && !isTokenExpired(token)) {
|
||||||
|
const uname = extractUsernameFromToken(token);
|
||||||
|
setState({
|
||||||
|
token,
|
||||||
|
username: uname,
|
||||||
|
role: savedRole as UserRole,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid session
|
||||||
|
await clearAllAuth();
|
||||||
|
setState({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await clearAllAuth();
|
||||||
|
setState({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
restore();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loginClient = async (token: string, mustChangePassword = false) => {
|
||||||
|
const uname = extractUsernameFromToken(token);
|
||||||
|
await storeToken(token);
|
||||||
|
if (uname) await setUsername(uname);
|
||||||
|
await storeRole('client');
|
||||||
|
setState({
|
||||||
|
token,
|
||||||
|
username: uname,
|
||||||
|
role: 'client',
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
mustChangePassword,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMustChangePassword = (value: boolean) => {
|
||||||
|
setState(prev => ({ ...prev, mustChangePassword: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginAdmin = async (token: string, role: 'admin' | 'cabine' | 'livreur') => {
|
||||||
|
const uname = extractUsernameFromToken(token);
|
||||||
|
await storeAdminToken(token);
|
||||||
|
if (uname) await setAdminUsername(uname);
|
||||||
|
await storeRole(role);
|
||||||
|
setState({
|
||||||
|
token,
|
||||||
|
username: uname,
|
||||||
|
role,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await clearAllAuth();
|
||||||
|
setState({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...state, loginClient, loginAdmin, logout, setMustChangePassword }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
client_id?: number;
|
||||||
|
user_id?: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
session_id: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
iss: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeToken = (token: string): JWTPayload | null => {
|
||||||
|
try {
|
||||||
|
return jwtDecode<JWTPayload>(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractUsernameFromToken = (token: string): string | null => {
|
||||||
|
const payload = decodeToken(token);
|
||||||
|
return payload?.username ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractRoleFromToken = (token: string): string | null => {
|
||||||
|
const payload = decodeToken(token);
|
||||||
|
return payload?.role ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTokenExpired = (token: string): boolean => {
|
||||||
|
const payload = decodeToken(token);
|
||||||
|
if (!payload) return true;
|
||||||
|
return Date.now() >= payload.exp * 1000;
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "token";
|
||||||
|
const ADMIN_TOKEN_KEY = "admin_token";
|
||||||
|
const USERNAME_KEY = "username";
|
||||||
|
const ADMIN_USERNAME_KEY = "admin_username";
|
||||||
|
const ROLE_KEY = "user_role";
|
||||||
|
|
||||||
|
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
||||||
|
export const setToken = (token: string) =>
|
||||||
|
AsyncStorage.setItem(TOKEN_KEY, token);
|
||||||
|
export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY);
|
||||||
|
|
||||||
|
export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY);
|
||||||
|
export const setAdminToken = (token: string) =>
|
||||||
|
AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||||
|
export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
|
||||||
|
export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY);
|
||||||
|
export const setUsername = (username: string) =>
|
||||||
|
AsyncStorage.setItem(USERNAME_KEY, username);
|
||||||
|
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
||||||
|
|
||||||
|
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
||||||
|
export const setAdminUsername = (username: string) =>
|
||||||
|
AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
||||||
|
export const removeAdminUsername = () =>
|
||||||
|
AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
|
||||||
|
|
||||||
|
// Role
|
||||||
|
export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
|
||||||
|
export const setRole = (role: string) => AsyncStorage.setItem(ROLE_KEY, role);
|
||||||
|
export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY);
|
||||||
|
|
||||||
|
// Clear all auth data
|
||||||
|
export const clearAllAuth = async () => {
|
||||||
|
await AsyncStorage.multiRemove([
|
||||||
|
TOKEN_KEY,
|
||||||
|
ADMIN_TOKEN_KEY,
|
||||||
|
USERNAME_KEY,
|
||||||
|
ADMIN_USERNAME_KEY,
|
||||||
|
ROLE_KEY,
|
||||||
|
]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TouchableOpacity, Text, StyleSheet } from "react-native";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../theme";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
|
||||||
|
function getTextColor(hex: string): string {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||||
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||||
|
}
|
||||||
|
interface CategoryPillProps {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryPill({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
color,
|
||||||
|
}: CategoryPillProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const catColor = color || colors.accent;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={[
|
||||||
|
styles.pill,
|
||||||
|
active
|
||||||
|
? { backgroundColor: catColor, borderColor: catColor }
|
||||||
|
: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{ color: active ? getTextColor(catColor) : colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pill: {
|
||||||
|
paddingHorizontal: spacing.l,
|
||||||
|
paddingVertical: spacing.s,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginRight: spacing.s,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.medium,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight, shadows } from "../theme";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
import StatusBadge from "./StatusBadge";
|
||||||
|
import { formatOrderDate, calculateOrderTotal, formatPrice } from "../api/api";
|
||||||
|
|
||||||
|
interface OrderCardProps {
|
||||||
|
order: {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
adresse?: string;
|
||||||
|
delivery_address?: string;
|
||||||
|
created_at: string;
|
||||||
|
total?: number;
|
||||||
|
total_prix?: number;
|
||||||
|
livreur_assign?: string;
|
||||||
|
items?: any[];
|
||||||
|
};
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderCard({ order, onPress }: OrderCardProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const total = calculateOrderTotal(order);
|
||||||
|
const address =
|
||||||
|
order.delivery_address || order.adresse || "Adresse inconnue";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
shadows.sm,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.orderId, { color: colors.textWhite }]}>
|
||||||
|
Commande #{order.id}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Ionicons
|
||||||
|
name="location-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.detail, { color: colors.textSecondary }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Ionicons
|
||||||
|
name="time-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.detail, { color: colors.textSecondary }]}
|
||||||
|
>
|
||||||
|
{formatOrderDate(order.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{order.livreur_assign && (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Ionicons
|
||||||
|
name="bicycle-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detail,
|
||||||
|
{ color: colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{order.livreur_assign}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[styles.footer, { borderTopColor: colors.borderLight }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.total, { color: colors.success }]}>
|
||||||
|
{formatPrice(total)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={18}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.l,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginLeft: spacing.s,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingTop: spacing.m,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
StyleSheet,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
function getTextColor(hex: string): string {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
const full =
|
||||||
|
h.length === 3
|
||||||
|
? h
|
||||||
|
.split("")
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join("")
|
||||||
|
: h;
|
||||||
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||||
|
}
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Video, ResizeMode } from "expo-av";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../theme";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
import { API_BASE_URL } from "../api/client";
|
||||||
|
import { useCart } from "../context/CartContext";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
const CARD_WIDTH = SCREEN_WIDTH - 48;
|
||||||
|
|
||||||
|
interface ProductCardProps {
|
||||||
|
product: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
stock: number;
|
||||||
|
unit?: string;
|
||||||
|
coming_soon?: boolean;
|
||||||
|
prices?: Array<{
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
active_price?: boolean;
|
||||||
|
}>;
|
||||||
|
media?: Array<{ url: string; type: string }>;
|
||||||
|
};
|
||||||
|
onPress: () => void;
|
||||||
|
categoryColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductCard({
|
||||||
|
product,
|
||||||
|
onPress,
|
||||||
|
categoryColor,
|
||||||
|
}: ProductCardProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const { addToCart } = useCart();
|
||||||
|
const catColor = categoryColor ?? colors.accent;
|
||||||
|
const isSoldOut = product.stock <= 0;
|
||||||
|
const isComingSoon = product.coming_soon === true;
|
||||||
|
const activePrices =
|
||||||
|
product.prices?.filter((p) => p.active_price !== false) ?? [];
|
||||||
|
const firstPrice = activePrices[0]?.price ?? null;
|
||||||
|
|
||||||
|
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||||
|
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||||
|
const imageUri = imageMedia ? `${API_BASE_URL}${imageMedia.url}` : null;
|
||||||
|
const videoUri = videoMedia ? `${API_BASE_URL}${videoMedia.url}` : null;
|
||||||
|
|
||||||
|
const [showQuantitySelect, setShowQuantitySelect] = useState(false);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [showVideo, setShowVideo] = useState(false);
|
||||||
|
|
||||||
|
const handleQuickAdd = () => {
|
||||||
|
if (!isSoldOut && product.prices && product.prices.length > 0) {
|
||||||
|
setShowQuantitySelect(!showQuantitySelect);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectQuantity = (priceOption: {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}) => {
|
||||||
|
addToCart({
|
||||||
|
product_id: product.id,
|
||||||
|
name_product: product.name,
|
||||||
|
category: (product.category || "autre").toLowerCase().trim(),
|
||||||
|
quantity: priceOption.quantity,
|
||||||
|
price: priceOption.price,
|
||||||
|
});
|
||||||
|
setShowSuccess(true);
|
||||||
|
setShowQuantitySelect(false);
|
||||||
|
setTimeout(() => setShowSuccess(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
borderColor: catColor,
|
||||||
|
shadowColor: catColor,
|
||||||
|
},
|
||||||
|
isSoldOut && styles.soldOut,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.imageContainer,
|
||||||
|
{ backgroundColor: colors.bgInput },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUri }}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.imagePlaceholder,
|
||||||
|
{ backgroundColor: catColor + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="leaf-outline"
|
||||||
|
size={60}
|
||||||
|
color={catColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{videoUri && !isSoldOut && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.videoBtn,
|
||||||
|
{ backgroundColor: catColor + "DD" },
|
||||||
|
]}
|
||||||
|
onPress={() => setShowVideo(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="videocam"
|
||||||
|
size={18}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.detailsBtn}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.detailsBtnText, { color: colors.white }]}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isSoldOut && (
|
||||||
|
<View style={styles.soldOutOverlay}>
|
||||||
|
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isComingSoon && (
|
||||||
|
<View style={styles.comingSoonOverlay}>
|
||||||
|
<View style={styles.comingSoonBox}>
|
||||||
|
<Text style={styles.comingSoonText}>BIENTÔT DISPONIBLE</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.info,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.name, { color: colors.textWhite }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.price, { color: colors.success }]}>
|
||||||
|
{firstPrice !== null
|
||||||
|
? `${firstPrice.toFixed(2)} €`
|
||||||
|
: "Prix non disponible"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.quickAddSection,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{showSuccess ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.successBanner,
|
||||||
|
{ backgroundColor: catColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.successText,
|
||||||
|
{ color: getTextColor(catColor) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Ajoute !
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.quickAddBtn,
|
||||||
|
{ backgroundColor: catColor },
|
||||||
|
(isSoldOut || isComingSoon) && {
|
||||||
|
backgroundColor: colors.textMuted,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={handleQuickAdd}
|
||||||
|
disabled={isSoldOut || isComingSoon}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.quickAddBtnText,
|
||||||
|
{ color: getTextColor(catColor) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isSoldOut
|
||||||
|
? "Rupture de stock"
|
||||||
|
: isComingSoon
|
||||||
|
? "Bientôt disponible"
|
||||||
|
: "Ajouter rapidement"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showQuantitySelect}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setShowQuantitySelect(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.pickerOverlay}
|
||||||
|
onPress={() => setShowQuantitySelect(false)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.pickerSheet,
|
||||||
|
{ backgroundColor: colors.bgSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.pickerHandle,
|
||||||
|
{ backgroundColor: colors.textMuted },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pickerTitle,
|
||||||
|
{ color: colors.textWhite },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Choisir une quantite
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.pickerScroll}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{activePrices.map((p) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p.quantity}
|
||||||
|
style={[
|
||||||
|
styles.pickerOption,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
borderColor: catColor + "44",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => handleSelectQuantity(p)}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<View style={styles.pickerOptionLeft}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pickerOptionQty,
|
||||||
|
{ color: colors.textWhite },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.quantity}
|
||||||
|
{product.unit || "g"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pickerOptionPrice,
|
||||||
|
{ color: catColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.price.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name="add-circle"
|
||||||
|
size={28}
|
||||||
|
color={catColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.pickerCloseBtn,
|
||||||
|
{ backgroundColor: colors.border },
|
||||||
|
]}
|
||||||
|
onPress={() => setShowQuantitySelect(false)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pickerCloseBtnText,
|
||||||
|
{ color: colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showVideo}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowVideo(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.videoModalOverlay}
|
||||||
|
onPress={() => setShowVideo(false)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.videoModalContent,
|
||||||
|
{ backgroundColor: colors.black },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.videoCloseBtn}
|
||||||
|
onPress={() => setShowVideo(false)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close"
|
||||||
|
size={22}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{videoUri && (
|
||||||
|
<Video
|
||||||
|
source={{ uri: videoUri }}
|
||||||
|
style={styles.videoPlayer}
|
||||||
|
useNativeControls
|
||||||
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
|
shouldPlay
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 15,
|
||||||
|
borderWidth: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.6,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
soldOut: { opacity: 0.75 },
|
||||||
|
imageContainer: {
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: 1,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
image: { width: "100%", height: "100%" },
|
||||||
|
imagePlaceholder: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
videoBtn: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
|
detailsBtn: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 12,
|
||||||
|
alignSelf: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.85)",
|
||||||
|
borderRadius: 25,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
|
detailsBtnText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
soldOutOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: [
|
||||||
|
{ translateX: -80 },
|
||||||
|
{ translateY: -25 },
|
||||||
|
{ rotate: "-15deg" },
|
||||||
|
],
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(255,0,0,0.95)",
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
soldOutText: {
|
||||||
|
color: "rgba(255,0,0,0.95)",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 3,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 6,
|
||||||
|
},
|
||||||
|
comingSoonOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
comingSoonBox: {
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(34,197,94,0.95)",
|
||||||
|
paddingHorizontal: 22,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
color: "rgba(34,197,94,0.95)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 6,
|
||||||
|
},
|
||||||
|
info: { padding: spacing.m, borderTopWidth: 1 },
|
||||||
|
name: {
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: fontSize.xl,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
|
||||||
|
quickAddBtn: {
|
||||||
|
width: "100%",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
quickAddBtnText: {
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
successBanner: {
|
||||||
|
width: "100%",
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
successText: { fontSize: fontSize.md, fontWeight: "700" },
|
||||||
|
pickerOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.7)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
pickerSheet: {
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 40,
|
||||||
|
maxHeight: "70%",
|
||||||
|
},
|
||||||
|
pickerHandle: {
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
alignSelf: "center",
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
pickerTitle: {
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
pickerScroll: { maxHeight: 350 },
|
||||||
|
pickerOption: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
pickerOptionLeft: { gap: 2 },
|
||||||
|
pickerOptionQty: { fontSize: fontSize.lg, fontWeight: fontWeight.bold },
|
||||||
|
pickerOptionPrice: {
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
pickerCloseBtn: {
|
||||||
|
marginTop: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
pickerCloseBtnText: {
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
videoModalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.92)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
videoModalContent: {
|
||||||
|
width: SCREEN_WIDTH - 10,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
videoCloseBtn: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: "rgba(239,68,68,0.85)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
videoPlayer: {
|
||||||
|
width: "100%",
|
||||||
|
height: Math.round(SCREEN_HEIGHT * 0.65),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Badge from "./ui/Badge";
|
||||||
|
import { STATUS_LABELS, getStatusColors } from "../utils/constants";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const statusColors = getStatusColors(colors);
|
||||||
|
const label = STATUS_LABELS[status] || status;
|
||||||
|
const color = statusColors[status] || colors.textMuted;
|
||||||
|
return <Badge label={label} color={color} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from '../../theme';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
textColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({ label, color, textColor = '#fff' }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { backgroundColor: color + '22', borderColor: color }]}>
|
||||||
|
<Text style={[styles.text, { color }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: spacing.m,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
type ViewStyle,
|
||||||
|
type TextStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
title: string;
|
||||||
|
onPress: () => void;
|
||||||
|
variant?:
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "danger"
|
||||||
|
| "success"
|
||||||
|
| "outline"
|
||||||
|
| "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: ViewStyle;
|
||||||
|
textStyle?: TextStyle;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
title,
|
||||||
|
onPress,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
textStyle,
|
||||||
|
fullWidth = false,
|
||||||
|
}: ButtonProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const bgColor = {
|
||||||
|
primary: colors.accent,
|
||||||
|
secondary: colors.bgCard,
|
||||||
|
danger: colors.danger,
|
||||||
|
success: colors.success,
|
||||||
|
outline: "transparent",
|
||||||
|
ghost: "transparent",
|
||||||
|
}[variant];
|
||||||
|
|
||||||
|
const txtColor = variant === "success" ? colors.black : colors.textWhite;
|
||||||
|
const borderColor = variant === "outline" ? colors.border : "transparent";
|
||||||
|
|
||||||
|
const paddingV = { sm: spacing.s, md: spacing.m, lg: spacing.l }[size];
|
||||||
|
const paddingH = { sm: spacing.m, md: spacing.xl, lg: spacing.xxl }[size];
|
||||||
|
const fSize = { sm: fontSize.sm, md: fontSize.md, lg: fontSize.lg }[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={[
|
||||||
|
styles.base,
|
||||||
|
{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
borderColor,
|
||||||
|
paddingVertical: paddingV,
|
||||||
|
paddingHorizontal: paddingH,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
fullWidth && styles.fullWidth,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={txtColor} size="small" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{ color: txtColor, fontSize: fSize },
|
||||||
|
textStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
base: {
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { type ReactNode } from "react";
|
||||||
|
import { View, StyleSheet, type ViewStyle } from "react-native";
|
||||||
|
import { spacing, borderRadius, shadows } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({ children, style }: CardProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
shadows.md,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.l,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, ActivityIndicator, Text, StyleSheet } from "react-native";
|
||||||
|
import { spacing, fontSize } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
message?: string;
|
||||||
|
size?: "small" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingSpinner({
|
||||||
|
message,
|
||||||
|
size = "large",
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.bgPrimary }]}>
|
||||||
|
<ActivityIndicator size={size} color={colors.accent} />
|
||||||
|
{message && (
|
||||||
|
<Text style={[styles.text, { color: colors.textSecondary }]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
marginTop: spacing.l,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Modal as RNModal,
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
TouchableOpacity,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Animated,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { spacing, borderRadius, fontSize } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
iconColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
iconColor,
|
||||||
|
}: ModalProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const scale = useRef(new Animated.Value(0.85)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(scale, {
|
||||||
|
toValue: 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
tension: 65,
|
||||||
|
friction: 8,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
scale.setValue(0.85);
|
||||||
|
opacity.setValue(0);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RNModal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.content,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
borderColor: colors.borderSubtle,
|
||||||
|
shadowColor: colors.accent,
|
||||||
|
transform: [{ scale }],
|
||||||
|
opacity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.accentBar,
|
||||||
|
{ backgroundColor: colors.accent },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
{icon && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconCircle,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(iconColor || colors.accent) +
|
||||||
|
"20",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={20}
|
||||||
|
color={iconColor || colors.accent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.title,
|
||||||
|
{ color: colors.textWhite },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
style={[
|
||||||
|
styles.closeBtn,
|
||||||
|
{ backgroundColor: colors.borderSubtle },
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close"
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.body}
|
||||||
|
contentContainerStyle={{ paddingBottom: spacing.xl }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</RNModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.85)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
borderRadius: 20,
|
||||||
|
width: "100%",
|
||||||
|
maxHeight: "80%",
|
||||||
|
borderWidth: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 30,
|
||||||
|
elevation: 20,
|
||||||
|
},
|
||||||
|
accentBar: {
|
||||||
|
height: 3,
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingTop: spacing.l,
|
||||||
|
paddingBottom: spacing.m,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: "700",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TextInput as RNTextInput,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { spacing, borderRadius, fontSize } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface CustomTextInputProps extends TextInputProps {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextInput({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
icon,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: CustomTextInputProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{label && (
|
||||||
|
<Text style={[styles.label, { color: colors.textSecondary }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.inputWrapper,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
borderColor: error ? colors.danger : colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{icon && <View style={styles.icon}>{icon}</View>}
|
||||||
|
<RNTextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: colors.textPrimary },
|
||||||
|
!!icon && styles.inputWithIcon,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
selectionColor={colors.accent}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{error && (
|
||||||
|
<Text style={[styles.errorText, { color: colors.danger }]}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
paddingLeft: spacing.m,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
paddingHorizontal: spacing.l,
|
||||||
|
},
|
||||||
|
inputWithIcon: {
|
||||||
|
paddingLeft: spacing.s,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Animated, Text, StyleSheet } from "react-native";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
visible: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toast({
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
visible,
|
||||||
|
onHide,
|
||||||
|
duration = 3000,
|
||||||
|
}: ToastProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const translateY = useRef(new Animated.Value(-50)).current;
|
||||||
|
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
success: colors.success,
|
||||||
|
error: colors.danger,
|
||||||
|
warning: colors.warning,
|
||||||
|
info: colors.info,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: -50,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => onHide());
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: TYPE_COLORS[type],
|
||||||
|
opacity,
|
||||||
|
transform: [{ translateY }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.text, { color: colors.black }]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
//
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 60,
|
||||||
|
left: spacing.l,
|
||||||
|
right: spacing.l,
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
paddingHorizontal: spacing.l,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
getCart,
|
||||||
|
addToCart as apiAddToCart,
|
||||||
|
removeFromCart as apiRemoveFromCart,
|
||||||
|
clearCart as apiClearCart,
|
||||||
|
} from "../api/api";
|
||||||
|
import { getToken } from "../auth/tokenStorage";
|
||||||
|
import { extractUsernameFromToken } from "../auth/jwtUtils";
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
name_product: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
category: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastData {
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartContextType {
|
||||||
|
cartItems: CartItem[];
|
||||||
|
addToCart: (item: Omit<CartItem, "id">) => Promise<void>;
|
||||||
|
removeFromCart: (id: number) => Promise<void>;
|
||||||
|
clearCart: () => Promise<void>;
|
||||||
|
cartCount: number;
|
||||||
|
cartTotal: number;
|
||||||
|
loading: boolean;
|
||||||
|
refreshCart: () => Promise<void>;
|
||||||
|
toast: ToastData | null;
|
||||||
|
clearToast: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function CartProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toast, setToast] = useState<ToastData | null>(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
|
const showToast = (
|
||||||
|
message: string,
|
||||||
|
type: ToastData["type"] = "success",
|
||||||
|
) => {
|
||||||
|
setToast({ message, type });
|
||||||
|
setTimeout(() => setToast(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearToast = () => setToast(null);
|
||||||
|
|
||||||
|
const getUsername = async (): Promise<string | null> => {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
return extractUsernameFromToken(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshCart = useCallback(async () => {
|
||||||
|
const username = await getUsername();
|
||||||
|
if (!username) {
|
||||||
|
setCartItems([]);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getCart(username);
|
||||||
|
if (response.success && response.panier) {
|
||||||
|
const items: CartItem[] = response.panier.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
product_id: item.product_id,
|
||||||
|
name_product:
|
||||||
|
item.product_name || item.name_product || "Produit",
|
||||||
|
price: item.price,
|
||||||
|
quantity: item.quantity,
|
||||||
|
category: (item.category || "autre").toLowerCase().trim(),
|
||||||
|
image: item.image,
|
||||||
|
}));
|
||||||
|
setCartItems(items);
|
||||||
|
} else {
|
||||||
|
setCartItems([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCartItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshCart();
|
||||||
|
}, [refreshCart]);
|
||||||
|
|
||||||
|
const addToCart = async (item: Omit<CartItem, "id">) => {
|
||||||
|
const username = await getUsername();
|
||||||
|
if (!username) {
|
||||||
|
showToast("Vous devez être connecté.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item.quantity || item.quantity <= 0) {
|
||||||
|
showToast("Quantité invalide", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleanName = (item.name_product || "Produit")
|
||||||
|
.replace(/\s*\([^)]*\)\s*/g, "")
|
||||||
|
.trim();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiAddToCart({
|
||||||
|
username,
|
||||||
|
product_id: item.product_id,
|
||||||
|
name_product: cleanName,
|
||||||
|
category: (item.category || "autre").toLowerCase().trim(),
|
||||||
|
quantity: Number(item.quantity),
|
||||||
|
price: Number(item.price) || 0,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
await refreshCart();
|
||||||
|
showToast(
|
||||||
|
`${cleanName} (${item.quantity}g) ajouté !`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
response.message || "Erreur lors de l'ajout",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Erreur lors de l'ajout", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromCart = async (id: number) => {
|
||||||
|
const username = await getUsername();
|
||||||
|
if (!username) {
|
||||||
|
showToast("Vous devez être connecté.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiRemoveFromCart(id, username);
|
||||||
|
if (response.success) {
|
||||||
|
await refreshCart();
|
||||||
|
showToast("Produit supprimé", "success");
|
||||||
|
} else {
|
||||||
|
showToast(response.message || "Erreur suppression", "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Erreur suppression", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCartAction = async () => {
|
||||||
|
const username = await getUsername();
|
||||||
|
if (!username) {
|
||||||
|
showToast("Vous devez être connecté.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClearCart(username);
|
||||||
|
if (response.success) {
|
||||||
|
setCartItems([]);
|
||||||
|
showToast(response.message || "Panier vidé", "success");
|
||||||
|
} else {
|
||||||
|
showToast(response.message || "Erreur vidage", "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Erreur vidage", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cartCount = cartItems.length;
|
||||||
|
const cartTotal = cartItems.reduce((sum, item) => sum + item.price, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CartContext.Provider
|
||||||
|
value={{
|
||||||
|
cartItems,
|
||||||
|
addToCart,
|
||||||
|
removeFromCart,
|
||||||
|
clearCart: clearCartAction,
|
||||||
|
cartCount,
|
||||||
|
cartTotal,
|
||||||
|
loading,
|
||||||
|
refreshCart,
|
||||||
|
toast,
|
||||||
|
clearToast,
|
||||||
|
isAuthenticated,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
const context = useContext(CartContext);
|
||||||
|
if (!context) throw new Error("useCart must be used within a CartProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { getClientNotifications, markNotificationsRead } from "../api/api";
|
||||||
|
import type { ClientNotification } from "../api/api";
|
||||||
|
import { getToken } from "../auth/tokenStorage";
|
||||||
|
|
||||||
|
interface ToastData {
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationContextType {
|
||||||
|
notifications: ClientNotification[];
|
||||||
|
unreadCount: number;
|
||||||
|
markAllRead: () => Promise<void>;
|
||||||
|
refreshNotifications: () => Promise<void>;
|
||||||
|
toast: ToastData | null;
|
||||||
|
clearToast: () => void;
|
||||||
|
navigateToOrder: number | null;
|
||||||
|
clearNavigateToOrder: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationContext = createContext<NotificationContextType>({
|
||||||
|
notifications: [],
|
||||||
|
unreadCount: 0,
|
||||||
|
markAllRead: async () => {},
|
||||||
|
refreshNotifications: async () => {},
|
||||||
|
toast: null,
|
||||||
|
clearToast: () => {},
|
||||||
|
navigateToOrder: null,
|
||||||
|
clearNavigateToOrder: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
return useContext(NotificationContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToastType(
|
||||||
|
notifType: string,
|
||||||
|
): "success" | "error" | "warning" | "info" {
|
||||||
|
switch (notifType) {
|
||||||
|
case "assigned":
|
||||||
|
return "success";
|
||||||
|
case "en_route":
|
||||||
|
return "info";
|
||||||
|
case "livre":
|
||||||
|
return "success";
|
||||||
|
case "failed":
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [notifications, setNotifications] = useState<ClientNotification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [toast, setToast] = useState<ToastData | null>(null);
|
||||||
|
const [navigateToOrder, setNavigateToOrder] = useState<number | null>(null);
|
||||||
|
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const isFirstLoadRef = useRef(true);
|
||||||
|
|
||||||
|
const clearToast = useCallback(() => setToast(null), []);
|
||||||
|
const clearNavigateToOrder = useCallback(() => setNavigateToOrder(null), []);
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(message: string, type: ToastData["type"]) => {
|
||||||
|
setToast({ message, type });
|
||||||
|
setTimeout(() => setToast(null), 5000);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getClientNotifications();
|
||||||
|
if (response.success) {
|
||||||
|
const newNotifs = response.notifications;
|
||||||
|
setNotifications(newNotifs);
|
||||||
|
setUnreadCount(response.unread_count);
|
||||||
|
|
||||||
|
if (!isFirstLoadRef.current) {
|
||||||
|
for (const notif of newNotifs) {
|
||||||
|
if (notif.read) continue;
|
||||||
|
const key = `${notif.command_id}-${notif.type}-${notif.created_at}`;
|
||||||
|
if (!seenIdsRef.current.has(key)) {
|
||||||
|
seenIdsRef.current.add(key);
|
||||||
|
showToast(notif.message, getToastType(notif.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const notif of newNotifs) {
|
||||||
|
const key = `${notif.command_id}-${notif.type}-${notif.created_at}`;
|
||||||
|
seenIdsRef.current.add(key);
|
||||||
|
}
|
||||||
|
isFirstLoadRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silencieux
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const markAllRead = useCallback(async () => {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await markNotificationsRead();
|
||||||
|
if (response.success) {
|
||||||
|
setUnreadCount(0);
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => ({ ...n, read: true })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silencieux
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Polling toutes les 15 secondes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
const interval = setInterval(fetchNotifications, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider
|
||||||
|
value={{
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
markAllRead,
|
||||||
|
refreshNotifications: fetchNotifications,
|
||||||
|
toast,
|
||||||
|
clearToast,
|
||||||
|
navigateToOrder,
|
||||||
|
clearNavigateToOrder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { darkColors, lightColors, type Colors } from "../theme/colors";
|
||||||
|
import { getPublicSettings } from "../api/api";
|
||||||
|
|
||||||
|
type ThemeMode = "dark" | "light";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
colors: Colors;
|
||||||
|
mode: ThemeMode;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "@theme_mode";
|
||||||
|
const COLORS_CACHE_KEY = "@client_colors_cache";
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [mode, setMode] = useState<ThemeMode>("dark");
|
||||||
|
const [colorOverrides, setColorOverrides] = useState<Partial<Colors>>({});
|
||||||
|
|
||||||
|
const fetchClientColors = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await getPublicSettings();
|
||||||
|
const overrides: Partial<Colors> = {
|
||||||
|
...(s.client_color_primary && { accent: s.client_color_primary }),
|
||||||
|
...(s.client_color_secondary && { secondary: s.client_color_secondary }),
|
||||||
|
...(s.client_color_success && { success: s.client_color_success }),
|
||||||
|
...(s.client_color_danger && { danger: s.client_color_danger }),
|
||||||
|
...(s.client_color_warning && { warning: s.client_color_warning }),
|
||||||
|
};
|
||||||
|
setColorOverrides(overrides);
|
||||||
|
AsyncStorage.setItem(COLORS_CACHE_KEY, JSON.stringify(overrides)).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.getItem(STORAGE_KEY).then((val) => {
|
||||||
|
if (val === "light" || val === "dark") setMode(val);
|
||||||
|
});
|
||||||
|
AsyncStorage.getItem(COLORS_CACHE_KEY).then((val) => {
|
||||||
|
if (val) {
|
||||||
|
try { setColorOverrides(JSON.parse(val)); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fetchClientColors();
|
||||||
|
}, [fetchClientColors]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener("change", (state) => {
|
||||||
|
if (state === "active") fetchClientColors();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [fetchClientColors]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = mode === "dark" ? "light" : "dark";
|
||||||
|
setMode(next);
|
||||||
|
AsyncStorage.setItem(STORAGE_KEY, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseColors = mode === "dark" ? darkColors : lightColors;
|
||||||
|
const colors: Colors = { ...baseColors, ...colorOverrides };
|
||||||
|
|
||||||
|
const value: ThemeContextType = {
|
||||||
|
colors,
|
||||||
|
mode,
|
||||||
|
toggleTheme,
|
||||||
|
isDark: mode === "dark",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Modal,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
Pressable,
|
||||||
|
} from "react-native";
|
||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCart } from "../context/CartContext";
|
||||||
|
import { useNotifications } from "../context/NotificationContext";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
import { logoutUser } from "../api/api";
|
||||||
|
import type { ClientNotification } from "../api/api";
|
||||||
|
import { fontSize, spacing, borderRadius } from "../theme";
|
||||||
|
import Toast from "../components/ui/Toast";
|
||||||
|
import type { ClientTabParamList, ClientStackParamList } from "./types";
|
||||||
|
|
||||||
|
import ProductsScreen from "../screens/client/ProductsScreen";
|
||||||
|
import CartScreen from "../screens/client/CartScreen";
|
||||||
|
import OrderTrackingScreen from "../screens/client/OrderTrackingScreen";
|
||||||
|
import OrderHistoryScreen from "../screens/client/OrderHistoryScreen";
|
||||||
|
import ProductDetailScreen from "../screens/client/ProductDetailScreen";
|
||||||
|
import CheckoutScreen from "../screens/client/CheckoutScreen";
|
||||||
|
import OrderDetailsScreen from "../screens/client/OrderDetailsScreen";
|
||||||
|
import ParrainageScreen from "../screens/client/ParrainageScreen";
|
||||||
|
import ProfileScreen from "../screens/client/ProfileScreen";
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator<ClientTabParamList>();
|
||||||
|
const Stack = createNativeStackNavigator<ClientStackParamList>();
|
||||||
|
|
||||||
|
function formatNotifDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMin < 1) return "A l'instant";
|
||||||
|
if (diffMin < 60) return `Il y a ${diffMin} min`;
|
||||||
|
const diffH = Math.floor(diffMin / 60);
|
||||||
|
if (diffH < 24) return `Il y a ${diffH}h`;
|
||||||
|
const diffD = Math.floor(diffH / 24);
|
||||||
|
if (diffD === 1) return "Hier";
|
||||||
|
return `Il y a ${diffD} jours`;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientTabs() {
|
||||||
|
const { cartCount } = useCart();
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
markAllRead,
|
||||||
|
toast,
|
||||||
|
clearToast,
|
||||||
|
navigateToOrder,
|
||||||
|
clearNavigateToOrder,
|
||||||
|
} = useNotifications();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const { colors, isDark, toggleTheme } = useTheme();
|
||||||
|
const navigation = useNavigation<any>();
|
||||||
|
const [notifModalVisible, setNotifModalVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigateToOrder) {
|
||||||
|
setNotifModalVisible(false);
|
||||||
|
navigation.navigate("Tracking");
|
||||||
|
clearNavigateToOrder();
|
||||||
|
}
|
||||||
|
}, [navigateToOrder, navigation, clearNavigateToOrder]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logoutUser();
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNotifModal = async () => {
|
||||||
|
setNotifModalVisible(true);
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
await markAllRead();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNotifItem = ({ item }: { item: ClientNotification }) => {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.notifItem,
|
||||||
|
{
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
borderLeftColor: item.read
|
||||||
|
? "transparent"
|
||||||
|
: colors.accent,
|
||||||
|
backgroundColor: item.read
|
||||||
|
? "transparent"
|
||||||
|
: colors.accent + "10",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.notifMessage, { color: colors.textPrimary }]}
|
||||||
|
>
|
||||||
|
{item.message || "Notification"}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.notifDate, { color: colors.textMuted }]}>
|
||||||
|
{item.created_at ? formatNotifDate(item.created_at) : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||||
|
headerTintColor: colors.textWhite,
|
||||||
|
headerRight: () => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: spacing.l,
|
||||||
|
gap: spacing.m,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openNotifModal}
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="notifications-outline"
|
||||||
|
size={24}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{unreadCount > 9
|
||||||
|
? "9+"
|
||||||
|
: unreadCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={toggleTheme}>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
isDark
|
||||||
|
? "sunny-outline"
|
||||||
|
: "moon-outline"
|
||||||
|
}
|
||||||
|
size={22}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleLogout}>
|
||||||
|
<Ionicons
|
||||||
|
name="log-out-outline"
|
||||||
|
size={24}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: colors.accent,
|
||||||
|
tabBarInactiveTintColor: colors.textMuted,
|
||||||
|
tabBarLabelStyle: { fontSize: fontSize.xs },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Products"
|
||||||
|
component={ProductsScreen}
|
||||||
|
options={{
|
||||||
|
title: "Produits",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
name="leaf-outline"
|
||||||
|
size={size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Cart"
|
||||||
|
component={CartScreen}
|
||||||
|
options={{
|
||||||
|
title: "Panier",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
name="cart-outline"
|
||||||
|
size={size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tabBarBadge: cartCount > 0 ? cartCount : undefined,
|
||||||
|
tabBarBadgeStyle: { backgroundColor: colors.accent },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Tracking"
|
||||||
|
component={OrderTrackingScreen}
|
||||||
|
options={{
|
||||||
|
title: "Suivi",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
name="navigate-outline"
|
||||||
|
size={size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="History"
|
||||||
|
component={OrderHistoryScreen}
|
||||||
|
options={{
|
||||||
|
title: "Historique",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
name="time-outline"
|
||||||
|
size={size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Profile"
|
||||||
|
component={ProfileScreen}
|
||||||
|
options={{
|
||||||
|
title: "Profil",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
name="person-outline"
|
||||||
|
size={size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={notifModalVisible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={() => setNotifModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modalOverlay,
|
||||||
|
{ backgroundColor: "rgba(0,0,0,0.5)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modalContent,
|
||||||
|
{ backgroundColor: colors.bgPrimary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modalHeader,
|
||||||
|
{ borderBottomColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.modalTitle,
|
||||||
|
{ color: colors.textPrimary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setNotifModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close"
|
||||||
|
size={24}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={notifications || []}
|
||||||
|
keyExtractor={(item, index) => {
|
||||||
|
if (
|
||||||
|
item?.command_id &&
|
||||||
|
item?.type &&
|
||||||
|
item?.created_at
|
||||||
|
) {
|
||||||
|
return `${item.command_id}-${item.type}-${item.created_at}`;
|
||||||
|
}
|
||||||
|
return `notif-${index}`;
|
||||||
|
}}
|
||||||
|
renderItem={renderNotifItem}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.textMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Aucune notification
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Toast
|
||||||
|
message={toast?.message || ""}
|
||||||
|
type={toast?.type || "info"}
|
||||||
|
visible={!!toast}
|
||||||
|
onHide={clearToast}
|
||||||
|
duration={5000}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientNavigator() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||||
|
headerTintColor: colors.textWhite,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ClientTabs"
|
||||||
|
component={ClientTabs}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ProductDetail"
|
||||||
|
component={ProductDetailScreen}
|
||||||
|
options={{ title: "Detail produit" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Checkout"
|
||||||
|
component={CheckoutScreen}
|
||||||
|
options={{ title: "Validation commande" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="OrderDetails"
|
||||||
|
component={OrderDetailsScreen}
|
||||||
|
options={{ title: "Detail commande" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Parrainage"
|
||||||
|
component={ParrainageScreen}
|
||||||
|
options={{ title: "Parrainage" }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
notifItem: {
|
||||||
|
padding: spacing.m,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
},
|
||||||
|
notifMessage: {
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
notifDate: {
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
height: "70%",
|
||||||
|
borderTopLeftRadius: borderRadius.lg,
|
||||||
|
borderTopRightRadius: borderRadius.lg,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.l,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export type RootStackParamList = {
|
||||||
|
ServerConfig: undefined;
|
||||||
|
home: undefined;
|
||||||
|
login: undefined;
|
||||||
|
register: undefined;
|
||||||
|
role: undefined;
|
||||||
|
clientTwoFA: { sessionToken: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordStackParamList = {
|
||||||
|
ChangePassword: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthStackParamList = {
|
||||||
|
RoleSelect: undefined;
|
||||||
|
ClientLogin: undefined;
|
||||||
|
AdminLogin: undefined;
|
||||||
|
CabineLogin: undefined;
|
||||||
|
LivreurLogin: undefined;
|
||||||
|
Register: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientTabParamList = {
|
||||||
|
Products: undefined;
|
||||||
|
Cart: undefined;
|
||||||
|
Tracking: undefined;
|
||||||
|
History: undefined;
|
||||||
|
Profile: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientStackParamList = {
|
||||||
|
ClientTabs: undefined;
|
||||||
|
ProductDetail: { productId: number };
|
||||||
|
Checkout: undefined;
|
||||||
|
OrderDetails: { orderId: number };
|
||||||
|
Parrainage: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeliveryTabParamList = {
|
||||||
|
Dashboard: undefined;
|
||||||
|
Stats: undefined;
|
||||||
|
Alerts: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminTabParamList = {
|
||||||
|
Dashboard: undefined;
|
||||||
|
Orders: undefined;
|
||||||
|
Users: undefined;
|
||||||
|
Products: undefined;
|
||||||
|
Delivery: undefined;
|
||||||
|
Alerts: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CabineTabParamList = {
|
||||||
|
Dashboard: undefined;
|
||||||
|
Orders: undefined;
|
||||||
|
Delivery: undefined;
|
||||||
|
Users: undefined;
|
||||||
|
Alerts: undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||||
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "../navigation/types";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, "home">;
|
||||||
|
|
||||||
|
export default function HomeScreen({ navigation }: Props) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
|
||||||
|
>
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.textWhite }]}>
|
||||||
|
Bienvenue sur l'app !
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||||
|
Connectez-vous pour continuer
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, { backgroundColor: colors.accent }]}
|
||||||
|
onPress={() => navigation.navigate("login")}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Se connecter</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
registerButton: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#7c3aed",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "white",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
registerButtonText: {
|
||||||
|
color: "#7c3aed",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { changePassword } from "../../api/api";
|
||||||
|
|
||||||
|
export default function ChangePasswordScreen() {
|
||||||
|
const { setMustChangePassword } = useAuth();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showCurrent, setShowCurrent] = useState(false);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
Alert.alert("Erreur", "Veuillez remplir tous les champs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
Alert.alert(
|
||||||
|
"Erreur",
|
||||||
|
"Le nouveau mot de passe doit contenir au moins 8 caractères",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
Alert.alert("Erreur", "Les nouveaux mots de passe ne correspondent pas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await changePassword(currentPassword, newPassword);
|
||||||
|
if (result.success) {
|
||||||
|
setMustChangePassword(false);
|
||||||
|
} else {
|
||||||
|
Alert.alert("Erreur", result.message || "Erreur inattendue");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.container, { backgroundColor: colors.bgPrimary }]}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.iconWrapper}>
|
||||||
|
<Feather name="lock" size={48} color="#7c3aed" />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.textPrimary }]}>
|
||||||
|
Changez votre mot de passe
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
|
||||||
|
Pour accéder à l'application, vous devez définir un nouveau mot de
|
||||||
|
passe personnel.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[styles.inputWrapper, { backgroundColor: colors.bgSecondary, borderColor: colors.border }]}>
|
||||||
|
<Feather name="lock" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.textPrimary }]}
|
||||||
|
placeholder="Mot de passe actuel"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
secureTextEntry={!showCurrent}
|
||||||
|
value={currentPassword}
|
||||||
|
onChangeText={setCurrentPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setShowCurrent(!showCurrent)}>
|
||||||
|
<Feather name={showCurrent ? "eye-off" : "eye"} size={18} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.inputWrapper, { backgroundColor: colors.bgSecondary, borderColor: colors.border }]}>
|
||||||
|
<Feather name="key" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.textPrimary }]}
|
||||||
|
placeholder="Nouveau mot de passe (min. 8 car.)"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
secureTextEntry={!showNew}
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setShowNew(!showNew)}>
|
||||||
|
<Feather name={showNew ? "eye-off" : "eye"} size={18} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.inputWrapper, { backgroundColor: colors.bgSecondary, borderColor: colors.border }]}>
|
||||||
|
<Feather name="check-circle" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.textPrimary }]}
|
||||||
|
placeholder="Confirmer le nouveau mot de passe"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
secureTextEntry={!showConfirm}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setShowConfirm(!showConfirm)}>
|
||||||
|
<Feather name={showConfirm ? "eye-off" : "eye"} size={18} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleChangePassword}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Confirmer</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 24,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 10,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 32,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 50,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#7c3aed",
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Feather, FontAwesome } from "@expo/vector-icons";
|
||||||
|
import { loginUser } from "../../api/api";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import type { LoginRequest } from "../../api/api_types";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "../../navigation/types";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
type LoginScreenNavigationProp = NativeStackNavigationProp<
|
||||||
|
RootStackParamList,
|
||||||
|
"login"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const LoginClient = () => {
|
||||||
|
const navigation = useNavigation<LoginScreenNavigationProp>();
|
||||||
|
const { loginClient } = useAuth();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<LoginRequest>({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}>({});
|
||||||
|
const [apiError, setApiError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors: { username?: string; password?: string } = {};
|
||||||
|
if (!formData.username.trim()) newErrors.username = "Username requis";
|
||||||
|
else if (formData.username.trim().length < 3)
|
||||||
|
newErrors.username = "Username trop court";
|
||||||
|
if (!formData.password) newErrors.password = "Mot de passe requis";
|
||||||
|
else if (formData.password.length < 6)
|
||||||
|
newErrors.password = "Mot de passe trop court";
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setApiError("");
|
||||||
|
try {
|
||||||
|
const result = await loginUser(
|
||||||
|
formData.username,
|
||||||
|
formData.password,
|
||||||
|
);
|
||||||
|
if (result.requires_2fa && result.session_token) {
|
||||||
|
navigation.navigate("clientTwoFA", {
|
||||||
|
sessionToken: result.session_token,
|
||||||
|
});
|
||||||
|
} else if (result.success && result.access_token) {
|
||||||
|
await loginClient(result.access_token, result.user?.must_change_password ?? false);
|
||||||
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
result.message || "Identifiants incorrects";
|
||||||
|
setApiError(errorMessage);
|
||||||
|
setErrors({ username: errorMessage });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Erreur de connexion";
|
||||||
|
setApiError(message);
|
||||||
|
setErrors({ username: message });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (name: "username" | "password", value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
if (errors[name]) setErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||||
|
if (apiError) setApiError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgSecondary },
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
header: { alignItems: "center", marginBottom: 24 },
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 8,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
apiError: {
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
color: "#991b1b",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
inputGroup: { marginBottom: 16 },
|
||||||
|
label: { marginBottom: 4, fontWeight: "500", color: colors.textSecondary },
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
},
|
||||||
|
icon: { marginRight: 8 },
|
||||||
|
input: { flex: 1, height: 40, color: colors.textPrimary },
|
||||||
|
inputError: { borderColor: "#ef4444" },
|
||||||
|
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
|
||||||
|
eyeButton: { padding: 4 },
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
submitText: { color: colors.white, fontWeight: "600", fontSize: 16 },
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<FontAwesome name="user-circle" size={48} color={colors.accent} />
|
||||||
|
<Text style={styles.title}>
|
||||||
|
Connexion Client
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Accédez à votre espace personnel
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{apiError ? (
|
||||||
|
<Text style={styles.apiError}>⚠️ {apiError}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Username Telegram
|
||||||
|
</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<Feather
|
||||||
|
name="user"
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
style={styles.icon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, errors.username && styles.inputError]}
|
||||||
|
placeholder="Votre username"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
value={formData.username}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
handleChange("username", value)
|
||||||
|
}
|
||||||
|
editable={!isLoading}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{errors.username && (
|
||||||
|
<Text style={styles.errorText}>{errors.username}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Mot de passe
|
||||||
|
</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<Feather
|
||||||
|
name="lock"
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
style={styles.icon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, errors.password && styles.inputError]}
|
||||||
|
placeholder="••••••"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
value={formData.password}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
handleChange("password", value)
|
||||||
|
}
|
||||||
|
editable={!isLoading}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.eyeButton}
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name={showPassword ? "eye-off" : "eye"}
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{errors.password && (
|
||||||
|
<Text style={styles.errorText}>{errors.password}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitButton, isLoading && { opacity: 0.6 }]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color={colors.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitText}>Se connecter</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginClient;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { verifyClient2FA } from "../../api/api";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "../../navigation/types";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
type TwoFANavigationProp = NativeStackNavigationProp<
|
||||||
|
RootStackParamList,
|
||||||
|
"clientTwoFA"
|
||||||
|
>;
|
||||||
|
type TwoFARouteProp = RouteProp<RootStackParamList, "clientTwoFA">;
|
||||||
|
|
||||||
|
const ClientTwoFAScreen = () => {
|
||||||
|
const navigation = useNavigation<TwoFANavigationProp>();
|
||||||
|
const route = useRoute<TwoFARouteProp>();
|
||||||
|
const { sessionToken } = route.params;
|
||||||
|
const { loginClient } = useAuth();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [apiError, setApiError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (code.trim().length !== 6) {
|
||||||
|
setApiError("Le code doit contenir 6 chiffres");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setApiError("");
|
||||||
|
try {
|
||||||
|
const result = await verifyClient2FA(sessionToken, code.trim());
|
||||||
|
if (result.success && result.access_token) {
|
||||||
|
await loginClient(result.access_token, result.user?.must_change_password ?? false);
|
||||||
|
} else {
|
||||||
|
setApiError(result.message || "Code invalide");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Erreur de connexion";
|
||||||
|
setApiError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeCode = (value: string) => {
|
||||||
|
const digitsOnly = value.replace(/[^0-9]/g, "").slice(0, 6);
|
||||||
|
setCode(digitsOnly);
|
||||||
|
if (apiError) setApiError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgSecondary },
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
header: { alignItems: "center", marginBottom: 24 },
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 8,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
apiError: {
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
color: "#991b1b",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
inputGroup: { marginBottom: 16 },
|
||||||
|
label: { marginBottom: 4, fontWeight: "500", color: colors.textSecondary },
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
},
|
||||||
|
icon: { marginRight: 8 },
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: 20,
|
||||||
|
letterSpacing: 8,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
submitText: { color: colors.white, fontWeight: "600", fontSize: 16 },
|
||||||
|
backButton: { alignItems: "center", marginTop: 16 },
|
||||||
|
backText: { color: colors.textMuted, fontSize: 14 },
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.container} behavior="padding">
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Feather name="shield" size={48} color={colors.accent} />
|
||||||
|
<Text style={styles.title}>Vérification en deux étapes</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Entrez le code à 6 chiffres envoyé sur Telegram
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{apiError ? (
|
||||||
|
<Text style={styles.apiError}>⚠️ {apiError}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Code de vérification</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="000000"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
value={code}
|
||||||
|
onChangeText={handleChangeCode}
|
||||||
|
editable={!isLoading}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitButton, isLoading && { opacity: 0.6 }]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color={colors.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitText}>Vérifier</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.backText}>Retour à la connexion</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientTwoFAScreen;
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "../../navigation/types";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { getStoredServerUrl, saveServerUrl } from "../../api/client";
|
||||||
|
import { normalizeServerUrl } from "../../utils/serverConfig";
|
||||||
|
import Toast from "../../components/ui/Toast";
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList, "ServerConfig">;
|
||||||
|
|
||||||
|
export default function ServerConfigScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [toastVisible, setToastVisible] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const stored = await getStoredServerUrl();
|
||||||
|
if (stored) setAddress(stored);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgSecondary },
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
header: { alignItems: "center", marginBottom: 24 },
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 8,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
inputGroup: { marginBottom: 16 },
|
||||||
|
label: {
|
||||||
|
marginBottom: 4,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
},
|
||||||
|
icon: { marginRight: 8 },
|
||||||
|
input: { flex: 1, height: 40, color: colors.textPrimary },
|
||||||
|
inputError: { borderColor: colors.danger },
|
||||||
|
errorText: { color: colors.danger, fontSize: 12, marginTop: 4 },
|
||||||
|
hint: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContinue = async () => {
|
||||||
|
const normalized = normalizeServerUrl(address);
|
||||||
|
if (!normalized) {
|
||||||
|
setError(
|
||||||
|
"Utilisez une URL (https://exemple.com) ou une adresse IP:Port (192.168.1.10:8000)",
|
||||||
|
);
|
||||||
|
setToastVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await saveServerUrl(normalized);
|
||||||
|
navigation.replace("home");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.container} behavior="padding">
|
||||||
|
<Toast
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
visible={toastVisible}
|
||||||
|
onHide={() => setToastVisible(false)}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Feather
|
||||||
|
name="server"
|
||||||
|
size={48}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
<Text style={styles.title}>Serveur API</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Renseignez l'adresse du serveur avant de
|
||||||
|
continuer
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Adresse du serveur</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.inputWrapper,
|
||||||
|
error && styles.inputError,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name="globe"
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
style={styles.icon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="192.168.1.10:8000 ou https://exemple.com"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
value={address}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setAddress(value);
|
||||||
|
if (error) setError("");
|
||||||
|
}}
|
||||||
|
editable={!isLoading}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
URL complète ou adresse IP suivie du port
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.submitButton,
|
||||||
|
isLoading && { opacity: 0.6 },
|
||||||
|
]}
|
||||||
|
onPress={handleContinue}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color={colors.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitText}>Continuer</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
} from "react-native";
|
||||||
|
import { useNavigation, useFocusEffect } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCart } from "../../context/CartContext";
|
||||||
|
import { getProductById } from "../../api/api";
|
||||||
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import Modal from "../../components/ui/Modal";
|
||||||
|
import Toast from "../../components/ui/Toast";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import {
|
||||||
|
spacing,
|
||||||
|
borderRadius,
|
||||||
|
fontSize,
|
||||||
|
fontWeight,
|
||||||
|
shadows,
|
||||||
|
} from "../../theme";
|
||||||
|
import { API_BASE_URL } from "../../api/client";
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<ClientStackParamList>;
|
||||||
|
|
||||||
|
interface EnrichedItem {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
name_product: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
category: string;
|
||||||
|
imageUri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const {
|
||||||
|
cartItems,
|
||||||
|
removeFromCart,
|
||||||
|
clearCart,
|
||||||
|
cartCount,
|
||||||
|
cartTotal,
|
||||||
|
loading,
|
||||||
|
refreshCart,
|
||||||
|
toast,
|
||||||
|
clearToast,
|
||||||
|
} = useCart();
|
||||||
|
const [enrichedItems, setEnrichedItems] = useState<EnrichedItem[]>([]);
|
||||||
|
const [loadingMedia, setLoadingMedia] = useState(false);
|
||||||
|
const [removeId, setRemoveId] = useState<number | null>(null);
|
||||||
|
const [showClearModal, setShowClearModal] = useState(false);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
refreshCart();
|
||||||
|
}, [refreshCart]),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const enrichItems = async () => {
|
||||||
|
if (cartItems.length === 0) {
|
||||||
|
setEnrichedItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingMedia(true);
|
||||||
|
const enriched: EnrichedItem[] = await Promise.all(
|
||||||
|
cartItems.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const res = await getProductById(item.product_id);
|
||||||
|
const p = res?.data || res?.product || res;
|
||||||
|
const img = p?.media?.find(
|
||||||
|
(m: any) => m.type === "image",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
imageUri: img
|
||||||
|
? `${API_BASE_URL}${img.url}`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...item, imageUri: undefined };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setEnrichedItems(enriched);
|
||||||
|
setLoadingMedia(false);
|
||||||
|
};
|
||||||
|
enrichItems();
|
||||||
|
}, [cartItems]);
|
||||||
|
|
||||||
|
const handleRemove = (id: number) => setRemoveId(id);
|
||||||
|
|
||||||
|
const confirmRemove = () => {
|
||||||
|
if (removeId !== null) removeFromCart(removeId);
|
||||||
|
setRemoveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => setShowClearModal(true);
|
||||||
|
|
||||||
|
const confirmClear = () => {
|
||||||
|
clearCart();
|
||||||
|
setShowClearModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItemName = removeId
|
||||||
|
? (
|
||||||
|
enrichedItems.find((i) => i.id === removeId) ||
|
||||||
|
cartItems.find((i) => i.id === removeId)
|
||||||
|
)?.name_product || "ce produit"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.xl,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginTop: spacing.l,
|
||||||
|
},
|
||||||
|
emptySubtitle: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.l,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
clearBtn: {
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.medium,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: spacing.l,
|
||||||
|
paddingBottom: 180,
|
||||||
|
},
|
||||||
|
cartItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.m,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
itemImage: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
},
|
||||||
|
itemImagePlaceholder: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
itemInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.m,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
itemQuantity: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
itemPrice: {
|
||||||
|
color: colors.success,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
removeBtn: {
|
||||||
|
padding: spacing.m,
|
||||||
|
},
|
||||||
|
modalBody: {
|
||||||
|
gap: spacing.l,
|
||||||
|
},
|
||||||
|
modalIconContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
modalIconCircleDanger: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: "rgba(239,68,68,0.12)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
modalText: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
modalBold: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: spacing.m,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
padding: spacing.xl,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
color: colors.success,
|
||||||
|
fontSize: fontSize.xxl,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading && cartItems.length === 0) {
|
||||||
|
return <LoadingSpinner message="Chargement du panier..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
visible={!!toast}
|
||||||
|
onHide={clearToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cartItems.length === 0 ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name="cart-outline"
|
||||||
|
size={80}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Panier vide</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
Ajoutez des produits pour commencer
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>
|
||||||
|
{cartCount} article{cartCount > 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={handleClear}>
|
||||||
|
<Text style={styles.clearBtn}>Tout vider</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={
|
||||||
|
enrichedItems.length > 0 ? enrichedItems : cartItems
|
||||||
|
}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={refreshCart}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={[styles.cartItem, shadows.sm]}>
|
||||||
|
{(item as EnrichedItem).imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: (item as EnrichedItem)
|
||||||
|
.imageUri,
|
||||||
|
}}
|
||||||
|
style={styles.itemImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.itemImagePlaceholder}>
|
||||||
|
<Ionicons
|
||||||
|
name="leaf-outline"
|
||||||
|
size={24}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.itemInfo}>
|
||||||
|
<Text
|
||||||
|
style={styles.itemName}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.name_product}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.itemQuantity}>
|
||||||
|
{item.quantity}g
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.itemPrice}>
|
||||||
|
{item.price.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleRemove(item.id)}
|
||||||
|
style={styles.removeBtn}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="trash-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={[styles.footer, shadows.lg]}>
|
||||||
|
<View style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>Total</Text>
|
||||||
|
<Text style={styles.totalValue}>
|
||||||
|
{cartTotal.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
title="Commander"
|
||||||
|
onPress={() => navigation.navigate("Checkout")}
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={removeId !== null}
|
||||||
|
onClose={() => setRemoveId(null)}
|
||||||
|
title="Supprimer le produit"
|
||||||
|
icon="trash-outline"
|
||||||
|
iconColor={colors.danger}
|
||||||
|
>
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
<View style={styles.modalIconContainer}>
|
||||||
|
<View style={styles.modalIconCircleDanger}>
|
||||||
|
<Ionicons
|
||||||
|
name="trash"
|
||||||
|
size={28}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
Retirer{" "}
|
||||||
|
<Text style={styles.modalBold}>{removeItemName}</Text>{" "}
|
||||||
|
du panier ?
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
title="Annuler"
|
||||||
|
onPress={() => setRemoveId(null)}
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Supprimer"
|
||||||
|
onPress={confirmRemove}
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showClearModal}
|
||||||
|
onClose={() => setShowClearModal(false)}
|
||||||
|
title="Vider le panier"
|
||||||
|
icon="cart-outline"
|
||||||
|
iconColor={colors.danger}
|
||||||
|
>
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
<View style={styles.modalIconContainer}>
|
||||||
|
<View style={styles.modalIconCircleDanger}>
|
||||||
|
<Ionicons
|
||||||
|
name="cart"
|
||||||
|
size={28}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
Supprimer les{" "}
|
||||||
|
<Text style={styles.modalBold}>
|
||||||
|
{cartCount} article{cartCount > 1 ? "s" : ""}
|
||||||
|
</Text>{" "}
|
||||||
|
du panier ?
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
title="Annuler"
|
||||||
|
onPress={() => setShowClearModal(false)}
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Vider"
|
||||||
|
onPress={confirmClear}
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
TouchableOpacity,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
} from "react-native";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import type { RouteProp } from "@react-navigation/native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
getCommandItemsWithDetails,
|
||||||
|
getProductById,
|
||||||
|
formatOrderDate,
|
||||||
|
formatPrice,
|
||||||
|
submitLivreurRating,
|
||||||
|
getOrderRatingStatus,
|
||||||
|
} from "../../api/api";
|
||||||
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
import Card from "../../components/ui/Card";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
|
import { API_BASE_URL } from "../../api/client";
|
||||||
|
|
||||||
|
type Route = RouteProp<ClientStackParamList, "OrderDetails">;
|
||||||
|
|
||||||
|
const TIMELINE_STEPS = [
|
||||||
|
{
|
||||||
|
key: "pending",
|
||||||
|
label: "Confirmee",
|
||||||
|
icon: "checkmark-circle-outline" as const,
|
||||||
|
},
|
||||||
|
{ key: "assigned", label: "Assignee", icon: "person-outline" as const },
|
||||||
|
{ key: "en_route", label: "En route", icon: "bicycle-outline" as const },
|
||||||
|
{ key: "arrived", label: "Arrivee", icon: "flag-outline" as const },
|
||||||
|
{ key: "livre", label: "Livree", icon: "cube-outline" as const },
|
||||||
|
{
|
||||||
|
key: "approved",
|
||||||
|
label: "Terminee",
|
||||||
|
icon: "shield-checkmark-outline" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_INDEX: Record<string, number> = {
|
||||||
|
pending: 0,
|
||||||
|
assigned: 1,
|
||||||
|
en_route: 2,
|
||||||
|
arrived: 3,
|
||||||
|
livre: 4,
|
||||||
|
delivered: 4,
|
||||||
|
approved: 5,
|
||||||
|
cancelled: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderDetailsScreen() {
|
||||||
|
const { params } = useRoute<Route>();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [order, setOrder] = useState<any>(null);
|
||||||
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [ratingModal, setRatingModal] = useState(false);
|
||||||
|
const [selectedStars, setSelectedStars] = useState(0);
|
||||||
|
const [ratingComment, setRatingComment] = useState("");
|
||||||
|
const [ratingSubmitting, setRatingSubmitting] = useState(false);
|
||||||
|
const [ratingDone, setRatingDone] = useState<{ rating: number; comment: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCommandItemsWithDetails(params.orderId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const data = res.data;
|
||||||
|
const cmd = data.command || data.command_info || data;
|
||||||
|
if (cmd.command_status && !cmd.status)
|
||||||
|
cmd.status = cmd.command_status;
|
||||||
|
if (cmd.command_address && !cmd.delivery_address)
|
||||||
|
cmd.delivery_address = cmd.command_address;
|
||||||
|
if (data.client_info) {
|
||||||
|
cmd.client_nom = cmd.client_nom || data.client_info.nom;
|
||||||
|
cmd.client_prenom =
|
||||||
|
cmd.client_prenom || data.client_info.prenom;
|
||||||
|
cmd.client_telephone =
|
||||||
|
cmd.client_telephone || data.client_info.telephone;
|
||||||
|
}
|
||||||
|
setOrder(cmd);
|
||||||
|
const items = data.items || data.products || [];
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
items.map(async (item: any) => {
|
||||||
|
try {
|
||||||
|
const pRes = await getProductById(
|
||||||
|
item.product_id || item.id,
|
||||||
|
);
|
||||||
|
const p = pRes?.data || pRes?.product || pRes;
|
||||||
|
const img = p?.media?.find(
|
||||||
|
(m: any) => m.type === "image",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
imageUri: img
|
||||||
|
? `${API_BASE_URL}${img.url}`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setProducts(enriched);
|
||||||
|
if (cmd.status === "approved") {
|
||||||
|
const r = await getOrderRatingStatus(params.orderId);
|
||||||
|
if (r.rated) setRatingDone({ rating: r.rating!, comment: r.comment ?? "" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Commande introuvable");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Erreur de chargement");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [params.orderId]);
|
||||||
|
|
||||||
|
const handleSubmitRating = async () => {
|
||||||
|
if (selectedStars === 0) return;
|
||||||
|
setRatingSubmitting(true);
|
||||||
|
const res = await submitLivreurRating(params.orderId, selectedStars, ratingComment);
|
||||||
|
setRatingSubmitting(false);
|
||||||
|
if (res.success) {
|
||||||
|
setRatingDone({ rating: selectedStars, comment: ratingComment });
|
||||||
|
setRatingModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||||
|
content: { padding: spacing.l, paddingBottom: spacing.xxxl },
|
||||||
|
center: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
errorText: { color: colors.danger, fontSize: fontSize.md },
|
||||||
|
headerCard: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.xl,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.xl,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
date: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||||
|
sectionTitle: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.medium,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
timelineStep: {
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
timelineDot: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.border,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
timelineDotCompleted: {
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
borderColor: colors.successDark,
|
||||||
|
},
|
||||||
|
timelineDotCurrent: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderColor: colors.accentLight,
|
||||||
|
shadowColor: colors.accent,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
timelineLabel: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 10,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
timelineLabelCompleted: {
|
||||||
|
color: colors.success,
|
||||||
|
fontWeight: fontWeight.medium,
|
||||||
|
},
|
||||||
|
timelineLabelCurrent: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
timelineLine: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 15,
|
||||||
|
left: "58%",
|
||||||
|
right: "-42%",
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
timelineLineCompleted: { backgroundColor: colors.success },
|
||||||
|
timelineLineCurrent: { backgroundColor: colors.accent },
|
||||||
|
currentBadge: { marginTop: 4, alignItems: "center" },
|
||||||
|
currentDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
},
|
||||||
|
cancelledBanner: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.m,
|
||||||
|
paddingVertical: spacing.l,
|
||||||
|
},
|
||||||
|
cancelledIconCircle: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: "rgba(239,68,68,0.12)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelledText: {
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
productRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
},
|
||||||
|
productRowBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
productImage: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
},
|
||||||
|
productImagePlaceholder: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
productInfo: { flex: 1, marginLeft: spacing.m },
|
||||||
|
productName: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.medium,
|
||||||
|
},
|
||||||
|
productQty: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
productPrice: {
|
||||||
|
color: colors.success,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
color: colors.success,
|
||||||
|
fontSize: fontSize.xxl,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
ratingBtn: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
backgroundColor: "#f59e0b",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
marginTop: spacing.m,
|
||||||
|
},
|
||||||
|
ratingBtnText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
ratingDoneRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginTop: spacing.m,
|
||||||
|
padding: spacing.s,
|
||||||
|
backgroundColor: "rgba(245,158,11,0.1)",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
},
|
||||||
|
ratingDoneText: {
|
||||||
|
color: "#f59e0b",
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.7)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderTopLeftRadius: borderRadius.xl,
|
||||||
|
borderTopRightRadius: borderRadius.xl,
|
||||||
|
padding: spacing.l,
|
||||||
|
paddingBottom: spacing.xxxl,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
modalSubtitle: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
starsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
commentInput: {
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
padding: spacing.m,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
backgroundColor: "#f59e0b",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
submitBtnText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
cancelBtn: { alignItems: "center", paddingVertical: spacing.s },
|
||||||
|
cancelBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner message="Chargement..." />;
|
||||||
|
if (error || !order) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{error || "Commande introuvable"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = order.status || "pending";
|
||||||
|
const currentIdx = STATUS_INDEX[status] ?? -1;
|
||||||
|
const isCancelled = status === "cancelled";
|
||||||
|
const address = order.delivery_address || order.adresse || "N/A";
|
||||||
|
const rawTotal =
|
||||||
|
order.total ||
|
||||||
|
order.total_prix ||
|
||||||
|
products.reduce((s: number, p: any) => s + (p.prix || p.price || 0), 0);
|
||||||
|
const referralUsed: number = order.referral_used || 0;
|
||||||
|
const total = rawTotal - referralUsed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
>
|
||||||
|
<View style={styles.headerCard}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.title}>Commande #{order.client_order_number}</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.date}>
|
||||||
|
{formatOrderDate(order.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: spacing.l }}>
|
||||||
|
<Text style={styles.sectionTitle}>Suivi</Text>
|
||||||
|
{isCancelled ? (
|
||||||
|
<View style={styles.cancelledBanner}>
|
||||||
|
<View style={styles.cancelledIconCircle}>
|
||||||
|
<Ionicons
|
||||||
|
name="close-circle"
|
||||||
|
size={28}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cancelledText}>
|
||||||
|
Commande annulee
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.timeline}>
|
||||||
|
{TIMELINE_STEPS.map((step, idx) => {
|
||||||
|
const isFinished =
|
||||||
|
currentIdx >= TIMELINE_STEPS.length - 1;
|
||||||
|
const completed = isFinished
|
||||||
|
? true
|
||||||
|
: idx < currentIdx;
|
||||||
|
const current = isFinished
|
||||||
|
? false
|
||||||
|
: idx === currentIdx;
|
||||||
|
const lineCompleted = isFinished
|
||||||
|
? true
|
||||||
|
: idx < currentIdx;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={step.key}
|
||||||
|
style={styles.timelineStep}
|
||||||
|
>
|
||||||
|
{idx < TIMELINE_STEPS.length - 1 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineLine,
|
||||||
|
lineCompleted &&
|
||||||
|
styles.timelineLineCompleted,
|
||||||
|
current &&
|
||||||
|
styles.timelineLineCurrent,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineDot,
|
||||||
|
completed &&
|
||||||
|
styles.timelineDotCompleted,
|
||||||
|
current &&
|
||||||
|
styles.timelineDotCurrent,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{completed ? (
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark"
|
||||||
|
size={16}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={step.icon}
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
current
|
||||||
|
? colors.white
|
||||||
|
: colors.textMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.timelineLabel,
|
||||||
|
completed &&
|
||||||
|
styles.timelineLabelCompleted,
|
||||||
|
current &&
|
||||||
|
styles.timelineLabelCurrent,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</Text>
|
||||||
|
{current && (
|
||||||
|
<View style={styles.currentBadge}>
|
||||||
|
<View style={styles.currentDot} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: spacing.l }}>
|
||||||
|
<Text style={styles.sectionTitle}>Livraison</Text>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="location-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.infoText}>{address}</Text>
|
||||||
|
</View>
|
||||||
|
{order.livreur_assign && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="bicycle-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
{order.livreur_assign}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{order.status === "approved" && (
|
||||||
|
ratingDone ? (
|
||||||
|
<View style={styles.ratingDoneRow}>
|
||||||
|
<Ionicons name="star" size={14} color="#f59e0b" />
|
||||||
|
<Text style={styles.ratingDoneText}>
|
||||||
|
Noté {ratingDone.rating}/5{ratingDone.comment ? ` · "${ratingDone.comment}"` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={styles.ratingBtn} onPress={() => setRatingModal(true)}>
|
||||||
|
<Ionicons name="star-outline" size={14} color="#fff" />
|
||||||
|
<Text style={styles.ratingBtnText}>Noter le livreur</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{(order.client_prenom || order.first_name) && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="person-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
{order.first_name || order.client_prenom}{" "}
|
||||||
|
{order.last_name || order.client_nom}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{(order.phone || order.client_telephone) && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="call-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
{order.phone || order.client_telephone}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: spacing.l }}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Produits ({products.length})
|
||||||
|
</Text>
|
||||||
|
{products.map((product, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
style={[
|
||||||
|
styles.productRow,
|
||||||
|
idx > 0 && styles.productRowBorder,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{product.imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: product.imageUri }}
|
||||||
|
style={styles.productImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.productImagePlaceholder}>
|
||||||
|
<Ionicons
|
||||||
|
name="leaf-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.productInfo}>
|
||||||
|
<Text style={styles.productName} numberOfLines={1}>
|
||||||
|
{product.produit ||
|
||||||
|
product.product_name ||
|
||||||
|
product.name_product ||
|
||||||
|
"Produit"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.productQty}>
|
||||||
|
{product.quantite || product.quantity || 0}g
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.productPrice}>
|
||||||
|
{formatPrice(product.prix || product.price || 0)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{referralUsed > 0 && (
|
||||||
|
<View style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>Crédit parrainage</Text>
|
||||||
|
<Text style={[styles.totalValue, { color: colors.accent }]}>
|
||||||
|
-{formatPrice(referralUsed)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>Total</Text>
|
||||||
|
<Text style={styles.totalValue}>{formatPrice(total)}</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal visible={ratingModal} transparent animationType="slide" onRequestClose={() => setRatingModal(false)}>
|
||||||
|
<Pressable style={styles.modalOverlay} onPress={() => setRatingModal(false)}>
|
||||||
|
<Pressable onPress={() => {}}>
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<Text style={styles.modalTitle}>Noter le livreur</Text>
|
||||||
|
{order.livreur_assign ? (
|
||||||
|
<Text style={styles.modalSubtitle}>{order.livreur_assign}</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.starsRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<TouchableOpacity key={star} onPress={() => setSelectedStars(star)}>
|
||||||
|
<Ionicons
|
||||||
|
name={star <= selectedStars ? "star" : "star-outline"}
|
||||||
|
size={36}
|
||||||
|
color="#f59e0b"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
style={styles.commentInput}
|
||||||
|
placeholder="Commentaire (optionnel)"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
value={ratingComment}
|
||||||
|
onChangeText={setRatingComment}
|
||||||
|
multiline
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitBtn, (selectedStars === 0 || ratingSubmitting) && { opacity: 0.5 }]}
|
||||||
|
onPress={handleSubmitRating}
|
||||||
|
disabled={selectedStars === 0 || ratingSubmitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitBtnText}>
|
||||||
|
{ratingSubmitting ? "Envoi..." : "Envoyer ma note"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.cancelBtn} onPress={() => setRatingModal(false)}>
|
||||||
|
<Text style={styles.cancelBtnText}>Annuler</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput as RNTextInput,
|
||||||
|
} from "react-native";
|
||||||
|
import { useNavigation, useFocusEffect } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
getMyOrders,
|
||||||
|
getOrderTracking,
|
||||||
|
getOrderETA,
|
||||||
|
confirmReception,
|
||||||
|
cancelCommand,
|
||||||
|
respondToAddressProposal,
|
||||||
|
formatOrderDate,
|
||||||
|
formatPrice,
|
||||||
|
calculateOrderTotal,
|
||||||
|
getPublicSettings,
|
||||||
|
} from "../../api/api";
|
||||||
|
import type {
|
||||||
|
OrderDetail,
|
||||||
|
TrackingResponse,
|
||||||
|
ETAResponse,
|
||||||
|
CancelCommandResponse,
|
||||||
|
} from "../../api/api_types";
|
||||||
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import Modal from "../../components/ui/Modal";
|
||||||
|
import Toast from "../../components/ui/Toast";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import {
|
||||||
|
spacing,
|
||||||
|
borderRadius,
|
||||||
|
fontSize,
|
||||||
|
fontWeight,
|
||||||
|
shadows,
|
||||||
|
} from "../../theme";
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<ClientStackParamList>;
|
||||||
|
|
||||||
|
const STATUS_PROGRESS: Record<string, number> = {
|
||||||
|
pending: 15,
|
||||||
|
assigned: 25,
|
||||||
|
en_route: 60,
|
||||||
|
arrived: 85,
|
||||||
|
livre: 95,
|
||||||
|
approved: 100,
|
||||||
|
cancelled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderTrackingScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [orders, setOrders] = useState<OrderDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const [tracking, setTracking] = useState<Record<number, TrackingResponse>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [etas, setEtas] = useState<Record<number, ETAResponse>>({});
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
const [cancelLoading, setCancelLoading] = useState(false);
|
||||||
|
const [penaltyWarning, setPenaltyWarning] =
|
||||||
|
useState<CancelCommandResponse | null>(null);
|
||||||
|
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
|
||||||
|
const [penaltiesEnabled, setPenaltiesEnabled] = useState(false);
|
||||||
|
const [toastMsg, setToastMsg] = useState("");
|
||||||
|
const [toastType, setToastType] = useState<
|
||||||
|
"success" | "error" | "warning" | "info"
|
||||||
|
>("success");
|
||||||
|
const [toastVisible, setToastVisible] = useState(false);
|
||||||
|
|
||||||
|
const showToast = (
|
||||||
|
msg: string,
|
||||||
|
type: "success" | "error" | "warning" | "info" = "success",
|
||||||
|
) => {
|
||||||
|
setToastMsg(msg);
|
||||||
|
setToastType(type);
|
||||||
|
setToastVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMyOrders();
|
||||||
|
if (res.success) setOrders(res.commands || []);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicSettings()
|
||||||
|
.then((s) => setPenaltiesEnabled(s.penalties_enabled))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchOrders();
|
||||||
|
}, [fetchOrders]),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(fetchOrders, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchOrders]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedId === null) return;
|
||||||
|
(async () => {
|
||||||
|
const [trackRes, etaRes] = await Promise.all([
|
||||||
|
getOrderTracking(expandedId),
|
||||||
|
getOrderETA(expandedId),
|
||||||
|
]);
|
||||||
|
if (trackRes.success)
|
||||||
|
setTracking((prev) => ({ ...prev, [expandedId]: trackRes }));
|
||||||
|
if (etaRes.success)
|
||||||
|
setEtas((prev) => ({ ...prev, [expandedId]: etaRes }));
|
||||||
|
})();
|
||||||
|
}, [expandedId]);
|
||||||
|
|
||||||
|
const handleConfirm = async (orderId: number) => {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await confirmReception(orderId);
|
||||||
|
if (res.success) {
|
||||||
|
const cat = res.category || "";
|
||||||
|
let catLabel = "";
|
||||||
|
if (cat === "total") catLabel = " (Total)";
|
||||||
|
else if (cat.includes("zipette")) catLabel = " (Zipette&Co)";
|
||||||
|
else if (cat.includes("weed") || cat.includes("hash"))
|
||||||
|
catLabel = " (Weed&Hash)";
|
||||||
|
showToast(
|
||||||
|
`Livraison confirmee ! +${res.points_earned || 0} points${catLabel}`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
showToast(res.message || "Erreur", "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Erreur de confirmation", "error");
|
||||||
|
} finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
setConfirmingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (orderId: number, force = false) => {
|
||||||
|
setCancelLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await cancelCommand(
|
||||||
|
orderId,
|
||||||
|
cancelReason || "Annulation client",
|
||||||
|
force,
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
showToast(res.message || "Commande annulee", "success");
|
||||||
|
setCancellingId(null);
|
||||||
|
setCancelReason("");
|
||||||
|
setPenaltyWarning(null);
|
||||||
|
fetchOrders();
|
||||||
|
} else if (res.warning) {
|
||||||
|
setPenaltyOrderId(orderId);
|
||||||
|
setPenaltyWarning(res);
|
||||||
|
setCancellingId(null);
|
||||||
|
} else {
|
||||||
|
showToast(res.message || "Erreur", "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Erreur annulation", "error");
|
||||||
|
} finally {
|
||||||
|
setCancelLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
marginTop: spacing.l,
|
||||||
|
},
|
||||||
|
list: { padding: spacing.l, paddingBottom: spacing.xxxl },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.l,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
progressBarBg: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
cardRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
cardDetail: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginLeft: spacing.s,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardFooter: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: spacing.m,
|
||||||
|
paddingTop: spacing.m,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
cardTotal: {
|
||||||
|
color: colors.success,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
expandedSection: {
|
||||||
|
marginTop: spacing.l,
|
||||||
|
paddingTop: spacing.l,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
trackRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
trackText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
},
|
||||||
|
expandedActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginTop: spacing.l,
|
||||||
|
},
|
||||||
|
proposalBox: {
|
||||||
|
backgroundColor: colors.warning + "18",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.m,
|
||||||
|
marginTop: spacing.m,
|
||||||
|
},
|
||||||
|
proposalTitle: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
proposalAddress: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
proposalActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.s,
|
||||||
|
},
|
||||||
|
modalBody: { gap: spacing.m },
|
||||||
|
modalText: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: spacing.m,
|
||||||
|
marginTop: spacing.m,
|
||||||
|
},
|
||||||
|
cancelInput: {
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: spacing.m,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
penaltyContent: { gap: spacing.l },
|
||||||
|
penaltyIconContainer: { alignItems: "center" },
|
||||||
|
penaltyIconCircle: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
backgroundColor: "rgba(245,158,11,0.12)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
penaltyText: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
penaltyBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
backgroundColor: "rgba(245,158,11,0.1)",
|
||||||
|
paddingVertical: spacing.s,
|
||||||
|
paddingHorizontal: spacing.m,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
penaltyDetail: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return <LoadingSpinner message="Chargement des commandes..." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Toast
|
||||||
|
message={toastMsg}
|
||||||
|
type={toastType}
|
||||||
|
visible={toastVisible}
|
||||||
|
onHide={() => setToastVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name="receipt-outline"
|
||||||
|
size={80}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>
|
||||||
|
Aucune commande active
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={orders}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
renderItem={({ item: order }) => {
|
||||||
|
const expanded = expandedId === order.id;
|
||||||
|
const gross = calculateOrderTotal(order);
|
||||||
|
const total = Math.max(
|
||||||
|
0,
|
||||||
|
gross - (order.referral_used ?? 0),
|
||||||
|
);
|
||||||
|
const progress = STATUS_PROGRESS[order.status] || 0;
|
||||||
|
const track = tracking[order.id];
|
||||||
|
const eta = etas[order.id];
|
||||||
|
const canConfirm = order.status === "livre";
|
||||||
|
const canCancel = [
|
||||||
|
"pending",
|
||||||
|
"assigned",
|
||||||
|
"en_route",
|
||||||
|
].includes(order.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={() =>
|
||||||
|
setExpandedId(expanded ? null : order.id)
|
||||||
|
}
|
||||||
|
style={[styles.card, shadows.sm]}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.orderId}>
|
||||||
|
Commande #{order.client_order_number}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressBarBg}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBarFill,
|
||||||
|
{ width: `${progress}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="location-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={styles.cardDetail}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{order.delivery_address ||
|
||||||
|
order.adresse ||
|
||||||
|
"N/A"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="time-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.cardDetail}>
|
||||||
|
{formatOrderDate(order.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.cardTotal}>
|
||||||
|
{formatPrice(total)}
|
||||||
|
</Text>
|
||||||
|
{(order.referral_used ?? 0) > 0 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.success,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
dont -
|
||||||
|
{(
|
||||||
|
order.referral_used as number
|
||||||
|
).toFixed(2)}{" "}
|
||||||
|
€ parrainage
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
expanded
|
||||||
|
? "chevron-up"
|
||||||
|
: "chevron-down"
|
||||||
|
}
|
||||||
|
size={18}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{order.address_proposal_status === "pending" &&
|
||||||
|
order.proposed_address && (
|
||||||
|
<View style={styles.proposalBox}>
|
||||||
|
<Text style={styles.proposalTitle}>
|
||||||
|
📍 Nouvelle adresse proposée
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.proposalAddress}
|
||||||
|
>
|
||||||
|
{order.proposed_address}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={styles.proposalActions}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
title="Accepter"
|
||||||
|
variant="success"
|
||||||
|
size="sm"
|
||||||
|
onPress={async () => {
|
||||||
|
const res =
|
||||||
|
await respondToAddressProposal(
|
||||||
|
order.id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
showToast(
|
||||||
|
"Adresse acceptée",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
res.message,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Refuser"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onPress={async () => {
|
||||||
|
const res =
|
||||||
|
await respondToAddressProposal(
|
||||||
|
order.id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
showToast(
|
||||||
|
"Adresse refusée",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
res.message,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{expanded && (
|
||||||
|
<View style={styles.expandedSection}>
|
||||||
|
{track?.livreur_username && (
|
||||||
|
<View style={styles.trackRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="bicycle-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.info}
|
||||||
|
/>
|
||||||
|
<Text style={styles.trackText}>
|
||||||
|
Livreur:{" "}
|
||||||
|
{track.livreur_username}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{order.status === "en_route" && (
|
||||||
|
<View style={styles.trackRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="timer-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.warning}
|
||||||
|
/>
|
||||||
|
<Text style={styles.trackText}>
|
||||||
|
{eta?.eta_minutes != null &&
|
||||||
|
eta.eta_minutes > 0
|
||||||
|
? `Temps de livraison estimé : ~${eta.eta_minutes} min`
|
||||||
|
: "Aucune heure disponible"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{order.status === "arrived" && (
|
||||||
|
<View style={styles.trackRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="timer-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.warning}
|
||||||
|
/>
|
||||||
|
<Text style={styles.trackText}>
|
||||||
|
Temps de livraison estimé :
|
||||||
|
~
|
||||||
|
{eta?.eta_minutes != null &&
|
||||||
|
eta.eta_minutes > 0 &&
|
||||||
|
eta.eta_minutes < 5
|
||||||
|
? eta.eta_minutes
|
||||||
|
: 5}{" "}
|
||||||
|
min
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{track?.current_step && (
|
||||||
|
<View style={styles.trackRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="footsteps-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
<Text style={styles.trackText}>
|
||||||
|
{track.current_step}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.expandedActions}>
|
||||||
|
<Button
|
||||||
|
title="Details"
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate(
|
||||||
|
"OrderDetails",
|
||||||
|
{ orderId: order.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{canConfirm && (
|
||||||
|
<Button
|
||||||
|
title="Confirmer reception"
|
||||||
|
onPress={() =>
|
||||||
|
setConfirmingId(
|
||||||
|
order.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="success"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canCancel && (
|
||||||
|
<Button
|
||||||
|
title="Annuler"
|
||||||
|
onPress={() =>
|
||||||
|
setCancellingId(
|
||||||
|
order.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={confirmingId !== null}
|
||||||
|
onClose={() => setConfirmingId(null)}
|
||||||
|
title="Confirmer la reception"
|
||||||
|
icon="checkmark-circle-outline"
|
||||||
|
iconColor={colors.success}
|
||||||
|
>
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
Confirmez-vous avoir recu votre commande #{confirmingId}{" "}
|
||||||
|
?
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
title="Annuler"
|
||||||
|
onPress={() => setConfirmingId(null)}
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Confirmer"
|
||||||
|
onPress={() =>
|
||||||
|
confirmingId && handleConfirm(confirmingId)
|
||||||
|
}
|
||||||
|
loading={confirmLoading}
|
||||||
|
variant="success"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={cancellingId !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setCancellingId(null);
|
||||||
|
setCancelReason("");
|
||||||
|
}}
|
||||||
|
title="Annuler la commande"
|
||||||
|
icon="close-circle-outline"
|
||||||
|
iconColor={colors.danger}
|
||||||
|
>
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
{penaltiesEnabled && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: colors.danger + "18",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.danger + "55",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="warning-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.danger}
|
||||||
|
style={{ marginTop: 1 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Attention : en cas d'annulations répétées, une
|
||||||
|
amende sera appliquée à votre compte.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
Raison de l'annulation :
|
||||||
|
</Text>
|
||||||
|
<RNTextInput
|
||||||
|
style={styles.cancelInput}
|
||||||
|
placeholder="Raison (optionnel)"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
value={cancelReason}
|
||||||
|
onChangeText={setCancelReason}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
title="Retour"
|
||||||
|
onPress={() => {
|
||||||
|
setCancellingId(null);
|
||||||
|
setCancelReason("");
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Annuler la commande"
|
||||||
|
onPress={() =>
|
||||||
|
cancellingId && handleCancel(cancellingId)
|
||||||
|
}
|
||||||
|
loading={cancelLoading}
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={penaltyWarning !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setPenaltyWarning(null);
|
||||||
|
setPenaltyOrderId(null);
|
||||||
|
}}
|
||||||
|
title="Attention - Penalite"
|
||||||
|
icon="warning-outline"
|
||||||
|
iconColor={colors.warning}
|
||||||
|
>
|
||||||
|
<View style={styles.penaltyContent}>
|
||||||
|
<View style={styles.penaltyIconContainer}>
|
||||||
|
<View style={styles.penaltyIconCircle}>
|
||||||
|
<Ionicons
|
||||||
|
name="warning"
|
||||||
|
size={32}
|
||||||
|
color={colors.warning}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.penaltyText}>
|
||||||
|
{penaltyWarning?.message}
|
||||||
|
</Text>
|
||||||
|
{penaltyWarning?.penalty_warning && (
|
||||||
|
<View style={styles.penaltyBadge}>
|
||||||
|
<Ionicons
|
||||||
|
name="remove-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.penaltyDetail,
|
||||||
|
{ color: colors.danger, fontWeight: "700" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Amende :{" "}
|
||||||
|
{penaltyWarning.penalty_warning.penalty_amount}{" "}
|
||||||
|
€
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
title="Retour"
|
||||||
|
onPress={() => setPenaltyWarning(null)}
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Confirmer l'annulation"
|
||||||
|
onPress={() => {
|
||||||
|
if (penaltyOrderId)
|
||||||
|
handleCancel(penaltyOrderId, true);
|
||||||
|
setPenaltyWarning(null);
|
||||||
|
setPenaltyOrderId(null);
|
||||||
|
}}
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Linking,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
|
import { getReferralBalance, getPublicSettings } from "../../api/api";
|
||||||
|
|
||||||
|
export default function ParrainageScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [balance, setBalance] = useState<number>(0);
|
||||||
|
const [contactTelegram, setContactTelegram] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getReferralBalance().then((res) => {
|
||||||
|
if (res.success) setBalance(res.balance);
|
||||||
|
});
|
||||||
|
getPublicSettings().then((settings) => {
|
||||||
|
if (settings.contact_telegram) {
|
||||||
|
setContactTelegram(settings.contact_telegram);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
icon: "chatbubble-ellipses-outline" as const,
|
||||||
|
title: "1. Contacte-nous sur Telegram",
|
||||||
|
desc: contactTelegram
|
||||||
|
? `Envoie un message à @${contactTelegram} en indiquant ton username et le username de la personne que tu as parrainée.`
|
||||||
|
: "Envoie-nous un message sur Telegram en indiquant ton username et le username de la personne que tu as parrainée.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "checkmark-circle-outline" as const,
|
||||||
|
title: "2. Validation par l'admin",
|
||||||
|
desc: "L'admin vérifie le parrainage et crédite manuellement un solde sur ton compte.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "wallet-outline" as const,
|
||||||
|
title: "3. Crédit disponible",
|
||||||
|
desc: "Le solde apparaît dans ton profil et au moment du paiement. Tu choisis de l'utiliser ou de le cumuler.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "cart-outline" as const,
|
||||||
|
title: "4. Utilisation à la commande",
|
||||||
|
desc: "Au checkout, active l'option \"Utiliser mon crédit parrainage\". Le montant sera déduit de ta commande.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||||
|
content: { padding: spacing.xl, paddingBottom: spacing.xxxl },
|
||||||
|
balanceCard: {
|
||||||
|
backgroundColor: colors.accent + "18",
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accent + "44",
|
||||||
|
padding: spacing.xl,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
balanceLabel: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
},
|
||||||
|
balanceAmount: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
stepCard: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
padding: spacing.l,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.m,
|
||||||
|
},
|
||||||
|
stepIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.accent + "18",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
stepContent: { flex: 1 },
|
||||||
|
stepTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
stepDesc: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
ruleCard: {
|
||||||
|
backgroundColor: colors.warning + "12",
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning + "44",
|
||||||
|
padding: spacing.l,
|
||||||
|
marginTop: spacing.m,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
ruleTitle: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
ruleText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
ruleExample: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
telegramBtn: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
backgroundColor: "#229ED9",
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.l,
|
||||||
|
marginTop: spacing.l,
|
||||||
|
},
|
||||||
|
telegramText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTelegramPress = () => {
|
||||||
|
if (contactTelegram) {
|
||||||
|
Linking.openURL(`https://t.me/${contactTelegram}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Solde actuel */}
|
||||||
|
<View style={styles.balanceCard}>
|
||||||
|
<Ionicons name="gift-outline" size={32} color={colors.accent} />
|
||||||
|
<Text style={styles.balanceLabel}>Mon solde parrainage</Text>
|
||||||
|
<Text style={styles.balanceAmount}>{balance.toFixed(2)} €</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Comment ça marche */}
|
||||||
|
<Text style={styles.sectionTitle}>Comment ca marche ?</Text>
|
||||||
|
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<View key={i} style={styles.stepCard}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Ionicons name={step.icon} size={20} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepContent}>
|
||||||
|
<Text style={styles.stepTitle}>{step.title}</Text>
|
||||||
|
<Text style={styles.stepDesc}>{step.desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Règle minimum de zone */}
|
||||||
|
<View style={styles.ruleCard}>
|
||||||
|
<Text style={styles.ruleTitle}>⚠️ Règle importante</Text>
|
||||||
|
<Text style={styles.ruleText}>
|
||||||
|
Même avec du crédit parrainage, tu dois toujours payer au minimum le seuil de ta zone de livraison.
|
||||||
|
Le crédit est déduit en plus du montant minimum.
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.ruleExample}>
|
||||||
|
Exemple : crédit 50 € + zone 50 € = commande de 100 € minimum.
|
||||||
|
Tu paies 50 € et le reste est couvert par ton crédit.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bouton Telegram */}
|
||||||
|
{contactTelegram ? (
|
||||||
|
<TouchableOpacity style={styles.telegramBtn} onPress={handleTelegramPress}>
|
||||||
|
<Ionicons name="paper-plane-outline" size={20} color="#fff" />
|
||||||
|
<Text style={styles.telegramText}>
|
||||||
|
Contacter @{contactTelegram}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,840 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
} from "react-native";
|
||||||
|
import { useRoute, useNavigation } from "@react-navigation/native";
|
||||||
|
import type { RouteProp } from "@react-navigation/native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Video, ResizeMode } from "expo-av";
|
||||||
|
import { getProductById, getCategories } from "../../api/api";
|
||||||
|
import { useCart } from "../../context/CartContext";
|
||||||
|
import type { Product } from "../../api/api_types";
|
||||||
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
import Toast from "../../components/ui/Toast";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
|
import { API_BASE_URL } from "../../api/client";
|
||||||
|
|
||||||
|
type Route = RouteProp<ClientStackParamList, "ProductDetail">;
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
export default function ProductDetailScreen() {
|
||||||
|
const { params } = useRoute<Route>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { colors, isDark } = useTheme();
|
||||||
|
const { addToCart } = useCart();
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
|
||||||
|
const [selectedPrice, setSelectedPrice] = useState<number>(0);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [showQuantityPicker, setShowQuantityPicker] = useState(false);
|
||||||
|
const [stockWarning, setStockWarning] = useState<{ wanted: number; available: number } | null>(null);
|
||||||
|
const [showVideo, setShowVideo] = useState(false);
|
||||||
|
const [catColor, setCatColor] = useState<string>("#7c3aed");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [res, categories] = await Promise.all([
|
||||||
|
getProductById(params.productId),
|
||||||
|
getCategories(),
|
||||||
|
]);
|
||||||
|
const p = res?.data || res?.product || res;
|
||||||
|
if (p && p.id) {
|
||||||
|
const fixedProduct = {
|
||||||
|
...p,
|
||||||
|
prices:
|
||||||
|
p.prices
|
||||||
|
?.filter((pr: any) => pr.active_price !== false)
|
||||||
|
.map((pr: any) => ({
|
||||||
|
quantity: parseFloat(String(pr.quantity)),
|
||||||
|
price: parseFloat(String(pr.price)),
|
||||||
|
active_price: pr.active_price,
|
||||||
|
})) || [],
|
||||||
|
};
|
||||||
|
setProduct(fixedProduct);
|
||||||
|
if (fixedProduct.prices.length > 0) {
|
||||||
|
setSelectedGrams(fixedProduct.prices[0].quantity);
|
||||||
|
setSelectedPrice(fixedProduct.prices[0].price);
|
||||||
|
}
|
||||||
|
const matched = categories.find(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase() ===
|
||||||
|
(p.category || "").toLowerCase(),
|
||||||
|
);
|
||||||
|
if (matched?.color) setCatColor(matched.color);
|
||||||
|
} else {
|
||||||
|
setError("Produit introuvable");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Erreur de chargement");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [params.productId]);
|
||||||
|
|
||||||
|
const handleGramsChange = (quantity: number) => {
|
||||||
|
setSelectedGrams(quantity);
|
||||||
|
const opt = product?.prices?.find((p) => p.quantity === quantity);
|
||||||
|
if (opt) setSelectedPrice(opt.price);
|
||||||
|
setShowQuantityPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCart = async () => {
|
||||||
|
if (!product || isOutOfStock || selectedGrams === null) return;
|
||||||
|
if (product.stock > 0 && selectedGrams > product.stock) {
|
||||||
|
setStockWarning({ wanted: selectedGrams, available: product.stock });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await addToCart({
|
||||||
|
product_id: product.id,
|
||||||
|
name_product: product.name,
|
||||||
|
category: product.category,
|
||||||
|
quantity: selectedGrams,
|
||||||
|
price: selectedPrice,
|
||||||
|
});
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => setShowSuccess(false), 2500);
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const catTextColor = (() => {
|
||||||
|
const h = catColor.replace("#", "");
|
||||||
|
const full =
|
||||||
|
h.length === 3
|
||||||
|
? h
|
||||||
|
.split("")
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join("")
|
||||||
|
: h;
|
||||||
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 > 128
|
||||||
|
? "#000000"
|
||||||
|
: "#ffffff";
|
||||||
|
})();
|
||||||
|
|
||||||
|
const overlayBg = isDark ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)";
|
||||||
|
const overlayBorder = isDark
|
||||||
|
? "rgba(255,255,255,0.08)"
|
||||||
|
: "rgba(0,0,0,0.08)";
|
||||||
|
const overlayText = isDark ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.85)";
|
||||||
|
const overlayTextSub = isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.6)";
|
||||||
|
const overlayBgLight = isDark
|
||||||
|
? "rgba(255,255,255,0.05)"
|
||||||
|
: "rgba(0,0,0,0.05)";
|
||||||
|
const overlayBgDisabled = isDark
|
||||||
|
? "rgba(255,255,255,0.08)"
|
||||||
|
: "rgba(0,0,0,0.08)";
|
||||||
|
const overlayTextDisabled = isDark
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "rgba(0,0,0,0.35)";
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||||
|
content: { paddingBottom: spacing.xxxl },
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
padding: spacing.xxl,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: fontSize.xl,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
backgroundColor: overlayBgLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: overlayBorder,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
backBtnText: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
imageSection: {
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: 1,
|
||||||
|
backgroundColor: overlayBg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: overlayBorder,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
imageSectionOut: { opacity: 0.4 },
|
||||||
|
image: { width: "100%", height: "100%" },
|
||||||
|
imagePlaceholder: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
soldOutBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: [
|
||||||
|
{ translateX: -90 },
|
||||||
|
{ translateY: -30 },
|
||||||
|
{ rotate: "-15deg" },
|
||||||
|
],
|
||||||
|
backgroundColor: "#ef4444",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: colors.white,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
paddingVertical: 16,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
soldOutText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 12,
|
||||||
|
},
|
||||||
|
comingSoonBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: [
|
||||||
|
{ translateX: -80 },
|
||||||
|
{ translateY: -30 },
|
||||||
|
{ rotate: "-15deg" },
|
||||||
|
],
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(34,197,94,0.95)",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
paddingVertical: 16,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
color: "rgba(34,197,94,0.95)",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 12,
|
||||||
|
},
|
||||||
|
videoBtn: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
|
infoSection: { padding: spacing.xl, gap: spacing.xl },
|
||||||
|
productName: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "700",
|
||||||
|
lineHeight: 36,
|
||||||
|
letterSpacing: -1,
|
||||||
|
},
|
||||||
|
priceRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
priceIndicator: {
|
||||||
|
width: 4,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "#10b981",
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
priceText: {
|
||||||
|
color: "#10b981",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
descriptionCard: {
|
||||||
|
backgroundColor: overlayBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: overlayBorder,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: catColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
descriptionTitle: {
|
||||||
|
color: overlayText,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
descriptionText: {
|
||||||
|
color: overlayTextSub,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
stockSection: {
|
||||||
|
backgroundColor: overlayBg,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: overlayBorder,
|
||||||
|
padding: spacing.l,
|
||||||
|
gap: spacing.m,
|
||||||
|
},
|
||||||
|
selectorLabel: {
|
||||||
|
color: overlayText,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: overlayBgLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
dropdownDisabled: { opacity: 0.5 },
|
||||||
|
dropdownText: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
addToCartBtn: {
|
||||||
|
backgroundColor: catColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 18,
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: catColor,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 30,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
addToCartBtnDisabled: {
|
||||||
|
backgroundColor: overlayBgDisabled,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
addToCartText: {
|
||||||
|
color: catTextColor,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
},
|
||||||
|
addToCartTextDisabled: { color: overlayTextDisabled },
|
||||||
|
pickerOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.85)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
pickerContent: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: overlayBorder,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: catColor,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 30,
|
||||||
|
elevation: 20,
|
||||||
|
},
|
||||||
|
pickerAccentBar: {
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: catColor,
|
||||||
|
marginHorizontal: -spacing.xl,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
pickerHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
pickerIconCircle: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: catColor + "26",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
pickerTitle: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
pickerOption: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: overlayBorder,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
backgroundColor: overlayBg,
|
||||||
|
},
|
||||||
|
pickerOptionLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
pickerOptionQty: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
pickerOptionPrice: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
pickerCheckCircle: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
videoOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.92)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
videoContent: {
|
||||||
|
width: SCREEN_WIDTH - 10,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
videoTopBar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor: overlayBg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: overlayBorder,
|
||||||
|
},
|
||||||
|
videoTitleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
videoTitleText: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
videoCloseBtn: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: overlayBgLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
videoPlayer: {
|
||||||
|
width: "100%",
|
||||||
|
height: Math.round(SCREEN_HEIGHT * 0.65),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors, isDark, catColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner message="Chargement du produit..." />;
|
||||||
|
|
||||||
|
if (error || !product) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorTitle}>
|
||||||
|
{error || "Produit introuvable"}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backBtn}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<Text style={styles.backBtnText}>Retour aux produits</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOutOfStock = product.stock === 0;
|
||||||
|
const isComingSoon = product.coming_soon === true;
|
||||||
|
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||||
|
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||||
|
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||||
|
const imageUri = imageMedia ? `${API_BASE_URL}${imageMedia.url}` : null;
|
||||||
|
const videoUri = videoMedia ? `${API_BASE_URL}${videoMedia.url}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Toast
|
||||||
|
message="Produit ajoute au panier !"
|
||||||
|
type="success"
|
||||||
|
visible={showSuccess}
|
||||||
|
onHide={() => setShowSuccess(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.imageSection,
|
||||||
|
isOutOfStock && styles.imageSectionOut,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUri }}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.imagePlaceholder,
|
||||||
|
{ backgroundColor: catColor + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="leaf-outline"
|
||||||
|
size={80}
|
||||||
|
color={catColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isOutOfStock && (
|
||||||
|
<View style={styles.soldOutBadge}>
|
||||||
|
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isComingSoon && (
|
||||||
|
<View style={styles.comingSoonBadge}>
|
||||||
|
<Text style={styles.comingSoonText}>
|
||||||
|
COMMING SOON
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{videoUri && !isOutOfStock && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.videoBtn,
|
||||||
|
{ backgroundColor: catColor + "DD" },
|
||||||
|
]}
|
||||||
|
onPress={() => setShowVideo(true)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="videocam"
|
||||||
|
size={20}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<Text style={styles.productName}>{product.name}</Text>
|
||||||
|
{selectedPrice > 0 && (
|
||||||
|
<View style={styles.priceRow}>
|
||||||
|
<View style={styles.priceIndicator} />
|
||||||
|
<Text style={styles.priceText}>
|
||||||
|
{selectedPrice.toFixed(2)} €{" "}
|
||||||
|
{selectedGrams &&
|
||||||
|
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.descriptionCard}>
|
||||||
|
<Text style={styles.descriptionTitle}>Description</Text>
|
||||||
|
<Text style={styles.descriptionText}>
|
||||||
|
{product.description ||
|
||||||
|
"Aucune description disponible."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{hasValidPrices && !isComingSoon && (
|
||||||
|
<View style={styles.stockSection}>
|
||||||
|
<Text style={styles.selectorLabel}>Quantite:</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dropdown,
|
||||||
|
{ borderColor: catColor },
|
||||||
|
isOutOfStock && styles.dropdownDisabled,
|
||||||
|
]}
|
||||||
|
onPress={() =>
|
||||||
|
!isOutOfStock && setShowQuantityPicker(true)
|
||||||
|
}
|
||||||
|
disabled={isOutOfStock}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownText}>
|
||||||
|
{selectedGrams !== null
|
||||||
|
? `${selectedGrams}${product.unit || "g"} - ${selectedPrice.toFixed(2)} €`
|
||||||
|
: "Choisir une quantite"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={18}
|
||||||
|
color={colors.textWhite}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.addToCartBtn,
|
||||||
|
(isOutOfStock ||
|
||||||
|
isComingSoon ||
|
||||||
|
selectedGrams === null) &&
|
||||||
|
styles.addToCartBtnDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
disabled={
|
||||||
|
isOutOfStock ||
|
||||||
|
isComingSoon ||
|
||||||
|
selectedGrams === null ||
|
||||||
|
adding
|
||||||
|
}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.addToCartText,
|
||||||
|
(isOutOfStock ||
|
||||||
|
isComingSoon ||
|
||||||
|
selectedGrams === null) &&
|
||||||
|
styles.addToCartTextDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isOutOfStock
|
||||||
|
? "Rupture de stock"
|
||||||
|
: isComingSoon
|
||||||
|
? "Bientôt disponible"
|
||||||
|
: "Ajouter au panier"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showQuantityPicker}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowQuantityPicker(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.pickerOverlay}
|
||||||
|
onPress={() => setShowQuantityPicker(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.pickerContent}>
|
||||||
|
<View style={styles.pickerAccentBar} />
|
||||||
|
<View style={styles.pickerHeader}>
|
||||||
|
<View style={styles.pickerIconCircle}>
|
||||||
|
<Ionicons
|
||||||
|
name="scale-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.pickerTitle}>
|
||||||
|
Choisir une quantite
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{product.prices?.map((p, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p.quantity}
|
||||||
|
style={[
|
||||||
|
styles.pickerOption,
|
||||||
|
selectedGrams === p.quantity && {
|
||||||
|
backgroundColor: catColor + "18",
|
||||||
|
borderColor: catColor,
|
||||||
|
},
|
||||||
|
index ===
|
||||||
|
(product.prices?.length || 0) - 1 && {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => handleGramsChange(p.quantity)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.pickerOptionLeft}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pickerOptionQty,
|
||||||
|
selectedGrams === p.quantity && {
|
||||||
|
color: catColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.quantity}
|
||||||
|
{product.unit || "g"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pickerOptionPrice,
|
||||||
|
selectedGrams === p.quantity && {
|
||||||
|
color: catColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.price.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{selectedGrams === p.quantity && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.pickerCheckCircle,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
catColor + "25",
|
||||||
|
borderColor: catColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark"
|
||||||
|
size={16}
|
||||||
|
color={catColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showVideo}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowVideo(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.videoOverlay}
|
||||||
|
onPress={() => setShowVideo(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.videoContent}>
|
||||||
|
<View style={styles.videoTopBar}>
|
||||||
|
<View style={styles.videoTitleRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="videocam"
|
||||||
|
size={16}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
<Text style={styles.videoTitleText}>
|
||||||
|
Video du produit
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.videoCloseBtn}
|
||||||
|
onPress={() => setShowVideo(false)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close"
|
||||||
|
size={18}
|
||||||
|
color={colors.textWhite}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{videoUri && (
|
||||||
|
<Video
|
||||||
|
source={{ uri: videoUri }}
|
||||||
|
style={styles.videoPlayer}
|
||||||
|
useNativeControls
|
||||||
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
|
shouldPlay
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal stock insuffisant */}
|
||||||
|
<Modal
|
||||||
|
visible={stockWarning !== null}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setStockWarning(null)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "center", alignItems: "center", padding: 24 }}
|
||||||
|
onPress={() => setStockWarning(null)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{ backgroundColor: colors.bgCard, borderRadius: 16, padding: 28, width: "100%", maxWidth: 340, alignItems: "center" }}
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 36, marginBottom: 12 }}>⚠️</Text>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: "700", color: colors.textPrimary, marginBottom: 8, textAlign: "center" }}>
|
||||||
|
Stock insuffisant
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.textMuted, textAlign: "center", marginBottom: 20, lineHeight: 20 }}>
|
||||||
|
Vous avez sélectionné{" "}
|
||||||
|
<Text style={{ fontWeight: "700", color: colors.textPrimary }}>{stockWarning?.wanted}g</Text>
|
||||||
|
{" "}mais il ne reste que{" "}
|
||||||
|
<Text style={{ fontWeight: "700", color: "#ef4444" }}>{stockWarning?.available}g</Text>
|
||||||
|
{" "}disponible pour ce produit.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setStockWarning(null)}
|
||||||
|
style={{ backgroundColor: "#ef4444", borderRadius: 10, paddingVertical: 12, paddingHorizontal: 32 }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#fff", fontWeight: "700", fontSize: 15 }}>OK</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
FlatList,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
Dimensions,
|
||||||
|
ImageBackground,
|
||||||
|
Animated,
|
||||||
|
} from "react-native";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import { getAllProducts, getProductsByCategory, getCategories } from "../../api/api";
|
||||||
|
import type { Category } from "../../api/api";
|
||||||
|
import type { Product } from "../../api/api_types";
|
||||||
|
import ProductCard from "../../components/ProductCard";
|
||||||
|
import CategoryPill from "../../components/CategoryPill";
|
||||||
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
import { spacing, fontSize } from "../../theme";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
|
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const CARD_WIDTH = SCREEN_WIDTH - 48;
|
||||||
|
|
||||||
|
// Les catégories sont chargées dynamiquement depuis l'API
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<ClientStackParamList>;
|
||||||
|
|
||||||
|
export default function ProductsScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("tous");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const carouselRef = useRef<FlatList>(null);
|
||||||
|
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
const cats = await getCategories();
|
||||||
|
const sorted = [...cats].sort((a, b) => {
|
||||||
|
if (a.is_coming_soon === b.is_coming_soon) return 0;
|
||||||
|
return a.is_coming_soon ? 1 : -1;
|
||||||
|
});
|
||||||
|
setCategories(sorted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pulse = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(scaleAnim, {
|
||||||
|
toValue: 1.12,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(scaleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
pulse.start();
|
||||||
|
return () => pulse.stop();
|
||||||
|
}, [scaleAnim]);
|
||||||
|
|
||||||
|
const fetchProducts = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let response: any;
|
||||||
|
if (selectedCategory === "tous") {
|
||||||
|
response = await getAllProducts();
|
||||||
|
} else {
|
||||||
|
response = await getProductsByCategory(selectedCategory);
|
||||||
|
}
|
||||||
|
const list = response?.data || response?.products || [];
|
||||||
|
setProducts(Array.isArray(list) ? list : []);
|
||||||
|
} catch {
|
||||||
|
setError("Impossible de charger les produits");
|
||||||
|
setProducts([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const catObj = categories.find((c) => c.name === selectedCategory);
|
||||||
|
if (catObj?.is_coming_soon) {
|
||||||
|
setLoading(false);
|
||||||
|
setProducts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
fetchProducts();
|
||||||
|
}, [selectedCategory, categories, fetchProducts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (carouselRef.current && products.length > 0) {
|
||||||
|
carouselRef.current.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
}
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
// Le rechargement des produits est déclenché par l'effet ci-dessus
|
||||||
|
// dès que `categories` change (nouvelle référence à chaque appel).
|
||||||
|
loadCategories();
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgSecondary },
|
||||||
|
filtersWrapper: {
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
paddingBottom: spacing.l,
|
||||||
|
},
|
||||||
|
filters: { paddingHorizontal: spacing.l, gap: spacing.s },
|
||||||
|
categoryHeader: {
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
categoryTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.textWhite,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
carousel: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: spacing.xxxl,
|
||||||
|
},
|
||||||
|
cardWrapper: { marginRight: 16 },
|
||||||
|
center: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
bgImage: {
|
||||||
|
opacity: 0.12,
|
||||||
|
resizeMode: "contain",
|
||||||
|
},
|
||||||
|
comingSoonWrapper: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 96,
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
comingSoonContent: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.xl,
|
||||||
|
gap: spacing.m,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
fontSize: Math.min(32, SCREEN_WIDTH * 0.07),
|
||||||
|
color: "#8E8FE8",
|
||||||
|
letterSpacing: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(142, 143, 232, 0.85)",
|
||||||
|
textShadowOffset: { width: 0, height: 0 },
|
||||||
|
textShadowRadius: 18,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.82,
|
||||||
|
},
|
||||||
|
comingSoonDesc: {
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
color: "rgba(142, 143, 232, 0.7)",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCategoryObj = categories.find((c) => c.name === selectedCategory);
|
||||||
|
const isSelectedComingSoon = selectedCategoryObj?.is_coming_soon ?? false;
|
||||||
|
|
||||||
|
if (loading && !refreshing) {
|
||||||
|
return <LoadingSpinner message="Chargement des produits..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImageBackground
|
||||||
|
source={undefined}
|
||||||
|
style={styles.container}
|
||||||
|
imageStyle={styles.bgImage}
|
||||||
|
>
|
||||||
|
<View style={styles.filtersWrapper}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
<CategoryPill
|
||||||
|
label="Tous"
|
||||||
|
active={selectedCategory === "tous"}
|
||||||
|
onPress={() => setSelectedCategory("tous")}
|
||||||
|
/>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<CategoryPill
|
||||||
|
key={cat.id}
|
||||||
|
label={cat.name}
|
||||||
|
active={selectedCategory === cat.name}
|
||||||
|
onPress={() => setSelectedCategory(cat.name)}
|
||||||
|
color={cat.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
<View style={styles.categoryHeader}>
|
||||||
|
<Text style={styles.categoryTitle}>
|
||||||
|
{selectedCategory === "tous" ? "Tous les produits" : selectedCategory}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelectedComingSoon ? (
|
||||||
|
<View style={styles.comingSoonContent}>
|
||||||
|
<Animated.Text
|
||||||
|
style={[styles.comingSoonText, { transform: [{ scale: scaleAnim }] }]}
|
||||||
|
>
|
||||||
|
Prochainement
|
||||||
|
</Animated.Text>
|
||||||
|
<Text style={styles.comingSoonDesc}>
|
||||||
|
Les produits de cette catégorie arrivent bientôt !
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Aucun produit disponible dans cette categorie.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
ref={carouselRef}
|
||||||
|
data={products}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled={false}
|
||||||
|
snapToInterval={CARD_WIDTH + 16}
|
||||||
|
snapToAlignment="center"
|
||||||
|
decelerationRate="fast"
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
contentContainerStyle={styles.carousel}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.cardWrapper}>
|
||||||
|
<ProductCard
|
||||||
|
product={item}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate("ProductDetail", {
|
||||||
|
productId: item.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
categoryColor={
|
||||||
|
categories.find(
|
||||||
|
(c) => c.name.toLowerCase() === item.category?.toLowerCase(),
|
||||||
|
)?.color
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ImageBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
|||||||
|
export const darkColors = {
|
||||||
|
// Backgrounds
|
||||||
|
bgPrimary: "#0a0a0a",
|
||||||
|
bgSecondary: "#1a1a1a",
|
||||||
|
bgCard: "#1e1e1e",
|
||||||
|
bgInput: "#2a2a2a",
|
||||||
|
bgModal: "rgba(0,0,0,0.95)",
|
||||||
|
|
||||||
|
// Text
|
||||||
|
textPrimary: "rgba(255, 255, 255, 0.87)",
|
||||||
|
textSecondary: "rgba(255, 255, 255, 0.6)",
|
||||||
|
textMuted: "rgba(255, 255, 255, 0.4)",
|
||||||
|
textWhite: "#ffffff",
|
||||||
|
|
||||||
|
// Accent
|
||||||
|
accent: "#7c3aed",
|
||||||
|
accentDark: "#6d28d9",
|
||||||
|
accentLight: "#8b5cf6",
|
||||||
|
secondary: "#22d3ee",
|
||||||
|
|
||||||
|
// Status
|
||||||
|
success: "#4ade80",
|
||||||
|
successDark: "#22c55e",
|
||||||
|
successDarker: "#16a34a",
|
||||||
|
danger: "#ef4444",
|
||||||
|
dangerDark: "#dc2626",
|
||||||
|
warning: "#f59e0b",
|
||||||
|
info: "#3b82f6",
|
||||||
|
|
||||||
|
// Category colors
|
||||||
|
categoryWeedHash: "#10b981",
|
||||||
|
categoryTous: "#9333ea",
|
||||||
|
categoryZipette: "#f5f5f0",
|
||||||
|
categoryGros: "#3dc2f7",
|
||||||
|
|
||||||
|
// Borders
|
||||||
|
border: "#333333",
|
||||||
|
borderLight: "#222222",
|
||||||
|
borderSubtle: "rgba(255, 255, 255, 0.1)",
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
overlay: "rgba(0, 0, 0, 0.5)",
|
||||||
|
transparent: "transparent",
|
||||||
|
white: "#ffffff",
|
||||||
|
black: "#000000",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const lightColors: Colors = {
|
||||||
|
// Backgrounds
|
||||||
|
bgPrimary: "#f2f2f7",
|
||||||
|
bgSecondary: "#ffffff",
|
||||||
|
bgCard: "#ffffff",
|
||||||
|
bgInput: "#e5e5ea",
|
||||||
|
bgModal: "rgba(255,255,255,0.95)",
|
||||||
|
|
||||||
|
// Text
|
||||||
|
textPrimary: "rgba(0, 0, 0, 0.87)",
|
||||||
|
textSecondary: "rgba(0, 0, 0, 0.6)",
|
||||||
|
textMuted: "rgba(0, 0, 0, 0.4)",
|
||||||
|
textWhite: "#000000",
|
||||||
|
|
||||||
|
// Accent
|
||||||
|
accent: "#7c3aed",
|
||||||
|
accentDark: "#6d28d9",
|
||||||
|
accentLight: "#8b5cf6",
|
||||||
|
secondary: "#0891b2",
|
||||||
|
|
||||||
|
// Status
|
||||||
|
success: "#16a34a",
|
||||||
|
successDark: "#15803d",
|
||||||
|
successDarker: "#166534",
|
||||||
|
danger: "#dc2626",
|
||||||
|
dangerDark: "#b91c1c",
|
||||||
|
warning: "#d97706",
|
||||||
|
info: "#2563eb",
|
||||||
|
|
||||||
|
// Category colors
|
||||||
|
categoryWeedHash: "#10b981",
|
||||||
|
categoryTous: "#9333ea",
|
||||||
|
categoryZipette: "#1a1a1a",
|
||||||
|
categoryGros: "#0ea5e9",
|
||||||
|
|
||||||
|
// Borders
|
||||||
|
border: "#d1d5db",
|
||||||
|
borderLight: "#e5e7eb",
|
||||||
|
borderSubtle: "rgba(0, 0, 0, 0.08)",
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
overlay: "rgba(0, 0, 0, 0.3)",
|
||||||
|
transparent: "transparent",
|
||||||
|
white: "#ffffff",
|
||||||
|
black: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Colors = { [K in keyof typeof darkColors]: string };
|
||||||
|
export type ColorName = keyof Colors;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { darkColors, lightColors, darkColors as colors } from "./colors";
|
||||||
|
export type { Colors } from "./colors";
|
||||||
|
export { spacing, borderRadius } from "./spacing";
|
||||||
|
export { fontSize, fontWeight, fontFamily } from "./typography";
|
||||||
|
export { shadows } from "./shadows";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export const shadows = {
|
||||||
|
sm: Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
md: Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
lg: Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
s: 8,
|
||||||
|
m: 12,
|
||||||
|
l: 16,
|
||||||
|
xl: 24,
|
||||||
|
xxl: 32,
|
||||||
|
xxxl: 48,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const borderRadius = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 25,
|
||||||
|
full: 9999,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export const fontFamily = Platform.select({
|
||||||
|
ios: 'System',
|
||||||
|
android: 'Roboto',
|
||||||
|
default: 'System',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fontSize = {
|
||||||
|
xs: 11,
|
||||||
|
sm: 13,
|
||||||
|
md: 15,
|
||||||
|
lg: 17,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 28,
|
||||||
|
title: 34,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const fontWeight = {
|
||||||
|
regular: '400' as const,
|
||||||
|
medium: '500' as const,
|
||||||
|
semibold: '600' as const,
|
||||||
|
bold: '700' as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Colors } from "../theme/colors";
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: "En attente",
|
||||||
|
assigned: "Assignée",
|
||||||
|
en_route: "En route",
|
||||||
|
arrived: "Arrivée",
|
||||||
|
livre: "Livrée",
|
||||||
|
delivered: "Livré",
|
||||||
|
approved: "Terminée",
|
||||||
|
cancelled: "Annulé",
|
||||||
|
available: "Disponible",
|
||||||
|
busy: "Occupé",
|
||||||
|
offline: "Hors ligne",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusColors = (colors: Colors): Record<string, string> => ({
|
||||||
|
pending: colors.warning,
|
||||||
|
assigned: colors.info,
|
||||||
|
en_route: colors.info,
|
||||||
|
arrived: colors.accent,
|
||||||
|
livre: colors.success,
|
||||||
|
delivered: colors.success,
|
||||||
|
approved: colors.successDark,
|
||||||
|
cancelled: colors.danger,
|
||||||
|
available: colors.success,
|
||||||
|
busy: colors.warning,
|
||||||
|
offline: colors.textMuted,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const PROTOCOL_RE = /^https?:\/\//i;
|
||||||
|
|
||||||
|
// Hostname label: alnum, may contain hyphens but not at the edges.
|
||||||
|
const HOST_RE =
|
||||||
|
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
|
||||||
|
const isValidPort = (port: string) => {
|
||||||
|
if (!/^\d{1,5}$/.test(port)) return false;
|
||||||
|
const n = Number(port);
|
||||||
|
return n >= 1 && n <= 65535;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accepts either a full URL ("https://exemple.com", "http://exemple.com:8080")
|
||||||
|
// or a bare host/IP with a port ("192.168.1.10:8000", "exemple.com:3000").
|
||||||
|
// Returns a normalized absolute base URL, or null if the input is invalid.
|
||||||
|
export function normalizeServerUrl(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const hasProtocol = PROTOCOL_RE.test(trimmed);
|
||||||
|
const withoutProtocol = trimmed.replace(PROTOCOL_RE, "");
|
||||||
|
const withoutTrailingSlash = withoutProtocol.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const parts = withoutTrailingSlash.split(":");
|
||||||
|
if (parts.length > 2) return null; // unsupported (e.g. raw IPv6)
|
||||||
|
|
||||||
|
const [host, port] = parts;
|
||||||
|
if (!host || !HOST_RE.test(host)) return null;
|
||||||
|
if (port !== undefined && !isValidPort(port)) return null;
|
||||||
|
|
||||||
|
const protocol = hasProtocol
|
||||||
|
? trimmed.match(PROTOCOL_RE)![0].toLowerCase()
|
||||||
|
: "http://";
|
||||||
|
|
||||||
|
return `${protocol}${withoutTrailingSlash}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user