chore: build
This commit is contained in:
@@ -29,6 +29,7 @@ yarn-error.*
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
!certs/certificate.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+10958
-11225
File diff suppressed because it is too large
Load Diff
+54
-54
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ export type RootStackParamList = {
|
||||
login: undefined;
|
||||
register: undefined;
|
||||
role: undefined;
|
||||
clientTwoFA: { sessionToken: string };
|
||||
};
|
||||
|
||||
export type ChangePasswordStackParamList = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user