chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s

This commit is contained in:
Xor290
2026-08-06 12:06:05 +02:00
parent c034088bee
commit 22a8d5026c
174 changed files with 30315 additions and 16120 deletions
+1
View File
@@ -29,6 +29,7 @@ yarn-error.*
# macOS
.DS_Store
*.pem
!certs/certificate.pem
# local env files
.env*.local
+6
View File
@@ -12,6 +12,7 @@ import { ThemeProvider, useTheme } from "./src/context/ThemeContext";
import HomeScreen from "./src/screens/HomeScreen";
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";
@@ -39,6 +40,11 @@ function AuthStack() {
component={ClientLoginScreen}
options={{ title: "Connexion" }}
/>
<Stack.Screen
name="clientTwoFA"
component={ClientTwoFAScreen}
options={{ title: "Vérification" }}
/>
</Stack.Navigator>
);
}
+61 -56
View File
@@ -1,60 +1,65 @@
{
"expo": {
"name": "Milieu Nantais",
"slug": "frontend-client",
"version": "1.0.2",
"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.clientpanel"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
"backgroundColor": "#000000"
},
"edgeToEdgeEnabled": true,
"softwareKeyboardLayoutMode": "pan",
"predictiveBackGestureEnabled": false,
"package": "com.uberstup.clientpanel",
"versionCode": 1,
"permissions": ["android.permission.VIBRATE"]
},
"web": {
"favicon": "./assets/icon.png"
},
"plugins": [
"expo-font",
"expo-router",
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
],
"extra": {
"eas": {
"projectId": "110d06c8-a8d5-4b3c-b262-0d4d7509ae9d"
},
"router": {}
},
"owner": "xor290",
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/110d06c8-a8d5-4b3c-b262-0d4d7509ae9d"
"expo": {
"name": "Milieu Nantais",
"slug": "frontend-client",
"version": "1.0.2",
"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.clientpanel"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
"backgroundColor": "#000000"
},
"edgeToEdgeEnabled": true,
"softwareKeyboardLayoutMode": "pan",
"predictiveBackGestureEnabled": false,
"package": "com.uberstup.clientpanel",
"versionCode": 1,
"permissions": [
"android.permission.VIBRATE"
]
},
"web": {
"favicon": "./assets/icon.png"
},
"plugins": [
"expo-font",
"expo-router",
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
],
"extra": {
"eas": {
"projectId": "110d06c8-a8d5-4b3c-b262-0d4d7509ae9d"
},
"router": {}
},
"owner": "xor290",
"runtimeVersion": "client-1.0.2",
"updates": {
"url": "https://u.expo.dev/110d06c8-a8d5-4b3c-b262-0d4d7509ae9d",
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": {
"keyid": "main",
"alg": "rsa-v1_5-sha256"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV
BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX
DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy
IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z
FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7
PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo
VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE
orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN
N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW
39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr
BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp
4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad
vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV
K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg
jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk
VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw==
-----END CERTIFICATE-----
+4 -4
View File
@@ -23,9 +23,9 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
},
"channel": "pre-prod"
"channel": "pre-prod-client"
},
"production": {
"distribution": "internal",
@@ -35,9 +35,9 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
},
"channel": "production"
"channel": "production-client"
}
}
}
+10958 -11225
View File
File diff suppressed because it is too large Load Diff
+54 -54
View File
@@ -1,57 +1,57 @@
{
"name": "mobile",
"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-router": "~6.0.23",
"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"
]
}
"name": "mobile",
"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-router": "~6.0.23",
"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"
]
}
}
}
+107 -25
View File
@@ -27,6 +27,8 @@ export interface AuthResponse {
access_token?: string;
token_type?: string;
expires_in?: number;
requires_2fa?: boolean;
session_token?: string;
user?: {
id: number;
username: string;
@@ -48,6 +50,13 @@ export const loginUser = async (
username,
password,
});
if (data.requires_2fa) {
return {
success: false,
requires_2fa: true,
session_token: data.session_token,
};
}
if (!data.access_token) {
return { success: false, message: "Token non reçu du serveur" };
}
@@ -68,6 +77,35 @@ export const loginUser = async (
}
};
export const verifyClient2FA = async (
sessionToken: string,
code: string,
): Promise<AuthResponse> => {
try {
const { data } = await apiClient.post(`${V1}/auth/2fa/verify`, {
session_token: sessionToken,
code,
});
if (!data.access_token) {
return { success: false, message: "Token non reçu du serveur" };
}
return {
success: true,
message: "Connexion réussie",
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
user: data.user,
};
} catch (error: any) {
const msg =
error.response?.data?.error ||
error.message ||
"Code invalide";
return { success: false, message: msg };
}
};
export const registerUser = async (
username: string,
password: string,
@@ -322,19 +360,18 @@ export const checkoutCart = async (
price_currency: data.price_currency,
};
} catch (error: any) {
const errMsg: string = error.response?.data?.error || "Erreur serveur";
if (errMsg.startsWith("Adresse invalide ")) {
const suggested = errMsg.replace("Adresse invalide ", "").trim();
const data = error.response?.data;
if (data?.corrected_address) {
return {
success: false,
invalid_address: true,
suggested_address: suggested,
message: errMsg,
suggested_address: data.corrected_address,
message: data.error || "Adresse non reconnue",
};
}
return {
success: false,
message: errMsg,
message: data?.error || "Erreur serveur",
};
}
};
@@ -420,10 +457,6 @@ export const respondToAddressProposal = async (
}
};
// ============================================
// TRACKING
// ============================================
export const getOrderTracking = async (
commandId: number,
): Promise<TrackingResponse> => {
@@ -517,10 +550,6 @@ export const getMyCompletedOrders = async (): Promise<HistoryResponse> => {
}
};
// ============================================
// CANCEL
// ============================================
export const cancelCommand = async (
commandId: number,
reason?: string,
@@ -778,6 +807,11 @@ export interface PublicSettings {
telegram_notifications_enabled: boolean;
two_fa_enabled: boolean;
contact_telegram: string;
client_color_primary: string;
client_color_secondary: string;
client_color_success: string;
client_color_danger: string;
client_color_warning: string;
}
export const getPublicSettings = async (): Promise<PublicSettings> => {
@@ -794,6 +828,11 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
telegram_notifications_enabled: false,
two_fa_enabled: false,
contact_telegram: "",
client_color_primary: "#7c3aed",
client_color_secondary: "#22d3ee",
client_color_success: "#4ade80",
client_color_danger: "#ef4444",
client_color_warning: "#f59e0b",
};
try {
const { data } = await apiClient.get(`${V1}/app-settings`);
@@ -816,6 +855,11 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
data.telegram_notifications_enabled ?? false,
two_fa_enabled: data.two_fa_enabled ?? false,
contact_telegram: data.contact_telegram || "",
client_color_primary: data.client_color_primary || "#7c3aed",
client_color_secondary: data.client_color_secondary || "#22d3ee",
client_color_success: data.client_color_success || "#4ade80",
client_color_danger: data.client_color_danger || "#ef4444",
client_color_warning: data.client_color_warning || "#f59e0b",
};
} catch {
return defaults;
@@ -926,6 +970,13 @@ export type RewardCategoryConfig = {
amount: number;
};
export type RewardItemConfig = {
product_id: number;
product_name: string;
quantity: number;
price: number;
};
export type PointsPoolInfo = {
key: string;
name: string;
@@ -934,13 +985,7 @@ export type PointsPoolInfo = {
rewards_claimed: number;
rewards_available: number;
eligible_configs: RewardCategoryConfig[];
};
export type RewardItemConfig = {
product_id: number;
product_name: string;
quantity: number;
price: number;
eligible_reward_items: RewardItemConfig[];
};
export type PointsRewardConfig = {
@@ -969,7 +1014,10 @@ export const getMyPointsRewards = async (): Promise<{
}
};
export const claimMyReward = async (poolKey: string, productId?: number): Promise<{
export const claimMyReward = async (
poolKey: string,
productId?: number,
): Promise<{
success: boolean;
description?: string;
remaining_rewards?: number;
@@ -978,7 +1026,9 @@ export const claimMyReward = async (poolKey: string, productId?: number): Promis
error?: string;
}> => {
try {
const body: { pool_key: string; product_id?: number } = { pool_key: poolKey };
const body: { pool_key: string; product_id?: number } = {
pool_key: poolKey,
};
if (productId !== undefined) body.product_id = productId;
const { data } = await apiClient.post(`${V1}/points/claim`, body);
return {
@@ -993,12 +1043,44 @@ export const claimMyReward = async (poolKey: string, productId?: number): Promis
}
};
export const submitLivreurRating = async (
orderId: number,
rating: number,
comment: string,
): Promise<{ success: boolean; error?: string }> => {
try {
await apiClient.post(`${V1}/orders/${orderId}/rate`, {
rating,
comment,
});
return { success: true };
} catch (e: any) {
return { success: false, error: e?.response?.data?.error || "Erreur" };
}
};
export const getOrderRatingStatus = async (
orderId: number,
): Promise<{ rated: boolean; rating?: number; comment?: string }> => {
try {
const { data } = await apiClient.get(`${V1}/orders/${orderId}/rating`);
return data;
} catch {
return { rated: false };
}
};
export const calculateOrderTotal = (order: any): number => {
if (typeof order.total_prix === "number" && order.total_prix > 0) return order.total_prix;
if (typeof order.total_prix === "number" && order.total_prix > 0)
return order.total_prix;
if (typeof order.total === "number" && order.total > 0) return order.total;
if (Array.isArray(order.items) && order.items.length > 0) {
return order.items.reduce((sum: number, item: any) => {
return sum + (item.prix || item.price || 0) * (item.quantite || item.quantity || 1);
return (
sum +
(item.prix || item.price || 0) *
(item.quantite || item.quantity || 1)
);
}, 0);
}
return 0;
+9 -1
View File
@@ -1,3 +1,7 @@
// ============================================
// api/api_TYPES.ts - TOUTES LES INTERFACES
// ============================================
export interface ApiResponse {
success: boolean;
message?: string;
@@ -6,7 +10,7 @@ export interface ApiResponse {
token_type?: string;
expires_in?: number;
user?: UserResponse;
[key: string]: any;
[key: string]: any; // Pour les champs additionnels
}
/**
@@ -89,6 +93,10 @@ export interface RegisterAdminRequest {
role: "admin" | "cabine" | "livreur";
}
// ============================================
// 🛒 PANIER - TYPES
// ============================================
/**
* Item du panier côté frontend
*/
+139 -174
View File
@@ -1,198 +1,163 @@
import React, {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
} from "react";
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";
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;
export type UserRole = 'client' | 'admin' | 'cabine' | 'livreur' | null;
interface AuthState {
token: string | null;
username: string | null;
role: UserRole;
isLoading: boolean;
isAuthenticated: boolean;
mustChangePassword: boolean;
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;
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,
});
const [state, setState] = useState<AuthState>({
token: null,
username: null,
role: null,
isLoading: true,
isAuthenticated: false,
mustChangePassword: false,
});
useEffect(() => {
const restore = async () => {
try {
const savedRole = await getRole();
// 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;
}
}
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 () => {
// No valid session
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
mustChangePassword: false,
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,
});
}
};
return (
<AuthContext.Provider
value={{
...state,
loginClient,
loginAdmin,
logout,
setMustChangePassword,
}}
>
{children}
</AuthContext.Provider>
);
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;
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+38 -3
View File
@@ -1,6 +1,8 @@
import React, { createContext, useContext, useState, useEffect, type ReactNode } from "react";
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";
@@ -12,16 +14,46 @@ interface ThemeContextType {
}
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";
@@ -29,8 +61,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
AsyncStorage.setItem(STORAGE_KEY, next);
};
const baseColors = mode === "dark" ? darkColors : lightColors;
const colors: Colors = { ...baseColors, ...colorOverrides };
const value: ThemeContextType = {
colors: mode === "dark" ? darkColors : lightColors,
colors,
mode,
toggleTheme,
isDark: mode === "dark",
+1
View File
@@ -3,6 +3,7 @@ export type RootStackParamList = {
login: undefined;
register: undefined;
role: undefined;
clientTwoFA: { sessionToken: string };
};
export type ChangePasswordStackParamList = {
+1 -2
View File
@@ -22,7 +22,7 @@ export default function HomeScreen({ navigation }: Props) {
</Text>
<TouchableOpacity
style={styles.button}
style={[styles.button, { backgroundColor: colors.accent }]}
onPress={() => navigation.navigate("login")}
>
<Text style={styles.buttonText}>Se connecter</Text>
@@ -58,7 +58,6 @@ const styles = StyleSheet.create({
textAlign: "center",
},
button: {
backgroundColor: "#7c3aed",
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 24,
@@ -61,7 +61,11 @@ const LoginClient = () => {
formData.username,
formData.password,
);
if (result.success && result.access_token) {
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 =
@@ -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;
@@ -1,5 +1,15 @@
import React, { useState, useEffect, useMemo } from "react";
import { View, Text, ScrollView, Image, StyleSheet } from "react-native";
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";
@@ -8,6 +18,8 @@ import {
getProductById,
formatOrderDate,
formatPrice,
submitLivreurRating,
getOrderRatingStatus,
} from "../../api/api";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
@@ -54,6 +66,11 @@ export default function OrderDetailsScreen() {
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 () => {
@@ -97,6 +114,10 @@ export default function OrderDetailsScreen() {
}),
);
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");
}
@@ -108,6 +129,17 @@ export default function OrderDetailsScreen() {
})();
}, [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({
@@ -296,6 +328,89 @@ export default function OrderDetailsScreen() {
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],
);
@@ -323,6 +438,7 @@ export default function OrderDetailsScreen() {
const total = rawTotal - referralUsed;
return (
<>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
@@ -454,6 +570,21 @@ export default function OrderDetailsScreen() {
</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
@@ -540,5 +671,51 @@ export default function OrderDetailsScreen() {
</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>
</>
);
}
@@ -67,6 +67,11 @@ export default function OrderHistoryScreen() {
telegram_notifications_enabled: false,
two_fa_enabled: false,
contact_telegram: "",
client_color_primary: "",
client_color_secondary: "",
client_color_success: "",
client_color_danger: "",
client_color_warning: "",
});
const [referralBalance, setReferralBalance] = useState(0);
const [pointsRewards, setPointsRewards] = useState<{
@@ -119,7 +124,8 @@ export default function OrderHistoryScreen() {
}, []);
const handleClaim = (poolKey: string) => {
const items = pointsRewards?.reward?.reward_items ?? [];
const pool = pointsRewards?.pools.find((p) => p.key === poolKey);
const items = pool?.eligible_reward_items ?? [];
if (items.length > 1) {
setClaimFeedback(null);
setRewardPickerPool(poolKey);
@@ -1091,7 +1097,9 @@ export default function OrderHistoryScreen() {
<ScrollView showsVerticalScrollIndicator={false}>
{(
pointsRewards?.reward?.reward_items ?? []
pointsRewards?.pools.find(
(p) => p.key === rewardPickerPool,
)?.eligible_reward_items ?? []
).map((item: RewardItemConfig, idx: number) => (
<TouchableOpacity
key={idx}
@@ -101,7 +101,9 @@ export default function OrderTrackingScreen() {
}, []);
useEffect(() => {
getPublicSettings().then((s) => setPenaltiesEnabled(s.penalties_enabled)).catch(() => {});
getPublicSettings()
.then((s) => setPenaltiesEnabled(s.penalties_enabled))
.catch(() => {});
}, []);
useFocusEffect(
@@ -392,7 +394,10 @@ export default function OrderTrackingScreen() {
renderItem={({ item: order }) => {
const expanded = expandedId === order.id;
const gross = calculateOrderTotal(order);
const total = Math.max(0, gross - (order.referral_used ?? 0));
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];
@@ -456,8 +461,18 @@ export default function OrderTrackingScreen() {
{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
style={{
fontSize: 11,
color: colors.success,
marginTop: 2,
}}
>
dont -
{(
order.referral_used as number
).toFixed(2)}{" "}
parrainage
</Text>
)}
</View>
@@ -559,7 +574,8 @@ export default function OrderTrackingScreen() {
color={colors.warning}
/>
<Text style={styles.trackText}>
{eta?.eta_minutes != null && eta.eta_minutes > 0
{eta?.eta_minutes != null &&
eta.eta_minutes > 0
? `Temps de livraison estimé : ~${eta.eta_minutes} min`
: "Aucune heure disponible"}
</Text>
@@ -573,7 +589,8 @@ export default function OrderTrackingScreen() {
color={colors.warning}
/>
<Text style={styles.trackText}>
Temps de livraison estimé : ~
Temps de livraison estimé :
~
{eta?.eta_minutes != null &&
eta.eta_minutes > 0 &&
eta.eta_minutes < 5
@@ -684,20 +701,35 @@ export default function OrderTrackingScreen() {
>
<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.
<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>
)}
@@ -765,8 +797,15 @@ export default function OrderTrackingScreen() {
size={16}
color={colors.danger}
/>
<Text style={[styles.penaltyDetail, { color: colors.danger, fontWeight: "700" }]}>
Amende : {penaltyWarning.penalty_warning.penalty_amount}
<Text
style={[
styles.penaltyDetail,
{ color: colors.danger, fontWeight: "700" },
]}
>
Amende :{" "}
{penaltyWarning.penalty_warning.penalty_amount}{" "}
</Text>
</View>
)}
@@ -41,6 +41,7 @@ export default function ProductDetailScreen() {
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");
@@ -95,6 +96,10 @@ export default function ProductDetailScreen() {
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({
@@ -794,6 +799,42 @@ export default function ProductDetailScreen() {
</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>
);
}
+22 -31
View File
@@ -50,10 +50,19 @@ export default function ProductsScreen() {
const carouselRef = useRef<FlatList>(null);
const scaleAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
getCategories().then(setCategories);
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([
@@ -73,8 +82,8 @@ export default function ProductsScreen() {
return () => pulse.stop();
}, [scaleAnim]);
const fetchProducts = useCallback(async (silent = false) => {
if (!silent) setError(null);
const fetchProducts = useCallback(async () => {
setError(null);
try {
let response: any;
if (selectedCategory === "tous") {
@@ -82,35 +91,17 @@ export default function ProductsScreen() {
} else {
response = await getProductsByCategory(selectedCategory);
}
const list: Product[] = Array.isArray(response?.data || response?.products)
? (response?.data || response?.products)
: [];
if (silent) {
setProducts((prev) =>
prev.map((p) => {
const updated = list.find((r) => r.id === p.id);
return updated ? { ...p, stock: updated.stock } : p;
})
);
} else {
setProducts(list);
}
const list = response?.data || response?.products || [];
setProducts(Array.isArray(list) ? list : []);
} catch {
if (!silent) {
setError("Impossible de charger les produits");
setProducts([]);
}
setError("Impossible de charger les produits");
setProducts([]);
} finally {
if (!silent) {
setLoading(false);
setRefreshing(false);
}
setLoading(false);
setRefreshing(false);
}
}, [selectedCategory]);
const fetchProductsRef = useRef(fetchProducts);
useEffect(() => { fetchProductsRef.current = fetchProducts; }, [fetchProducts]);
useEffect(() => {
const catObj = categories.find((c) => c.name === selectedCategory);
if (catObj?.is_coming_soon) {
@@ -120,8 +111,6 @@ export default function ProductsScreen() {
}
setLoading(true);
fetchProducts();
const interval = setInterval(() => fetchProductsRef.current(true), 30_000);
return () => clearInterval(interval);
}, [selectedCategory, categories, fetchProducts]);
useEffect(() => {
@@ -132,7 +121,9 @@ export default function ProductsScreen() {
const onRefresh = () => {
setRefreshing(true);
fetchProducts();
// 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(
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,7 @@ export const darkColors = {
accent: "#7c3aed",
accentDark: "#6d28d9",
accentLight: "#8b5cf6",
secondary: "#22d3ee",
// Status
success: "#4ade80",
@@ -62,6 +63,7 @@ export const lightColors: Colors = {
accent: "#7c3aed",
accentDark: "#6d28d9",
accentLight: "#8b5cf6",
secondary: "#0891b2",
// Status
success: "#16a34a",