chore: build
This commit is contained in:
@@ -172,12 +172,10 @@ export const resetAdminStats = async (
|
||||
);
|
||||
return data;
|
||||
};
|
||||
// ============================================
|
||||
// STATISTIQUES MENSUELLES (jour par jour)
|
||||
// ============================================
|
||||
|
||||
export interface MonthlyDayStat {
|
||||
day: string; // "2026-06-05"
|
||||
label: string; // "05/06"
|
||||
day: string;
|
||||
label: string;
|
||||
count: number;
|
||||
revenue: 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