chore: build
Omnex Plateform App - EAS Build / build (push) Canceled after 7m42s
Omnex Plateform Client - EAS Build / build (push) Canceled after 4m43s

This commit is contained in:
Xor290
2026-08-04 21:18:12 +02:00
parent f75cf5700c
commit bef57d91de
55 changed files with 24850 additions and 5 deletions
+3 -5
View File
@@ -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;
+42
View File
@@ -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
+179
View File
@@ -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}</>;
}
+11
View File
@@ -0,0 +1,11 @@
module.exports = ({ config }) => {
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
return {
...config,
updates: {
...config.updates,
...(updateUrl ? { url: updateUrl } : {}),
},
};
};
+65
View File
@@ -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-----
+19
View File
@@ -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"
}
}
}
+8
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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
+781
View File
@@ -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;
};
}
+65
View File
@@ -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}`;
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}