chore: build
This commit is contained in:
+18
-1
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useEffect } from "react";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { ActivityIndicator, View, StyleSheet } from "react-native";
|
import { ActivityIndicator, View, StyleSheet } from "react-native";
|
||||||
|
import * as Updates from "expo-updates";
|
||||||
import { NavigationContainer } from "@react-navigation/native";
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
|
||||||
@@ -91,6 +92,22 @@ function RootNavigator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (__DEV__) return;
|
||||||
|
const checkForUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const update = await Updates.checkForUpdateAsync();
|
||||||
|
if (update.isAvailable) {
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
await Updates.reloadAsync();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore update errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkForUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
updates: {
|
||||||
|
...config.updates,
|
||||||
|
...(updateUrl ? { url: updateUrl } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
+10
-2
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Admin Panel",
|
"name": "Admin Panel",
|
||||||
"slug": "frontend-admin",
|
"slug": "frontend-admin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "com.uberstup.adminpanel",
|
"package": "com.uberstup.adminpanel",
|
||||||
|
"abiFilters": ["arm64-v8a"],
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/icon.png",
|
"foregroundImage": "./assets/icon.png",
|
||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-location",
|
"expo-location",
|
||||||
|
"expo-updates",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -50,6 +52,12 @@
|
|||||||
"projectId": "fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440"
|
"projectId": "fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "xor290"
|
"owner": "xor290",
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -11,21 +11,21 @@
|
|||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"gradleCommand": ":app:assembleDebug"
|
"gradleCommand": ":app:assembleDebug"
|
||||||
},
|
},
|
||||||
"ios": {
|
|
||||||
"simulator": true
|
|
||||||
},
|
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://5.181.0.112.nip.io"
|
"EXPO_PUBLIC_API_URL": "http://localhost:8080"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"preview": {
|
"channel": "development"
|
||||||
|
},
|
||||||
|
"pre-prod": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://5.181.0.112.nip.io"
|
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
|
||||||
}
|
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
|
||||||
|
},
|
||||||
|
"channel": "pre-prod-admin"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -34,8 +34,10 @@
|
|||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://mln-uber.club"
|
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
|
||||||
}
|
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
|
||||||
|
},
|
||||||
|
"channel": "production-admin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+77
@@ -21,6 +21,7 @@
|
|||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-location": "~19.0.8",
|
"expo-location": "~19.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.17",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -4210,6 +4211,12 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-eas-client": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.22",
|
"version": "19.0.22",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
||||||
@@ -4253,6 +4260,12 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-json-utils": {
|
||||||
|
"version": "0.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
|
||||||
|
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo-keep-awake": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
||||||
@@ -4270,6 +4283,19 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-manifests": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config": "~12.0.13",
|
||||||
|
"expo-json-utils": "~0.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.25",
|
"version": "3.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||||
@@ -4320,6 +4346,57 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-structured-headers": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates": {
|
||||||
|
"version": "29.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.17.tgz",
|
||||||
|
"integrity": "sha512-9h78cs6Q2rs/dEY7zAgyEm/m6J5rHy8RNpRyhilEAvrzrGLHChVZJT+bSR2RwNJg1DtwUNEjCgZrxDlM7LnNkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/code-signing-certificates": "^0.0.6",
|
||||||
|
"@expo/plist": "^0.4.8",
|
||||||
|
"@expo/spawn-async": "^1.7.2",
|
||||||
|
"arg": "4.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"expo-eas-client": "~1.0.8",
|
||||||
|
"expo-manifests": "~1.0.11",
|
||||||
|
"expo-structured-headers": "~5.0.0",
|
||||||
|
"expo-updates-interface": "~2.0.0",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"ignore": "^5.3.1",
|
||||||
|
"resolve-from": "^5.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"expo-updates": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates-interface": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates/node_modules/arg": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo/node_modules/@expo/cli": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.24",
|
"version": "54.0.24",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-location": "~19.0.8",
|
"expo-location": "~19.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.17",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import apiClient, { API_BASE_URL } from "./client";
|
import apiClient from "./client";
|
||||||
|
import { API_BASE_URL } from "./client";
|
||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
ClientResponse,
|
ClientResponse,
|
||||||
@@ -52,6 +53,72 @@ export const logoutAdmin = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface StatsSummary {
|
||||||
|
total_orders: number;
|
||||||
|
total_revenue: number;
|
||||||
|
peak_weekday: string;
|
||||||
|
top_product: string;
|
||||||
|
avg_per_day: number;
|
||||||
|
}
|
||||||
|
export interface WeekdayStat {
|
||||||
|
weekday: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
export interface DayStat {
|
||||||
|
day: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
export interface DayRevenueStat {
|
||||||
|
day: string;
|
||||||
|
label: string;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
export interface HourStat {
|
||||||
|
hour: number;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
export interface ProductStat {
|
||||||
|
product_id: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
order_count: number;
|
||||||
|
revenue: number;
|
||||||
|
category: string;
|
||||||
|
category_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuantityStat {
|
||||||
|
quantity: number;
|
||||||
|
order_count: number;
|
||||||
|
total_sold: number;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
export interface ProductQuantityBreakdown {
|
||||||
|
product_id: number;
|
||||||
|
name: string;
|
||||||
|
category_color: string;
|
||||||
|
total_orders: number;
|
||||||
|
quantities: QuantityStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
summary: StatsSummary;
|
||||||
|
by_weekday: WeekdayStat[];
|
||||||
|
by_day_30: DayStat[];
|
||||||
|
by_day_revenue: DayRevenueStat[];
|
||||||
|
by_hour: HourStat[];
|
||||||
|
top_products: ProductStat[];
|
||||||
|
by_quantity: ProductQuantityBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAdminStats = async (): Promise<AdminStats> => {
|
||||||
|
const { data } = await apiClient.get(`${V2}/admin/protected/stats`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getAllClients = async (): Promise<ClientResponse[]> => {
|
export const getAllClients = async (): Promise<ClientResponse[]> => {
|
||||||
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
||||||
return data.clients || [];
|
return data.clients || [];
|
||||||
@@ -84,9 +151,6 @@ export const updateUserByAdmin = async (
|
|||||||
return { success: true, message: data.message, user: data.user };
|
return { success: true, message: data.message, user: data.user };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// COMMANDES
|
|
||||||
|
|
||||||
export const getAllCommands = async (status?: string, username?: string) => {
|
export const getAllCommands = async (status?: string, username?: string) => {
|
||||||
let url = `${V2}/admin/protected/orders`;
|
let url = `${V2}/admin/protected/orders`;
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
@@ -171,8 +235,11 @@ export const updateCommandAddress = async (
|
|||||||
export const validateCommand = async (commandId: number) => {
|
export const validateCommand = async (commandId: number) => {
|
||||||
const { data } = await apiClient.post(
|
const { data } = await apiClient.post(
|
||||||
`${V2}/admin/protected/orders/${commandId}/force-validate`,
|
`${V2}/admin/protected/orders/${commandId}/force-validate`,
|
||||||
|
{ command_id: commandId },
|
||||||
);
|
);
|
||||||
return { success: true, message: data.message };
|
const validated = (data.validated ?? []) as { command_id: number; points_awarded: number }[];
|
||||||
|
const points = validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0;
|
||||||
|
return { success: true, points_awarded: points, validated_count: data.validated_count ?? 0 };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const proposeAddressChangeAdmin = async (
|
export const proposeAddressChangeAdmin = async (
|
||||||
@@ -197,23 +264,11 @@ export const notifyClientToDescend = async (commandId: number) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const confirmReceptionAdmin = async (commandId: number) => {
|
|
||||||
const { data } = await apiClient.post(
|
|
||||||
`${V2}/admin/protected/orders/${commandId}/confirm-reception`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: data.message,
|
|
||||||
points_earned: data.points_earned,
|
|
||||||
client_username: data.client_username,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// LIVREURS
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export const getAvailableDeliveryPersons = async () => {
|
export const getAvailableDeliveryPersons = async () => {
|
||||||
|
try {
|
||||||
const { data } = await apiClient.get(
|
const { data } = await apiClient.get(
|
||||||
`${V2}/admin/protected/delivery-persons`,
|
`${V2}/admin/protected/delivery-persons`,
|
||||||
);
|
);
|
||||||
@@ -222,6 +277,9 @@ export const getAvailableDeliveryPersons = async () => {
|
|||||||
livreurs: data.livreurs || [],
|
livreurs: data.livreurs || [],
|
||||||
count: data.count || 0,
|
count: data.count || 0,
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
|
return { success: false, livreurs: [], count: 0 };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const assignDeliveryPerson = async (
|
export const assignDeliveryPerson = async (
|
||||||
@@ -370,10 +428,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PRODUITS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getAllProductsAdmin = async (): Promise<{
|
export const getAllProductsAdmin = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Product[];
|
data: Product[];
|
||||||
@@ -445,7 +499,7 @@ export const createUserByAdmin = async (data: {
|
|||||||
role: string;
|
role: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { data: res } = await apiClient.post(
|
const { data: res } = await apiClient.post(
|
||||||
`${V2}/admin/auth/register`,
|
`${V2}/admin/protected/users`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -710,6 +764,20 @@ export const deleteProductMediaAdmin = async (
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const activateProductPrice = async (priceId: number) => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${V2}/admin/protected/active/product/price/${priceId}`,
|
||||||
|
);
|
||||||
|
return { success: true, message: data.message };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deactivateProductPrice = async (priceId: number) => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${V2}/admin/protected/desactive/product/price/${priceId}`,
|
||||||
|
);
|
||||||
|
return { success: true, message: data.message };
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ADDRESSES
|
// ADDRESSES
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -849,6 +917,26 @@ export interface PointsTier {
|
|||||||
points: number;
|
points: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RewardCategoryConfig {
|
||||||
|
category: string;
|
||||||
|
all_products: boolean;
|
||||||
|
product_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RewardItem {
|
||||||
|
product_id: number;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsReward {
|
||||||
|
threshold: number;
|
||||||
|
type: "free_product" | "half_price_product" | "custom";
|
||||||
|
description: string;
|
||||||
|
category_configs: RewardCategoryConfig[];
|
||||||
|
reward_items: RewardItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PointsPool {
|
export interface PointsPool {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -947,6 +1035,7 @@ export interface AppSettings {
|
|||||||
penalty_tiers: PenaltyTier[];
|
penalty_tiers: PenaltyTier[];
|
||||||
points_enabled: boolean;
|
points_enabled: boolean;
|
||||||
points_pools: PointsPool[];
|
points_pools: PointsPool[];
|
||||||
|
points_reward?: PointsReward | null;
|
||||||
referral_enabled: boolean;
|
referral_enabled: boolean;
|
||||||
delivery_schedule: DeliverySchedule;
|
delivery_schedule: DeliverySchedule;
|
||||||
postal_zones: PostalZone[];
|
postal_zones: PostalZone[];
|
||||||
@@ -958,7 +1047,10 @@ export interface AppSettings {
|
|||||||
telegram_bot_token: string;
|
telegram_bot_token: string;
|
||||||
telegram_bot_username: string;
|
telegram_bot_username: string;
|
||||||
telegram_notifications_enabled: boolean;
|
telegram_notifications_enabled: boolean;
|
||||||
|
telegram_2fa_enabled: boolean;
|
||||||
delivery_mode: DeliveryModeConfig;
|
delivery_mode: DeliveryModeConfig;
|
||||||
|
shop_name: string;
|
||||||
|
contact_telegram: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSettings = async (): Promise<{
|
export const getSettings = async (): Promise<{
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import apiClient, { API_BASE_URL } from "./client";
|
import apiClient from "./client";
|
||||||
|
import { API_BASE_URL } from "./client";
|
||||||
|
|
||||||
|
//@ts
|
||||||
import type {
|
import type {
|
||||||
OrderItem,
|
OrderItem,
|
||||||
DeliveryPerson,
|
DeliveryPerson,
|
||||||
@@ -39,10 +42,6 @@ export const confirmReceptionCabine = async (commandId: number) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PENALITES
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const applyClientPenalty = async (
|
export const applyClientPenalty = async (
|
||||||
clientUsername: string,
|
clientUsername: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
@@ -70,7 +69,6 @@ export const resetClientPenalties = async (clientUsername: string) => {
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
// pool: index dans pool_names/pool_keys, -1 = reset tous les points
|
|
||||||
export const resetClientPoints = async (
|
export const resetClientPoints = async (
|
||||||
clientUsername: string,
|
clientUsername: string,
|
||||||
pool: number = -1,
|
pool: number = -1,
|
||||||
@@ -96,10 +94,6 @@ export const getPenaltiesStats = async () => {
|
|||||||
return { success: true, data: data.data };
|
return { success: true, data: data.data };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// COMMANDES
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getCancelledOrders = async () => {
|
export const getCancelledOrders = async () => {
|
||||||
const { data } = await apiClient.get(`${API}/commands/cancelled`);
|
const { data } = await apiClient.get(`${API}/commands/cancelled`);
|
||||||
return {
|
return {
|
||||||
@@ -200,7 +194,7 @@ export const getAllDeliveryPersonsWithDetails = async (): Promise<{
|
|||||||
users.map(async (u: any): Promise<DeliveryPerson> => {
|
users.map(async (u: any): Promise<DeliveryPerson> => {
|
||||||
try {
|
try {
|
||||||
const { data: details } = await apiClient.get(
|
const { data: details } = await apiClient.get(
|
||||||
`${V2}/admin/protected/delivery-persons/${u.username}`,
|
`${API}/delivery-persons/${u.username}`,
|
||||||
);
|
);
|
||||||
const d = details.deliveryman || details;
|
const d = details.deliveryman || details;
|
||||||
const parsedStatus = parseStatus(d.status);
|
const parsedStatus = parseStatus(d.status);
|
||||||
@@ -373,7 +367,7 @@ export const markCabineNotificationsRead = async (): Promise<void> => {
|
|||||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(
|
const { data } = await apiClient.get(
|
||||||
`https://5.181.0.112.nip.io/api/v1/app-settings`,
|
`${API_BASE_URL}/api/v1/app-settings`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
penalties_enabled: data.penalties_enabled ?? true,
|
penalties_enabled: data.penalties_enabled ?? true,
|
||||||
@@ -433,11 +427,41 @@ export const getAllAddresses = async (): Promise<
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMMANDES — CABINE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const getCabineCommands = async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
commands: any[];
|
||||||
|
count: number;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`${API}/commands`);
|
||||||
|
return { success: true, commands: data.commands || [], count: data.count || 0 };
|
||||||
|
} catch {
|
||||||
|
return { success: false, commands: [], count: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CLIENTS — CABINE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const getCabineAllClients = async (): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`${API}/all/clients`);
|
||||||
|
return data.clients || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TELEGRAM — CABINE
|
// TELEGRAM — CABINE
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
const CABINE_API = "https://5.181.0.112.nip.io/api/v1/cabine";
|
const CABINE_API = `${API_BASE_URL}/api/v1/cabine`;
|
||||||
|
|
||||||
export const getCabineTelegramStatus = async (): Promise<{
|
export const getCabineTelegramStatus = async (): Promise<{
|
||||||
linked: boolean;
|
linked: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import apiClient, { API_BASE_URL } from "./client";
|
import apiClient from "./client";
|
||||||
|
import { API_BASE_URL } from "./client";
|
||||||
import type {
|
import type {
|
||||||
DeliveryStatus,
|
DeliveryStatus,
|
||||||
QueueInfo,
|
QueueInfo,
|
||||||
@@ -9,10 +10,6 @@ import type {
|
|||||||
|
|
||||||
const API = `${API_BASE_URL}/api/v1/livreur`;
|
const API = `${API_BASE_URL}/api/v1/livreur`;
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// STATUT
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getMyStatus = async (): Promise<{
|
export const getMyStatus = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status?: DeliveryStatus;
|
status?: DeliveryStatus;
|
||||||
@@ -45,10 +42,6 @@ export const updateMyStatus = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// QUEUE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getMyQueue = async (): Promise<{
|
export const getMyQueue = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
queue_info?: QueueInfo;
|
queue_info?: QueueInfo;
|
||||||
@@ -65,10 +58,6 @@ export const getMyQueue = async (): Promise<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// LIVRAISONS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getMyDeliveries = async (): Promise<{
|
export const getMyDeliveries = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
deliveries?: DeliveryItem[];
|
deliveries?: DeliveryItem[];
|
||||||
@@ -383,6 +372,28 @@ export const ISSUE_LABELS: Record<IssueType, string> = {
|
|||||||
other: "Autre",
|
other: "Autre",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StatPoint = { label: string; count: number; revenue: number };
|
||||||
|
|
||||||
|
export const getMyStats = async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
by_day?: StatPoint[];
|
||||||
|
by_week?: StatPoint[];
|
||||||
|
by_month?: StatPoint[];
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`${API}/stats`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
by_day: data.by_day || [],
|
||||||
|
by_week: data.by_week || [],
|
||||||
|
by_month: data.by_month || [],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.response?.data?.error || "Erreur réseau" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const reportDeliveryIssue = async (
|
export const reportDeliveryIssue = async (
|
||||||
deliveryId: number,
|
deliveryId: number,
|
||||||
issueType: IssueType,
|
issueType: IssueType,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor: attach JWT token
|
|
||||||
apiClient.interceptors.request.use(async (config) => {
|
apiClient.interceptors.request.use(async (config) => {
|
||||||
const isAdminRoute =
|
const isAdminRoute =
|
||||||
config.url?.includes("/api/v2/") ||
|
config.url?.includes("/api/v2/") ||
|
||||||
@@ -27,7 +26,6 @@ apiClient.interceptors.request.use(async (config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Response interceptor: handle common errors
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import axios from "axios";
|
|||||||
|
|
||||||
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
|
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
|
||||||
|
|
||||||
// ---- Types ----
|
|
||||||
export interface LatLng {
|
export interface LatLng {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -24,7 +23,6 @@ export interface NavigationInstruction {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Maneuver translations (FR) ----
|
|
||||||
export const maneuverTranslations: Record<string, string> = {
|
export const maneuverTranslations: Record<string, string> = {
|
||||||
TURN_LEFT: "Tournez à gauche",
|
TURN_LEFT: "Tournez à gauche",
|
||||||
TURN_RIGHT: "Tournez à droite",
|
TURN_RIGHT: "Tournez à droite",
|
||||||
@@ -48,7 +46,6 @@ export const maneuverTranslations: Record<string, string> = {
|
|||||||
WAYPOINT_REACHED: "Point de passage atteint",
|
WAYPOINT_REACHED: "Point de passage atteint",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ionicons name per maneuver
|
|
||||||
export const maneuverIcons: Record<string, string> = {
|
export const maneuverIcons: Record<string, string> = {
|
||||||
TURN_LEFT: "arrow-back",
|
TURN_LEFT: "arrow-back",
|
||||||
TURN_RIGHT: "arrow-forward",
|
TURN_RIGHT: "arrow-forward",
|
||||||
@@ -79,7 +76,6 @@ function formatDistance(meters: number): string {
|
|||||||
return `${(meters / 1000).toFixed(1)} km`;
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Geocode address → coords ----
|
|
||||||
export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
||||||
try {
|
try {
|
||||||
const url = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(address)}.json?key=${TOMTOM_API_KEY}&limit=1`;
|
const url = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(address)}.json?key=${TOMTOM_API_KEY}&limit=1`;
|
||||||
@@ -96,7 +92,6 @@ export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Calculate route ----
|
|
||||||
export async function calculateRoute(
|
export async function calculateRoute(
|
||||||
origin: LatLng,
|
origin: LatLng,
|
||||||
destination: LatLng,
|
destination: LatLng,
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ export interface Product {
|
|||||||
category: string;
|
category: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
prices?: Array<{ quantity: number; price: number }>;
|
prices?: Array<{ id?: number; quantity: number; price: number; active_price?: boolean }>;
|
||||||
media?: Array<{ url: string; type: string; id?: number; created_at?: string }>;
|
media?: Array<{ url: string; type: string; id?: number; created_at?: string }>;
|
||||||
|
coming_soon?: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { fontSize, spacing } from "../theme";
|
|||||||
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
||||||
|
|
||||||
import DashboardScreen from "../screens/admin/DashboardScreen";
|
import DashboardScreen from "../screens/admin/DashboardScreen";
|
||||||
|
import StatsScreen from "../screens/admin/StatsScreen";
|
||||||
import OrdersScreen from "../screens/admin/OrdersScreen";
|
import OrdersScreen from "../screens/admin/OrdersScreen";
|
||||||
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
|
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
|
||||||
import UsersScreen from "../screens/admin/UsersScreen";
|
import UsersScreen from "../screens/admin/UsersScreen";
|
||||||
@@ -175,6 +176,16 @@ function AdminTabs() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Stats"
|
||||||
|
component={StatsScreen}
|
||||||
|
options={{
|
||||||
|
title: "Stats",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="bar-chart-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Orders"
|
name="Orders"
|
||||||
component={OrdersScreen}
|
component={OrdersScreen}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AuthStackParamList = {
|
|||||||
|
|
||||||
export type AdminTabParamList = {
|
export type AdminTabParamList = {
|
||||||
Dashboard: undefined;
|
Dashboard: undefined;
|
||||||
|
Stats: undefined;
|
||||||
Orders: undefined;
|
Orders: undefined;
|
||||||
Users: undefined;
|
Users: undefined;
|
||||||
Products: undefined;
|
Products: undefined;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Modal,
|
Modal,
|
||||||
StatusBar,
|
|
||||||
Alert,
|
Alert,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -16,7 +15,7 @@ import {
|
|||||||
type RouteProp,
|
type RouteProp,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from "react-native-maps";
|
import TomTomMap, { type TomTomMapRef, type TomTomMarker } from "../../components/TomTomMap";
|
||||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -24,13 +23,12 @@ import {
|
|||||||
getCommandItems,
|
getCommandItems,
|
||||||
updateCommandStatus,
|
updateCommandStatus,
|
||||||
validateCommand,
|
validateCommand,
|
||||||
confirmReceptionAdmin,
|
|
||||||
notifyClientToDescend,
|
notifyClientToDescend,
|
||||||
getDeliveryPersonDetails,
|
getDeliveryPersonDetails,
|
||||||
deleteCommandItem,
|
deleteCommandItem,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
|
import { calculateRoute, geocodeAddress } from "../../api/tomtom";
|
||||||
import type { RouteInfo, LatLng } from "../../api/tomtom";
|
import type { RouteInfo, LatLng } from "../../api/tomtom";
|
||||||
import type { AdminStackParamList } from "../../navigation/types";
|
import type { AdminStackParamList } from "../../navigation/types";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
@@ -55,11 +53,12 @@ export default function OrderDetailScreen() {
|
|||||||
const [showItemsModal, setShowItemsModal] = useState(false);
|
const [showItemsModal, setShowItemsModal] = useState(false);
|
||||||
|
|
||||||
// Map / tracking
|
// Map / tracking
|
||||||
const mapRef = useRef<MapView | null>(null);
|
const mapRef = useRef<TomTomMapRef | null>(null);
|
||||||
const fullscreenMapRef = useRef<MapView | null>(null);
|
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
|
||||||
const [mapFullscreen, setMapFullscreen] = useState(false);
|
const [mapFullscreen, setMapFullscreen] = useState(false);
|
||||||
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
|
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
|
||||||
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
|
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
|
||||||
|
const [livreurMarkers, setLivreurMarkers] = useState<TomTomMarker[]>([]);
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
||||||
const [mapLoading, setMapLoading] = useState(false);
|
const [mapLoading, setMapLoading] = useState(false);
|
||||||
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
||||||
@@ -72,7 +71,7 @@ export default function OrderDetailScreen() {
|
|||||||
getCommandItems(orderId),
|
getCommandItems(orderId),
|
||||||
]);
|
]);
|
||||||
setCommand(cmdRes.command);
|
setCommand(cmdRes.command);
|
||||||
setItems(itemsRes.items);
|
setItems(itemsRes.items ?? []);
|
||||||
|
|
||||||
// If livreur assigned, fetch their location and calc route
|
// If livreur assigned, fetch their location and calc route
|
||||||
const cmd = cmdRes.command;
|
const cmd = cmdRes.command;
|
||||||
@@ -99,13 +98,21 @@ export default function OrderDetailScreen() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
|
|
||||||
|
// Rejoue la route sur la carte fullscreen quand elle s'ouvre
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapFullscreen && livreurCoords && destCoords) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fullscreenMapRef.current?.calcRoute(livreurCoords, destCoords);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}, [mapFullscreen]);
|
||||||
|
|
||||||
const loadLivreurRoute = async (
|
const loadLivreurRoute = async (
|
||||||
livreurUsername: string,
|
livreurUsername: string,
|
||||||
deliveryAddress: string,
|
deliveryAddress: string,
|
||||||
) => {
|
) => {
|
||||||
setMapLoading(true);
|
setMapLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get livreur location
|
|
||||||
const details = await getDeliveryPersonDetails(livreurUsername);
|
const details = await getDeliveryPersonDetails(livreurUsername);
|
||||||
const loc = details?.location;
|
const loc = details?.location;
|
||||||
if (!loc?.latitude || !loc?.longitude) {
|
if (!loc?.latitude || !loc?.longitude) {
|
||||||
@@ -113,24 +120,26 @@ export default function OrderDetailScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin: LatLng = {
|
const origin: LatLng = { latitude: loc.latitude, longitude: loc.longitude };
|
||||||
|
setLivreurCoords(origin);
|
||||||
|
setLivreurMarkers([{
|
||||||
|
id: livreurUsername,
|
||||||
latitude: loc.latitude,
|
latitude: loc.latitude,
|
||||||
longitude: loc.longitude,
|
longitude: loc.longitude,
|
||||||
};
|
color: "#22c55e",
|
||||||
setLivreurCoords(origin);
|
label: livreurUsername,
|
||||||
|
description: "Livreur",
|
||||||
|
}]);
|
||||||
|
|
||||||
// Geocode destination
|
// Dessine la route sur la carte et récupère les infos (distance/durée)
|
||||||
const dest = await geocodeAddress(deliveryAddress);
|
const dest = await geocodeAddress(deliveryAddress);
|
||||||
if (!dest) {
|
if (dest) {
|
||||||
setMapLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDestCoords(dest);
|
setDestCoords(dest);
|
||||||
|
|
||||||
// Calculate route
|
|
||||||
const result = await calculateRoute(origin, dest);
|
const result = await calculateRoute(origin, dest);
|
||||||
if (result) {
|
if (result) {
|
||||||
setRouteInfo(result.route);
|
setRouteInfo(result.route);
|
||||||
|
mapRef.current?.calcRoute(origin, dest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* silent */
|
/* silent */
|
||||||
@@ -138,27 +147,6 @@ export default function OrderDetailScreen() {
|
|||||||
setMapLoading(false);
|
setMapLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fitMapToRoute = (ref: React.RefObject<MapView | null>) => {
|
|
||||||
if (ref.current && livreurCoords && destCoords) {
|
|
||||||
ref.current.fitToCoordinates(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
latitude: livreurCoords.latitude,
|
|
||||||
longitude: livreurCoords.longitude,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
latitude: destCoords.latitude,
|
|
||||||
longitude: destCoords.longitude,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
edgePadding: { top: 80, right: 60, bottom: 80, left: 60 },
|
|
||||||
animated: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
try {
|
try {
|
||||||
await validateCommand(orderId);
|
await validateCommand(orderId);
|
||||||
@@ -205,7 +193,7 @@ export default function OrderDetailScreen() {
|
|||||||
try {
|
try {
|
||||||
await deleteCommandItem(orderId, itemId);
|
await deleteCommandItem(orderId, itemId);
|
||||||
const itemsRes = await getCommandItems(orderId);
|
const itemsRes = await getCommandItems(orderId);
|
||||||
setItems(itemsRes.items);
|
setItems(itemsRes.items ?? []);
|
||||||
const cmdRes = await getCommandByID(orderId);
|
const cmdRes = await getCommandByID(orderId);
|
||||||
setCommand(cmdRes.command);
|
setCommand(cmdRes.command);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -217,20 +205,6 @@ export default function OrderDetailScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmReception = async () => {
|
|
||||||
try {
|
|
||||||
const res = await confirmReceptionAdmin(orderId);
|
|
||||||
showSuccess(
|
|
||||||
"Réception confirmée",
|
|
||||||
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
|
|
||||||
);
|
|
||||||
const updated = await getCommandByID(orderId);
|
|
||||||
setCommand(updated.command);
|
|
||||||
} catch (e: any) {
|
|
||||||
showError("Erreur", e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCommand = () => {
|
const handleDeleteCommand = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Supprimer la commande",
|
"Supprimer la commande",
|
||||||
@@ -316,39 +290,6 @@ export default function OrderDetailScreen() {
|
|||||||
},
|
},
|
||||||
map: { width: "100%", height: MAP_HEIGHT },
|
map: { width: "100%", height: MAP_HEIGHT },
|
||||||
|
|
||||||
driverMarkerOuter: {
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: 17,
|
|
||||||
backgroundColor: colors.success + "40",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
driverMarkerInner: {
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 13,
|
|
||||||
backgroundColor: colors.success,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
destMarkerOuter: {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: 15,
|
|
||||||
backgroundColor: colors.danger + "40",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
destMarkerInner: {
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: 11,
|
|
||||||
backgroundColor: colors.danger,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
|
|
||||||
routeOverlay: {
|
routeOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: spacing.s,
|
top: spacing.s,
|
||||||
@@ -409,6 +350,81 @@ export default function OrderDetailScreen() {
|
|||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Grouped items
|
||||||
|
subItemRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
paddingLeft: spacing.m,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: colors.border,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
subItemText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
groupTotalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: spacing.s,
|
||||||
|
paddingTop: spacing.s,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
groupTotalQty: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
groupTotalPrice: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
categoryBadge: {
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
color: colors.accent,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginRight: spacing.s,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
// Category summary
|
||||||
|
categorySummaryCard: {
|
||||||
|
marginTop: spacing.s,
|
||||||
|
},
|
||||||
|
categoryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: spacing.s,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
categoryName: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
categoryQty: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "700",
|
||||||
|
marginRight: spacing.l,
|
||||||
|
},
|
||||||
|
categoryTotal: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "700",
|
||||||
|
minWidth: 70,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
|
||||||
// Items modal
|
// Items modal
|
||||||
itemsModalOverlay: {
|
itemsModalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -476,6 +492,30 @@ export default function OrderDetailScreen() {
|
|||||||
[colors, MAP_HEIGHT],
|
[colors, MAP_HEIGHT],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Groupement des items par product_id
|
||||||
|
const productGroups = (items ?? []).reduce<Record<string, any[]>>(
|
||||||
|
(acc, item) => {
|
||||||
|
const key = String(item.product_id || item.produit);
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const groupedList = Object.values(productGroups);
|
||||||
|
|
||||||
|
// Récapitulatif par catégorie
|
||||||
|
const categoryTotals = (items ?? []).reduce<
|
||||||
|
Record<string, { qty: number; total: number }>
|
||||||
|
>((acc, item) => {
|
||||||
|
const cat = item.category || "Autre";
|
||||||
|
if (!acc[cat]) acc[cat] = { qty: 0, total: 0 };
|
||||||
|
acc[cat].qty += item.quantite ?? 0;
|
||||||
|
acc[cat].total += item.prix ?? 0;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const categoryEntries = Object.entries(categoryTotals);
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner message="Chargement..." />;
|
if (loading) return <LoadingSpinner message="Chargement..." />;
|
||||||
if (!command)
|
if (!command)
|
||||||
return (
|
return (
|
||||||
@@ -496,74 +536,21 @@ export default function OrderDetailScreen() {
|
|||||||
visible={mapFullscreen}
|
visible={mapFullscreen}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setMapFullscreen(false)}
|
onRequestClose={() => setMapFullscreen(false)}
|
||||||
statusBarTranslucent
|
|
||||||
>
|
>
|
||||||
<StatusBar hidden={mapFullscreen} />
|
|
||||||
<View style={styles.fullscreenContainer}>
|
<View style={styles.fullscreenContainer}>
|
||||||
{livreurCoords && (
|
<TomTomMap
|
||||||
<MapView
|
|
||||||
ref={fullscreenMapRef}
|
ref={fullscreenMapRef}
|
||||||
provider={PROVIDER_DEFAULT}
|
|
||||||
style={styles.fullscreenMap}
|
style={styles.fullscreenMap}
|
||||||
initialRegion={{
|
markers={livreurMarkers}
|
||||||
latitude: livreurCoords.latitude,
|
initialCenter={livreurCoords ?? undefined}
|
||||||
longitude: livreurCoords.longitude,
|
initialZoom={14}
|
||||||
latitudeDelta: 0.02,
|
|
||||||
longitudeDelta: 0.02,
|
|
||||||
}}
|
|
||||||
onMapReady={() => fitMapToRoute(fullscreenMapRef)}
|
|
||||||
showsCompass
|
|
||||||
showsScale
|
|
||||||
>
|
|
||||||
<Marker
|
|
||||||
coordinate={livreurCoords}
|
|
||||||
title={command.livreur_assign}
|
|
||||||
>
|
|
||||||
<View style={styles.driverMarkerOuter}>
|
|
||||||
<View style={styles.driverMarkerInner}>
|
|
||||||
<Ionicons
|
|
||||||
name="bicycle"
|
|
||||||
size={14}
|
|
||||||
color={colors.white}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
{destCoords && (
|
|
||||||
<Marker
|
|
||||||
coordinate={destCoords}
|
|
||||||
title="Destination"
|
|
||||||
>
|
|
||||||
<View style={styles.destMarkerOuter}>
|
|
||||||
<View style={styles.destMarkerInner}>
|
|
||||||
<Ionicons
|
|
||||||
name="flag"
|
|
||||||
size={12}
|
|
||||||
color={colors.white}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
)}
|
|
||||||
{routeInfo && routeInfo.coordinates.length > 0 && (
|
|
||||||
<Polyline
|
|
||||||
coordinates={routeInfo.coordinates}
|
|
||||||
strokeColor="#4285F4"
|
|
||||||
strokeWidth={5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</MapView>
|
|
||||||
)}
|
|
||||||
<View style={styles.fullscreenTopBar}>
|
<View style={styles.fullscreenTopBar}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.closeBtn}
|
style={styles.closeBtn}
|
||||||
onPress={() => setMapFullscreen(false)}
|
onPress={() => setMapFullscreen(false)}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="close" size={24} color={colors.white} />
|
||||||
name="close"
|
|
||||||
size={24}
|
|
||||||
color={colors.white}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.fullscreenTitle}>
|
<Text style={styles.fullscreenTitle}>
|
||||||
{routeInfo
|
{routeInfo
|
||||||
@@ -584,12 +571,30 @@ export default function OrderDetailScreen() {
|
|||||||
<Text style={styles.info}>Client: {command.username}</Text>
|
<Text style={styles.info}>Client: {command.username}</Text>
|
||||||
<Text style={styles.info}>Adresse: {command.adresse}</Text>
|
<Text style={styles.info}>Adresse: {command.adresse}</Text>
|
||||||
<Text style={styles.info}>
|
<Text style={styles.info}>
|
||||||
Total: {command.total_prix?.toFixed(2)} €
|
Total brut: {command.total_prix?.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
{command.referral_used > 0 && (
|
{command.referral_used > 0 && (
|
||||||
|
<>
|
||||||
<Text style={[styles.info, { color: colors.success }]}>
|
<Text style={[styles.info, { color: colors.success }]}>
|
||||||
Parrainage utilisé: -{command.referral_used?.toFixed(2)} €
|
Parrainage utilisé: -
|
||||||
|
{command.referral_used?.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.info,
|
||||||
|
{
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Net:{" "}
|
||||||
|
{(
|
||||||
|
command.total_prix - command.referral_used
|
||||||
|
).toFixed(2)}{" "}
|
||||||
|
€
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{command.livreur_assign && (
|
{command.livreur_assign && (
|
||||||
<Text style={styles.info}>
|
<Text style={styles.info}>
|
||||||
@@ -600,29 +605,53 @@ export default function OrderDetailScreen() {
|
|||||||
{new Date(command.created_at).toLocaleString("fr-FR")}
|
{new Date(command.created_at).toLocaleString("fr-FR")}
|
||||||
</Text>
|
</Text>
|
||||||
{command.status === "cancelled" && command.cancel_reason ? (
|
{command.status === "cancelled" && command.cancel_reason ? (
|
||||||
<View style={{
|
<View
|
||||||
|
style={{
|
||||||
marginTop: spacing.m,
|
marginTop: spacing.m,
|
||||||
padding: spacing.m,
|
padding: spacing.m,
|
||||||
backgroundColor: colors.danger + "18",
|
backgroundColor: colors.danger + "18",
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: borderRadius.sm,
|
||||||
borderLeftWidth: 3,
|
borderLeftWidth: 3,
|
||||||
borderLeftColor: colors.danger,
|
borderLeftColor: colors.danger,
|
||||||
}}>
|
}}
|
||||||
<Text style={{ color: colors.danger, fontSize: fontSize.xs, fontWeight: "700", marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontWeight: "700",
|
||||||
|
marginBottom: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Motif d'annulation
|
Motif d'annulation
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ color: colors.textSecondary, fontSize: fontSize.sm }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{command.cancel_reason}
|
{command.cancel_reason}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : command.status === "cancelled" ? (
|
) : command.status === "cancelled" ? (
|
||||||
<View style={{
|
<View
|
||||||
|
style={{
|
||||||
marginTop: spacing.m,
|
marginTop: spacing.m,
|
||||||
padding: spacing.m,
|
padding: spacing.m,
|
||||||
backgroundColor: colors.bgSecondary,
|
backgroundColor: colors.bgSecondary,
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: borderRadius.sm,
|
||||||
}}>
|
}}
|
||||||
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, fontStyle: "italic" }}>
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Aucun motif fourni
|
Aucun motif fourni
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -635,69 +664,21 @@ export default function OrderDetailScreen() {
|
|||||||
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
|
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
|
||||||
{hasMap ? (
|
{hasMap ? (
|
||||||
<View style={styles.mapContainer}>
|
<View style={styles.mapContainer}>
|
||||||
<MapView
|
<TomTomMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
provider={PROVIDER_DEFAULT}
|
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
initialRegion={{
|
markers={livreurMarkers}
|
||||||
latitude: livreurCoords!.latitude,
|
initialCenter={livreurCoords ?? undefined}
|
||||||
longitude: livreurCoords!.longitude,
|
initialZoom={14}
|
||||||
latitudeDelta: 0.02,
|
|
||||||
longitudeDelta: 0.02,
|
|
||||||
}}
|
|
||||||
onMapReady={() => fitMapToRoute(mapRef)}
|
|
||||||
>
|
|
||||||
<Marker
|
|
||||||
coordinate={livreurCoords!}
|
|
||||||
title={command.livreur_assign}
|
|
||||||
>
|
|
||||||
<View style={styles.driverMarkerOuter}>
|
|
||||||
<View style={styles.driverMarkerInner}>
|
|
||||||
<Ionicons
|
|
||||||
name="bicycle"
|
|
||||||
size={14}
|
|
||||||
color={colors.white}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
{destCoords && (
|
|
||||||
<Marker
|
|
||||||
coordinate={destCoords}
|
|
||||||
title="Destination"
|
|
||||||
>
|
|
||||||
<View style={styles.destMarkerOuter}>
|
|
||||||
<View
|
|
||||||
style={styles.destMarkerInner}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="flag"
|
|
||||||
size={12}
|
|
||||||
color={colors.white}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
)}
|
|
||||||
{routeInfo &&
|
|
||||||
routeInfo.coordinates.length > 0 && (
|
|
||||||
<Polyline
|
|
||||||
coordinates={routeInfo.coordinates}
|
|
||||||
strokeColor="#4285F4"
|
|
||||||
strokeWidth={4}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</MapView>
|
|
||||||
|
|
||||||
{/* Route info overlay */}
|
|
||||||
{routeInfo && (
|
{routeInfo && (
|
||||||
<View style={styles.routeOverlay}>
|
<View style={styles.routeOverlay}>
|
||||||
<Text style={styles.routeOverlayUser}>
|
<Text style={styles.routeOverlayUser}>
|
||||||
{command.livreur_assign}
|
{command.livreur_assign}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.routeOverlayInfo}>
|
<Text style={styles.routeOverlayInfo}>
|
||||||
{routeInfo.distance} ·{" "}
|
{routeInfo.distance} · {routeInfo.duration}
|
||||||
{routeInfo.duration}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -742,22 +723,38 @@ export default function OrderDetailScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items groupés par produit */}
|
||||||
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
|
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
|
||||||
{items.map((item: any) => (
|
{groupedList.map((group, gi) => {
|
||||||
<Card key={item.id} style={{ marginBottom: spacing.s }}>
|
const rep = group[0];
|
||||||
|
const name = rep.produit ?? rep.product_name;
|
||||||
|
const unit = rep.unit || "";
|
||||||
|
const totalQty = group.reduce(
|
||||||
|
(s: number, it: any) => s + (it.quantite ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalPrice = group.reduce(
|
||||||
|
(s: number, it: any) => s + (it.prix ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const isMultiple = group.length > 1;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`${rep.product_id}-${gi}`}
|
||||||
|
style={{ marginBottom: spacing.s }}
|
||||||
|
>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={[styles.itemName, { flex: 1 }]}>
|
<Text style={[styles.itemName, { flex: 1 }]}>
|
||||||
{item.produit ?? item.product_name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{rep.category ? (
|
||||||
|
<Text style={styles.categoryBadge}>
|
||||||
|
{rep.category}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.itemDeleteBtn}
|
style={styles.itemDeleteBtn}
|
||||||
onPress={() =>
|
onPress={() => handleDeleteItem(rep.id, name)}
|
||||||
handleDeleteItem(
|
|
||||||
item.id,
|
|
||||||
item.produit ?? item.product_name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="trash-outline"
|
name="trash-outline"
|
||||||
@@ -766,16 +763,61 @@ export default function OrderDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.itemDetails}>
|
{isMultiple &&
|
||||||
<Text style={styles.info}>
|
group.map((item: any, i: number) => (
|
||||||
Quantité: {item.quantite}
|
<View key={item.id} style={styles.subItemRow}>
|
||||||
|
<Text style={styles.subItemText}>
|
||||||
|
{item.quantite}
|
||||||
|
{unit} — {item.prix?.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.info}>
|
<TouchableOpacity
|
||||||
Prix: {item.prix?.toFixed(2)} €
|
onPress={() =>
|
||||||
|
handleDeleteItem(item.id, name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="remove-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={styles.groupTotalRow}>
|
||||||
|
<Text style={styles.groupTotalQty}>
|
||||||
|
{isMultiple
|
||||||
|
? `Total: ${totalQty}${unit}`
|
||||||
|
: `${totalQty}${unit}`}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.groupTotalPrice}>
|
||||||
|
{totalPrice.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Récapitulatif par catégorie */}
|
||||||
|
{categoryEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>Par catégorie</Text>
|
||||||
|
<Card style={styles.categorySummaryCard}>
|
||||||
|
{categoryEntries.map(([cat, data]) => (
|
||||||
|
<View key={cat} style={styles.categoryRow}>
|
||||||
|
<Text style={styles.categoryName}>{cat}</Text>
|
||||||
|
<Text style={styles.categoryQty}>
|
||||||
|
{data.qty.toFixed(
|
||||||
|
data.qty % 1 === 0 ? 0 : 2,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.categoryTotal}>
|
||||||
|
{data.total.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Text style={styles.sectionTitle}>Actions</Text>
|
<Text style={styles.sectionTitle}>Actions</Text>
|
||||||
@@ -823,15 +865,7 @@ export default function OrderDetailScreen() {
|
|||||||
style={{ marginTop: spacing.s }}
|
style={{ marginTop: spacing.s }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!["approved", "cancelled"].includes(command.status) && (
|
|
||||||
<Button
|
|
||||||
title="Confirmer la commande"
|
|
||||||
onPress={handleConfirmReception}
|
|
||||||
variant="primary"
|
|
||||||
fullWidth
|
|
||||||
style={{ marginTop: spacing.s }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
title="Supprimer la commande"
|
title="Supprimer la commande"
|
||||||
onPress={handleDeleteCommand}
|
onPress={handleDeleteCommand}
|
||||||
@@ -865,9 +899,23 @@ export default function OrderDetailScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{items.map((item: any) => (
|
{groupedList.map((group, gi) => {
|
||||||
|
const rep = group[0];
|
||||||
|
const name = rep.produit ?? rep.product_name;
|
||||||
|
const unit = rep.unit || "";
|
||||||
|
const totalQty = group.reduce(
|
||||||
|
(s: number, it: any) =>
|
||||||
|
s + (it.quantite ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalPrice = group.reduce(
|
||||||
|
(s: number, it: any) => s + (it.prix ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const isMultiple = group.length > 1;
|
||||||
|
return (
|
||||||
<View
|
<View
|
||||||
key={item.id}
|
key={`modal-${rep.product_id}-${gi}`}
|
||||||
style={styles.itemsModalCard}
|
style={styles.itemsModalCard}
|
||||||
>
|
>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
@@ -877,16 +925,22 @@ export default function OrderDetailScreen() {
|
|||||||
{ flex: 1 },
|
{ flex: 1 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{item.produit ?? item.product_name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{rep.category ? (
|
||||||
|
<Text
|
||||||
|
style={styles.categoryBadge}
|
||||||
|
>
|
||||||
|
{rep.category}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.itemDeleteBtn}
|
style={styles.itemDeleteBtn}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowItemsModal(false);
|
setShowItemsModal(false);
|
||||||
handleDeleteItem(
|
handleDeleteItem(
|
||||||
item.id,
|
rep.id,
|
||||||
item.produit ??
|
name,
|
||||||
item.product_name,
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -897,16 +951,80 @@ export default function OrderDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.itemDetails}>
|
{isMultiple &&
|
||||||
<Text style={styles.info}>
|
group.map((item: any) => (
|
||||||
Quantité: {item.quantite}
|
<View
|
||||||
|
key={item.id}
|
||||||
|
style={styles.subItemRow}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={
|
||||||
|
styles.subItemText
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.quantite}
|
||||||
|
{unit} —{" "}
|
||||||
|
{item.prix?.toFixed(2)}{" "}
|
||||||
|
€
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.info}>
|
|
||||||
Prix: {item.prix?.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
<View style={styles.groupTotalRow}>
|
||||||
|
<Text style={styles.groupTotalQty}>
|
||||||
|
{isMultiple
|
||||||
|
? `Total: ${totalQty}${unit}`
|
||||||
|
: `${totalQty}${unit}`}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.groupTotalPrice}
|
||||||
|
>
|
||||||
|
{totalPrice.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Récapitulatif catégories dans le modal */}
|
||||||
|
{categoryEntries.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: spacing.m,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
paddingTop: spacing.m,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.itemsModalTitle,
|
||||||
|
{
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Par catégorie
|
||||||
|
</Text>
|
||||||
|
{categoryEntries.map(([cat, data]) => (
|
||||||
|
<View
|
||||||
|
key={cat}
|
||||||
|
style={styles.categoryRow}
|
||||||
|
>
|
||||||
|
<Text style={styles.categoryName}>
|
||||||
|
{cat}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.categoryQty}>
|
||||||
|
{data.qty.toFixed(
|
||||||
|
data.qty % 1 === 0 ? 0 : 2,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.categoryTotal}>
|
||||||
|
{data.total.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<Text style={styles.empty}>Aucun article</Text>
|
<Text style={styles.empty}>Aucun article</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
updateCommandStatus,
|
updateCommandStatus,
|
||||||
getCommandItems,
|
getCommandItems,
|
||||||
notifyClientToDescend,
|
notifyClientToDescend,
|
||||||
confirmReceptionAdmin,
|
|
||||||
|
validateCommand,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
deleteCommandItem,
|
deleteCommandItem,
|
||||||
proposeAddressChangeAdmin,
|
proposeAddressChangeAdmin,
|
||||||
@@ -220,23 +221,23 @@ export default function OrdersScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmReception = (commandId: number) => {
|
const handleForceValidate = (commandId: number) => {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
"Confirmer la commande",
|
"Finaliser la commande",
|
||||||
`Confirmer la réception de la commande #${commandId} ?`,
|
`Finaliser définitivement la commande #${commandId} et attribuer les points au client ?`,
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const res = await confirmReceptionAdmin(commandId);
|
const res = await validateCommand(commandId);
|
||||||
showSuccess(
|
showSuccess(
|
||||||
"Réception confirmée",
|
"Commande finalisée",
|
||||||
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
|
`${res.points_awarded} point(s) attribués au client`,
|
||||||
);
|
);
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showError("Erreur", e.message);
|
showError("Erreur", e.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Confirmer",
|
"Finaliser",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -443,6 +444,11 @@ export default function OrdersScreen() {
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
fontSize: fontSize.xs,
|
fontSize: fontSize.xs,
|
||||||
},
|
},
|
||||||
|
livreurCancelBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
proposedAddressBox: {
|
proposedAddressBox: {
|
||||||
backgroundColor: colors.bgPrimary,
|
backgroundColor: colors.bgPrimary,
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: borderRadius.sm,
|
||||||
@@ -642,14 +648,21 @@ export default function OrdersScreen() {
|
|||||||
|
|
||||||
{/* Total + parrainage */}
|
{/* Total + parrainage */}
|
||||||
<Text style={styles.cardText}>
|
<Text style={styles.cardText}>
|
||||||
<Text style={{ color: colors.textMuted }}>Total : </Text>
|
<Text style={{ color: colors.textMuted }}>
|
||||||
|
{(item.referral_used ?? 0) > 0 ? "Total brut : " : "Total : "}
|
||||||
|
</Text>
|
||||||
{item.total_prix.toFixed(2)} €
|
{item.total_prix.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
{(item.referral_used ?? 0) > 0 && (
|
{(item.referral_used ?? 0) > 0 && (
|
||||||
<Text style={{ color: colors.accent }}>
|
<>
|
||||||
{" "}(parrainage -{item.referral_used!.toFixed(2)} €)
|
<Text style={[styles.cardText, { color: colors.success }]}>
|
||||||
|
Parrainage : -{item.referral_used!.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={[styles.cardText, { fontWeight: "700" }]}>
|
||||||
|
Net : {(item.total_prix - item.referral_used!).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Livreur */}
|
{/* Livreur */}
|
||||||
{!!item.livreur_assign && (
|
{!!item.livreur_assign && (
|
||||||
@@ -662,8 +675,18 @@ export default function OrdersScreen() {
|
|||||||
{/* Raison annulation */}
|
{/* Raison annulation */}
|
||||||
{isCancelled && !!item.cancel_reason && (
|
{isCancelled && !!item.cancel_reason && (
|
||||||
<View style={styles.cancelReasonBox}>
|
<View style={styles.cancelReasonBox}>
|
||||||
|
{item.cancel_reason.startsWith("[Livreur:") && (
|
||||||
|
<View style={styles.livreurCancelBadge}>
|
||||||
|
<Ionicons name="bicycle-outline" size={12} color={colors.danger} />
|
||||||
|
<Text style={[styles.cancelReasonText, { color: colors.danger, fontWeight: "600" }]}>
|
||||||
|
{" "}Annulé par le livreur
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<Text style={styles.cancelReasonText}>
|
<Text style={styles.cancelReasonText}>
|
||||||
Raison : {item.cancel_reason}
|
{item.cancel_reason.startsWith("[Livreur:")
|
||||||
|
? item.cancel_reason.replace(/^\[Livreur: [^\]]+\] /, "")
|
||||||
|
: item.cancel_reason}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -751,14 +774,15 @@ export default function OrdersScreen() {
|
|||||||
},
|
},
|
||||||
condition: item.status === "assigned",
|
condition: item.status === "assigned",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
label: "Confirmer réception",
|
label: "Finaliser commande",
|
||||||
icon: "checkmark-circle-outline" as keyof typeof Ionicons.glyphMap,
|
icon: "trophy-outline" as keyof typeof Ionicons.glyphMap,
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
handleConfirmReception(item.id);
|
handleForceValidate(item.id);
|
||||||
},
|
},
|
||||||
condition: item.status === "livre",
|
condition: !isDone && item.status !== "livre",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Supprimer",
|
label: "Supprimer",
|
||||||
@@ -787,8 +811,18 @@ export default function OrdersScreen() {
|
|||||||
<Text style={styles.cardText}>Client: {item.username}</Text>
|
<Text style={styles.cardText}>Client: {item.username}</Text>
|
||||||
<Text style={styles.cardText}>Adresse: {item.adresse}</Text>
|
<Text style={styles.cardText}>Adresse: {item.adresse}</Text>
|
||||||
<Text style={styles.cardText}>
|
<Text style={styles.cardText}>
|
||||||
Total: {item.total_prix.toFixed(2)} €
|
{(item.referral_used ?? 0) > 0 ? "Total brut" : "Total"}: {item.total_prix.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
|
{(item.referral_used ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.cardText, { color: colors.success }]}>
|
||||||
|
Parrainage: -{(item.referral_used ?? 0).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cardText, { fontWeight: "700" }]}>
|
||||||
|
Net: {(item.total_prix - (item.referral_used ?? 0)).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{item.livreur_assign && (
|
{item.livreur_assign && (
|
||||||
<Text style={styles.cardText}>
|
<Text style={styles.cardText}>
|
||||||
Livreur: {item.livreur_assign}
|
Livreur: {item.livreur_assign}
|
||||||
@@ -1096,12 +1130,22 @@ export default function OrdersScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{itemsModal.commandInfo?.total_prix != null && (
|
{itemsModal.commandInfo?.total_prix != null && (
|
||||||
|
<>
|
||||||
<Text style={styles.modalTotal}>
|
<Text style={styles.modalTotal}>
|
||||||
{Number(
|
{(itemsModal.commandInfo.referral_used ?? 0) > 0 ? "Brut : " : ""}
|
||||||
itemsModal.commandInfo.total_prix,
|
{Number(itemsModal.commandInfo.total_prix).toFixed(2)} €
|
||||||
).toFixed(2)}{" "}
|
|
||||||
€
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{(itemsModal.commandInfo.referral_used ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.modalTotal, { color: colors.success, fontSize: 13 }]}>
|
||||||
|
Parrainage : -{Number(itemsModal.commandInfo.referral_used).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.modalTotal, { fontWeight: "700" }]}>
|
||||||
|
Net : {(Number(itemsModal.commandInfo.total_prix) - Number(itemsModal.commandInfo.referral_used)).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
deleteProductAdmin,
|
deleteProductAdmin,
|
||||||
uploadProductMediaAdmin,
|
uploadProductMediaAdmin,
|
||||||
deleteProductMediaAdmin,
|
deleteProductMediaAdmin,
|
||||||
|
activateProductPrice,
|
||||||
|
deactivateProductPrice,
|
||||||
getCategories,
|
getCategories,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
import type { Category } from "../../api/api_admin";
|
import type { Category } from "../../api/api_admin";
|
||||||
@@ -40,8 +42,10 @@ import { useAlert } from "../../hooks/useAlert";
|
|||||||
// Types
|
// Types
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
interface PriceRow {
|
interface PriceRow {
|
||||||
|
id?: number;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
price: string;
|
price: string;
|
||||||
|
active: boolean;
|
||||||
}
|
}
|
||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -74,6 +78,7 @@ interface FormState {
|
|||||||
stock: string;
|
stock: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
prices: PriceRow[];
|
prices: PriceRow[];
|
||||||
|
comingSoon: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm = (firstCategory = ""): FormState => ({
|
const emptyForm = (firstCategory = ""): FormState => ({
|
||||||
@@ -82,7 +87,8 @@ const emptyForm = (firstCategory = ""): FormState => ({
|
|||||||
description: "",
|
description: "",
|
||||||
stock: "",
|
stock: "",
|
||||||
unit: "u",
|
unit: "u",
|
||||||
prices: [{ quantity: "1", price: "" }],
|
prices: [{ quantity: "1", price: "", active: true }],
|
||||||
|
comingSoon: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================================================
|
// ==================================================
|
||||||
@@ -159,13 +165,16 @@ export default function ProductsScreen() {
|
|||||||
description: product.description || "",
|
description: product.description || "",
|
||||||
stock: product.stock.toString(),
|
stock: product.stock.toString(),
|
||||||
unit: product.unit || "u",
|
unit: product.unit || "u",
|
||||||
|
comingSoon: product.coming_soon ?? false,
|
||||||
prices:
|
prices:
|
||||||
product.prices && product.prices.length > 0
|
product.prices && product.prices.length > 0
|
||||||
? product.prices.map((p) => ({
|
? product.prices.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
quantity: p.quantity.toString(),
|
quantity: p.quantity.toString(),
|
||||||
price: p.price.toString(),
|
price: p.price.toString(),
|
||||||
|
active: p.active_price ?? true,
|
||||||
}))
|
}))
|
||||||
: [{ quantity: "1", price: "" }],
|
: [{ quantity: "1", price: "", active: true }],
|
||||||
});
|
});
|
||||||
setExistingMedia(
|
setExistingMedia(
|
||||||
(product.media || []).map((m) => ({
|
(product.media || []).map((m) => ({
|
||||||
@@ -191,13 +200,27 @@ export default function ProductsScreen() {
|
|||||||
const addPriceRow = () =>
|
const addPriceRow = () =>
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
prices: [...f.prices, { quantity: "1", price: "" }],
|
prices: [...f.prices, { quantity: "1", price: "", active: true }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const togglePriceActive = (idx: number) =>
|
||||||
|
setForm((f) => {
|
||||||
|
const prices = [...f.prices];
|
||||||
|
prices[idx] = { ...prices[idx], active: !prices[idx].active };
|
||||||
|
return { ...f, prices };
|
||||||
|
});
|
||||||
const removePriceRow = (idx: number) =>
|
const removePriceRow = (idx: number) =>
|
||||||
setForm((f) => ({
|
setForm((f) => {
|
||||||
...f,
|
const target = f.prices[idx];
|
||||||
prices: f.prices.filter((_, i) => i !== idx),
|
if (target.id) {
|
||||||
}));
|
// Prix existant en DB → désactiver au lieu de supprimer
|
||||||
|
const prices = [...f.prices];
|
||||||
|
prices[idx] = { ...target, active: false };
|
||||||
|
return { ...f, prices };
|
||||||
|
}
|
||||||
|
// Nouveau prix jamais sauvegardé → supprimer de la liste
|
||||||
|
return { ...f, prices: f.prices.filter((_, i) => i !== idx) };
|
||||||
|
});
|
||||||
const updatePriceField = (
|
const updatePriceField = (
|
||||||
idx: number,
|
idx: number,
|
||||||
field: "quantity" | "price",
|
field: "quantity" | "price",
|
||||||
@@ -292,6 +315,7 @@ export default function ProductsScreen() {
|
|||||||
const prices = form.prices.map((p) => ({
|
const prices = form.prices.map((p) => ({
|
||||||
quantity: parseFloat(p.quantity),
|
quantity: parseFloat(p.quantity),
|
||||||
price: parseFloat(p.price),
|
price: parseFloat(p.price),
|
||||||
|
active_price: p.active,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -318,6 +342,7 @@ export default function ProductsScreen() {
|
|||||||
stock: parseFloat(form.stock),
|
stock: parseFloat(form.stock),
|
||||||
unit: form.unit,
|
unit: form.unit,
|
||||||
prices,
|
prices,
|
||||||
|
coming_soon: form.comingSoon,
|
||||||
});
|
});
|
||||||
productId = editingProduct.id;
|
productId = editingProduct.id;
|
||||||
} else {
|
} else {
|
||||||
@@ -328,12 +353,12 @@ export default function ProductsScreen() {
|
|||||||
fd.append("description", form.description.trim());
|
fd.append("description", form.description.trim());
|
||||||
fd.append("stock", form.stock);
|
fd.append("stock", form.stock);
|
||||||
fd.append("unit", form.unit);
|
fd.append("unit", form.unit);
|
||||||
|
fd.append("coming_soon", form.comingSoon ? "true" : "false");
|
||||||
|
|
||||||
// ✅ FIX PRINCIPAL : Envoyer les prix au format que Go attend
|
|
||||||
// Backend attend : prices[0][quantity], prices[0][price], etc.
|
|
||||||
prices.forEach((p, i) => {
|
prices.forEach((p, i) => {
|
||||||
fd.append(`prices[${i}][quantity]`, String(p.quantity));
|
fd.append(`prices[${i}][quantity]`, String(p.quantity));
|
||||||
fd.append(`prices[${i}][price]`, String(p.price));
|
fd.append(`prices[${i}][price]`, String(p.price));
|
||||||
|
fd.append(`prices[${i}][active_price]`, p.active_price ? "true" : "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attacher les médias en attente
|
// Attacher les médias en attente
|
||||||
@@ -607,6 +632,22 @@ export default function ProductsScreen() {
|
|||||||
catBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
catBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||||
catBtnTextActive: { color: colors.accent, fontWeight: "600" },
|
catBtnTextActive: { color: colors.accent, fontWeight: "600" },
|
||||||
|
|
||||||
|
comingSoonBtn: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.textMuted,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
marginBottom: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
comingSoonBtnActive: {
|
||||||
|
borderColor: "#22c55e",
|
||||||
|
backgroundColor: "#22c55e20",
|
||||||
|
},
|
||||||
|
comingSoonBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||||
|
comingSoonBtnTextActive: { color: "#22c55e", fontWeight: "700" },
|
||||||
|
|
||||||
// Prices
|
// Prices
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -647,6 +688,7 @@ export default function ProductsScreen() {
|
|||||||
paddingHorizontal: spacing.m,
|
paddingHorizontal: spacing.m,
|
||||||
},
|
},
|
||||||
removePriceBtn: { padding: spacing.s, marginBottom: 4 },
|
removePriceBtn: { padding: spacing.s, marginBottom: 4 },
|
||||||
|
toggleActiveBtn: { padding: spacing.s, marginBottom: 4 },
|
||||||
|
|
||||||
// Media section
|
// Media section
|
||||||
mediaSectionBox: { marginTop: spacing.m },
|
mediaSectionBox: { marginTop: spacing.m },
|
||||||
@@ -803,11 +845,24 @@ export default function ProductsScreen() {
|
|||||||
)}
|
)}
|
||||||
<Text style={styles.info}>Stock: {item.stock} {item.unit || "u"}</Text>
|
<Text style={styles.info}>Stock: {item.stock} {item.unit || "u"}</Text>
|
||||||
{item.prices && item.prices.length > 0 && (
|
{item.prices && item.prices.length > 0 && (
|
||||||
<Text style={styles.info}>
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginTop: 2 }}>
|
||||||
{item.prices
|
{item.prices.map((p, i) => (
|
||||||
.map((p) => `${p.quantity}${item.unit || "u"} = ${p.price}€`)
|
<Text
|
||||||
.join(" | ")}
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.info,
|
||||||
|
p.active_price === false && {
|
||||||
|
textDecorationLine: "line-through",
|
||||||
|
color: colors.textMuted,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.quantity}{item.unit || "u"} = {p.price}€
|
||||||
|
{i < item.prices!.length - 1 ? " |" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<Text style={styles.desc} numberOfLines={2}>
|
<Text style={styles.desc} numberOfLines={2}>
|
||||||
@@ -965,6 +1020,31 @@ export default function ProductsScreen() {
|
|||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* À venir */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.comingSoonBtn,
|
||||||
|
form.comingSoon && styles.comingSoonBtnActive,
|
||||||
|
]}
|
||||||
|
onPress={() =>
|
||||||
|
setForm((f) => {
|
||||||
|
const next = !f.comingSoon;
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
comingSoon: next,
|
||||||
|
prices: f.prices.map((p) => ({ ...p, active: !next })),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.comingSoonBtnText,
|
||||||
|
form.comingSoon && styles.comingSoonBtnTextActive,
|
||||||
|
]}>
|
||||||
|
{form.comingSoon ? "À venir (activé)" : "Marquer comme «À venir»"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Unité de mesure */}
|
{/* Unité de mesure */}
|
||||||
<Text style={styles.label}>Unité de mesure *</Text>
|
<Text style={styles.label}>Unité de mesure *</Text>
|
||||||
<View style={styles.catRow}>
|
<View style={styles.catRow}>
|
||||||
@@ -1055,6 +1135,16 @@ export default function ProductsScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.toggleActiveBtn}
|
||||||
|
onPress={() => togglePriceActive(idx)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={p.active ? "checkmark-circle" : "close-circle"}
|
||||||
|
size={22}
|
||||||
|
color={p.active ? colors.success || "#22c55e" : colors.danger}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
{form.prices.length > 1 && (
|
{form.prices.length > 1 && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.removePriceBtn}
|
style={styles.removePriceBtn}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { useNavigation } from "@react-navigation/native";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
||||||
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, RewardItem, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||||
|
import type { Product } from "../../api/types";
|
||||||
import AlertModal from "../../components/ui/AlertModal";
|
import AlertModal from "../../components/ui/AlertModal";
|
||||||
import { useAlert } from "../../hooks/useAlert";
|
import { useAlert } from "../../hooks/useAlert";
|
||||||
|
|
||||||
@@ -45,6 +46,51 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [
|
|||||||
{ key: "sunday", label: "Dimanche" },
|
{ key: "sunday", label: "Dimanche" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Composant section accordéon générique
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
function AccordionSection({
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
colors,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
colors: any;
|
||||||
|
s: any;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<View style={s.section}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setOpen((o) => !o)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.l,
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[s.sectionTitle, { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 }]}>{title}</Text>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||||
|
{badge}
|
||||||
|
<Ionicons name={open ? "chevron-up" : "chevron-down"} size={18} color={colors.textMuted} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{open && (
|
||||||
|
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DeliveryScheduleSection({
|
function DeliveryScheduleSection({
|
||||||
schedule,
|
schedule,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -62,8 +108,7 @@ function DeliveryScheduleSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.section}>
|
<AccordionSection title="Horaires de livraison" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Horaires de livraison</Text>
|
|
||||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||||
Définissez les jours et horaires d'ouverture pour la livraison.
|
Définissez les jours et horaires d'ouverture pour la livraison.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -132,7 +177,7 @@ function DeliveryScheduleSection({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</AccordionSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,8 +244,7 @@ function PostalZonesSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.section}>
|
<AccordionSection title="Zones de livraison" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Zones de livraison</Text>
|
|
||||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||||
Définissez les zones de livraison et leur montant minimum de commande.
|
Définissez les zones de livraison et leur montant minimum de commande.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -435,7 +479,7 @@ function PostalZonesSection({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</AccordionSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,24 +590,20 @@ function TiersSection({
|
|||||||
onChange(tiers.filter((_, idx) => idx !== i));
|
onChange(tiers.filter((_, idx) => idx !== i));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const badge = (
|
||||||
<View style={s.section}>
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
|
|
||||||
<Text style={s.sectionTitle}>{title}</Text>
|
|
||||||
<View style={{
|
<View style={{
|
||||||
paddingHorizontal: spacing.s,
|
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: active ? accentColor + "25" : colors.border + "40",
|
backgroundColor: active ? accentColor + "25" : colors.border + "40",
|
||||||
borderWidth: 1,
|
borderWidth: 1, borderColor: active ? accentColor : colors.border,
|
||||||
borderColor: active ? accentColor : colors.border,
|
|
||||||
marginBottom: spacing.s,
|
|
||||||
}}>
|
}}>
|
||||||
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
|
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
|
||||||
{active ? "Actif" : "Ignoré"}
|
{active ? "Actif" : "Ignoré"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionSection title={title} badge={badge} colors={colors} s={s}>
|
||||||
<Text style={s.hint}>
|
<Text style={s.hint}>
|
||||||
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
|
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
|
||||||
Max = 0 signifie illimité (pas de borne supérieure).
|
Max = 0 signifie illimité (pas de borne supérieure).
|
||||||
@@ -639,7 +679,479 @@ function TiersSection({
|
|||||||
Ajouter un palier
|
Ajouter un palier
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</AccordionSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REWARD_ACCENT = "#f59e0b";
|
||||||
|
|
||||||
|
const REWARD_TYPES: { value: PointsReward["type"]; label: string; icon: string }[] = [
|
||||||
|
{ value: "free_product", label: "Produit offert", icon: "gift-outline" },
|
||||||
|
{ value: "half_price_product", label: "Produit à -50%", icon: "pricetag-outline" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_REWARD: PointsReward = {
|
||||||
|
threshold: 20,
|
||||||
|
type: "free_product",
|
||||||
|
description: "",
|
||||||
|
category_configs: [],
|
||||||
|
reward_items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Sélecteur de produits pour une catégorie dans la récompense
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
function CategoryProductPicker({
|
||||||
|
catConfig,
|
||||||
|
products,
|
||||||
|
onChange,
|
||||||
|
colors,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
catConfig: RewardCategoryConfig;
|
||||||
|
products: Product[];
|
||||||
|
onChange: (cfg: RewardCategoryConfig) => void;
|
||||||
|
colors: any;
|
||||||
|
s: any;
|
||||||
|
}) {
|
||||||
|
const catProducts = products.filter((p) => p.category === catConfig.category);
|
||||||
|
|
||||||
|
const toggleProduct = (id: number) => {
|
||||||
|
const ids = catConfig.product_ids.includes(id)
|
||||||
|
? catConfig.product_ids.filter((x) => x !== id)
|
||||||
|
: [...catConfig.product_ids, id];
|
||||||
|
onChange({ ...catConfig, product_ids: ids, all_products: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
||||||
|
{/* Toggle tous / sélection */}
|
||||||
|
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onChange({ ...catConfig, all_products: true, product_ids: [] })}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||||
|
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||||
|
borderColor: catConfig.all_products ? REWARD_ACCENT : colors.border,
|
||||||
|
backgroundColor: catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
|
||||||
|
Tous ({catProducts.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onChange({ ...catConfig, all_products: false })}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||||
|
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||||
|
borderColor: !catConfig.all_products ? REWARD_ACCENT : colors.border,
|
||||||
|
backgroundColor: !catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
|
||||||
|
Sélection
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Liste des produits si mode sélection */}
|
||||||
|
{!catConfig.all_products && (
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||||
|
{catProducts.length === 0 ? (
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
|
||||||
|
Aucun produit dans cette catégorie
|
||||||
|
</Text>
|
||||||
|
) : catProducts.map((p) => {
|
||||||
|
const sel = catConfig.product_ids.includes(p.id);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p.id}
|
||||||
|
onPress={() => toggleProduct(p.id)}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||||
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||||
|
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||||
|
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
|
||||||
|
flexDirection: "row", alignItems: "center", gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||||
|
{p.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Section centralisée récompenses par palier
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
function CentralRewardSection({
|
||||||
|
reward,
|
||||||
|
allCategories,
|
||||||
|
productsByCategory,
|
||||||
|
onChange,
|
||||||
|
colors,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
reward: PointsReward | null | undefined;
|
||||||
|
allCategories: Category[];
|
||||||
|
productsByCategory: Record<string, Product[]>;
|
||||||
|
onChange: (reward: PointsReward | null) => void;
|
||||||
|
colors: any;
|
||||||
|
s: any;
|
||||||
|
}) {
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const enabled = !!reward;
|
||||||
|
const rawR = reward ?? EMPTY_REWARD;
|
||||||
|
const r: PointsReward = { ...rawR, category_configs: rawR.category_configs ?? [] };
|
||||||
|
|
||||||
|
const update = (patch: Partial<PointsReward>) =>
|
||||||
|
onChange({ ...r, ...patch });
|
||||||
|
|
||||||
|
const getCatConfig = (catName: string): RewardCategoryConfig =>
|
||||||
|
r.category_configs.find((c) => c.category === catName) ??
|
||||||
|
{ category: catName, all_products: true, product_ids: [] };
|
||||||
|
|
||||||
|
const isCatSelected = (catName: string) =>
|
||||||
|
r.category_configs.some((c) => c.category === catName);
|
||||||
|
|
||||||
|
const toggleCategory = (catName: string) => {
|
||||||
|
if (isCatSelected(catName)) {
|
||||||
|
update({ category_configs: r.category_configs.filter((c) => c.category !== catName) });
|
||||||
|
} else {
|
||||||
|
update({ category_configs: [...r.category_configs, { category: catName, all_products: true, product_ids: [] }] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCatConfig = (cfg: RewardCategoryConfig) => {
|
||||||
|
update({
|
||||||
|
category_configs: r.category_configs.map((c) =>
|
||||||
|
c.category === cfg.category ? cfg : c
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewardBadge = (
|
||||||
|
<View style={{
|
||||||
|
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||||
|
backgroundColor: enabled ? REWARD_ACCENT + "25" : colors.border + "40",
|
||||||
|
borderWidth: 1, borderColor: enabled ? REWARD_ACCENT : colors.border,
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
|
||||||
|
{enabled ? "Activée" : "Désactivée"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionSection title="Récompenses par palier" badge={rewardBadge} colors={colors} s={s}>
|
||||||
|
<View style={[s.row, s.rowFirst]}>
|
||||||
|
<View style={s.rowLeft}>
|
||||||
|
<Text style={s.rowLabel}>Récompense activée</Text>
|
||||||
|
<Text style={s.rowDesc}>
|
||||||
|
Le seuil s'applique indépendamment à chaque type de points.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={enabled}
|
||||||
|
onValueChange={(v) => onChange(v ? EMPTY_REWARD : null)}
|
||||||
|
trackColor={{ false: colors.border, true: REWARD_ACCENT }}
|
||||||
|
thumbColor="#fff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
|
||||||
|
|
||||||
|
{/* Seuil */}
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={s.rowLabel}>Seuil de points</Text>
|
||||||
|
<Text style={s.rowDesc}>
|
||||||
|
Dès X points cumulés dans un type, la récompense est débloquée pour ce type.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||||
|
<TextInput
|
||||||
|
style={[s.thresholdInput, { width: screenWidth < 380 ? 52 : 64 }]}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={String(r.threshold)}
|
||||||
|
onChangeText={(v) => { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) update({ threshold: n }); }}
|
||||||
|
/>
|
||||||
|
<Text style={s.thresholdSep}>pts</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<View>
|
||||||
|
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Type de récompense</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
|
||||||
|
{REWARD_TYPES.map((rt) => {
|
||||||
|
const sel = r.type === rt.value;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={rt.value}
|
||||||
|
onPress={() => update({ type: rt.value })}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||||
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||||
|
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||||
|
backgroundColor: sel ? REWARD_ACCENT + "20" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={rt.icon as any} size={14} color={sel ? REWARD_ACCENT : colors.textMuted} />
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||||
|
{rt.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View>
|
||||||
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Description affichée au client</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m, paddingVertical: spacing.s, height: 72, textAlignVertical: "top" }]}
|
||||||
|
value={r.description}
|
||||||
|
onChangeText={(v) => update({ description: v })}
|
||||||
|
placeholder="Ex : Un produit 30€ de ton choix parmi les produits conditionnés en 30€"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Produits récompense — uniquement pour le type "Produit offert" */}
|
||||||
|
{r.type === "free_product" && (
|
||||||
|
<View>
|
||||||
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Produits ajoutés au panier</Text>
|
||||||
|
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
||||||
|
Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier (gratuits). Il doit commander au moins un produit normal.
|
||||||
|
</Text>
|
||||||
|
<View style={{ gap: spacing.s }}>
|
||||||
|
{r.reward_items.map((item, idx) => {
|
||||||
|
const allProds = Object.values(productsByCategory).flat();
|
||||||
|
const prod = allProds.find((p) => p.id === item.product_id);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1, borderColor: REWARD_ACCENT + "44",
|
||||||
|
borderRadius: borderRadius.sm, padding: spacing.m,
|
||||||
|
backgroundColor: REWARD_ACCENT + "08", gap: spacing.s,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sélecteur produit */}
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||||
|
{allProds.map((p) => {
|
||||||
|
const sel = item.product_id === p.id;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p.id}
|
||||||
|
onPress={() => {
|
||||||
|
const updated = r.reward_items.map((it, i) =>
|
||||||
|
i === idx ? { ...it, product_id: p.id } : it
|
||||||
|
);
|
||||||
|
update({ reward_items: updated });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center", gap: 4,
|
||||||
|
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||||
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||||
|
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||||
|
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||||
|
{p.name} ({p.category})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quantité + Prix + Supprimer */}
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||||
|
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted }}>Qté :</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={item.quantity > 0 ? String(item.quantity) : ""}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
const updated = r.reward_items.map((it, i) =>
|
||||||
|
i === idx ? { ...it, quantity: isNaN(n) ? 0 : n } : it
|
||||||
|
);
|
||||||
|
update({ reward_items: updated });
|
||||||
|
}}
|
||||||
|
placeholder="1"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted }}>Prix :</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={item.price > 0 ? String(item.price) : ""}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
const updated = r.reward_items.map((it, i) =>
|
||||||
|
i === idx ? { ...it, price: isNaN(n) ? 0 : n } : it
|
||||||
|
);
|
||||||
|
update({ reward_items: updated });
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted }}>€</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => update({ reward_items: r.reward_items.filter((_, i) => i !== idx) })}
|
||||||
|
style={{ padding: spacing.xs }}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{prod && (
|
||||||
|
<Text style={{ fontSize: 11, color: REWARD_ACCENT, fontStyle: "italic" }}>
|
||||||
|
{prod.name}{item.quantity > 0 ? ` · x${item.quantity}` : ""}{item.price > 0 ? ` · ${item.price}€` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!prod && (
|
||||||
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
|
||||||
|
Sélectionnez un produit ci-dessus
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Object.values(productsByCategory).flat().length === 0 ? (
|
||||||
|
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucun produit disponible</Text>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => update({ reward_items: [...r.reward_items, { product_id: 0, quantity: 1, price: 0 }] })}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||||
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||||
|
borderColor: REWARD_ACCENT + "66",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={16} color={REWARD_ACCENT} />
|
||||||
|
<Text style={{ fontSize: 13, color: REWARD_ACCENT, fontWeight: "600" }}>Ajouter un produit récompense</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Catégories éligibles — uniquement pour le type "Produit à -50%" */}
|
||||||
|
{r.type === "half_price_product" && (
|
||||||
|
<View>
|
||||||
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles à -50%</Text>
|
||||||
|
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
||||||
|
Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection.
|
||||||
|
</Text>
|
||||||
|
{allCategories.length === 0 ? (
|
||||||
|
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: spacing.m }}>
|
||||||
|
{allCategories.map((cat) => {
|
||||||
|
const selected = isCatSelected(cat.name);
|
||||||
|
const catColor = cat.color || REWARD_ACCENT;
|
||||||
|
return (
|
||||||
|
<View key={cat.name}>
|
||||||
|
{/* Chip catégorie */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => toggleCategory(cat.name)}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||||
|
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||||
|
borderColor: selected ? catColor : colors.border,
|
||||||
|
backgroundColor: selected ? catColor + "22" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={selected ? "chevron-down" : "chevron-forward"}
|
||||||
|
size={12}
|
||||||
|
color={selected ? catColor : colors.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Sélecteur produits (visible si catégorie sélectionnée) */}
|
||||||
|
{selected && (
|
||||||
|
<CategoryProductPicker
|
||||||
|
catConfig={getCatConfig(cat.name)}
|
||||||
|
products={productsByCategory[cat.name] ?? []}
|
||||||
|
onChange={updateCatConfig}
|
||||||
|
colors={colors}
|
||||||
|
s={s}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
|
||||||
|
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
|
||||||
|
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type →{" "}
|
||||||
|
{REWARD_TYPES.find((x) => x.value === r.type)?.label}
|
||||||
|
</Text>
|
||||||
|
{r.description !== "" && (
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
|
||||||
|
)}
|
||||||
|
{r.type === "half_price_product" && r.category_configs.map((cfg) => (
|
||||||
|
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||||
|
• Éligible à -50% : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{r.type === "free_product" && r.reward_items.filter((it) => it.product_id > 0).map((it, idx) => {
|
||||||
|
const prod = Object.values(productsByCategory).flat().find((p) => p.id === it.product_id);
|
||||||
|
return (
|
||||||
|
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
|
<Ionicons name="gift-outline" size={11} color={REWARD_ACCENT} />
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||||
|
{prod?.name ?? `Produit #${it.product_id}`}{it.quantity > 0 ? ` · x${it.quantity}` : ""}{it.price > 0 ? ` · ${it.price}€` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</AccordionSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,11 +1189,16 @@ export default function SettingsScreen() {
|
|||||||
telegram_bot_token: "",
|
telegram_bot_token: "",
|
||||||
telegram_bot_username: "",
|
telegram_bot_username: "",
|
||||||
telegram_notifications_enabled: false,
|
telegram_notifications_enabled: false,
|
||||||
|
telegram_2fa_enabled: false,
|
||||||
delivery_mode: { mode: "single" as const, category_routes: [] },
|
delivery_mode: { mode: "single" as const, category_routes: [] },
|
||||||
|
shop_name: "Milieu-Nantais",
|
||||||
|
contact_telegram: "",
|
||||||
|
points_reward: null,
|
||||||
});
|
});
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [productsByCategory, setProductsByCategory] = useState<Record<string, Product[]>>({});
|
||||||
const [livreurs, setLivreurs] = useState<string[]>([]);
|
const [livreurs, setLivreurs] = useState<string[]>([]);
|
||||||
|
|
||||||
// Refs pour l'auto-sauvegarde au départ de la page
|
// Refs pour l'auto-sauvegarde au départ de la page
|
||||||
@@ -711,18 +1228,33 @@ export default function SettingsScreen() {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
isLoaded.current = false;
|
isLoaded.current = false;
|
||||||
const [settingsRes, categoriesRes, livreursRes] = await Promise.all([
|
try {
|
||||||
|
const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([
|
||||||
getSettings(),
|
getSettings(),
|
||||||
getCategories(),
|
getCategories(),
|
||||||
getAvailableDeliveryPersons(),
|
getAvailableDeliveryPersons(),
|
||||||
|
getAllProductsAdmin(),
|
||||||
]);
|
]);
|
||||||
if (settingsRes.success && settingsRes.settings) {
|
if (settingsRes.success && settingsRes.settings) {
|
||||||
|
const s = settingsRes.settings;
|
||||||
setSettings({
|
setSettings({
|
||||||
...settingsRes.settings,
|
...s,
|
||||||
points_pools: settingsRes.settings.points_pools ?? [],
|
penalty_tiers: s.penalty_tiers ?? [],
|
||||||
delivery_schedule: settingsRes.settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
points_pools: (s.points_pools ?? []).map((p: any) => ({
|
||||||
postal_zones: settingsRes.settings.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
...p,
|
||||||
delivery_mode: settingsRes.settings.delivery_mode ?? { mode: "single", category_routes: [] },
|
categories: p.categories ?? [],
|
||||||
|
tiers: p.tiers ?? [],
|
||||||
|
})),
|
||||||
|
nowpayments_currencies: s.nowpayments_currencies ?? [],
|
||||||
|
delivery_schedule: s.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
||||||
|
postal_zones: s.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
||||||
|
delivery_mode: {
|
||||||
|
...(s.delivery_mode ?? { mode: "single" as const }),
|
||||||
|
category_routes: s.delivery_mode?.category_routes ?? [],
|
||||||
|
},
|
||||||
|
points_reward: s.points_reward
|
||||||
|
? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [], reward_items: s.points_reward.reward_items ?? [] }
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (categoriesRes) {
|
if (categoriesRes) {
|
||||||
@@ -731,10 +1263,21 @@ export default function SettingsScreen() {
|
|||||||
if (livreursRes.success) {
|
if (livreursRes.success) {
|
||||||
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
|
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
|
||||||
}
|
}
|
||||||
|
if (productsRes.success) {
|
||||||
|
const byCategory: Record<string, Product[]> = {};
|
||||||
|
for (const p of productsRes.data) {
|
||||||
|
const cat = p.category || "";
|
||||||
|
if (!byCategory[cat]) byCategory[cat] = [];
|
||||||
|
byCategory[cat].push(p);
|
||||||
|
}
|
||||||
|
setProductsByCategory(byCategory);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[SettingsScreen] loadData error:", e);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// Marquer comme chargé après un tick pour que le useEffect de settings ne
|
|
||||||
// considère pas le setSettings initial comme une modification utilisateur
|
|
||||||
setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0);
|
setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -939,9 +1482,28 @@ export default function SettingsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={s.container}>
|
<View style={s.container}>
|
||||||
<ScrollView contentContainerStyle={s.content}>
|
<ScrollView contentContainerStyle={s.content}>
|
||||||
|
{/* Personnalisation */}
|
||||||
|
<AccordionSection title="Personnalisation" colors={colors} s={s}>
|
||||||
|
<View style={[s.row, s.rowFirst]}>
|
||||||
|
<View style={s.rowLeft}>
|
||||||
|
<Text style={s.rowLabel}>Nom du shop</Text>
|
||||||
|
<Text style={s.rowDesc}>Affiché dans la sidebar du site client</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={settings.shop_name}
|
||||||
|
onChangeText={(v) => setSettings((p) => ({ ...p, shop_name: v }))}
|
||||||
|
placeholder="Ex: Milieu-Nantais"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
{/* Amendes */}
|
{/* Amendes */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Amendes" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Amendes</Text>
|
|
||||||
<View style={[s.row, s.rowFirst]}>
|
<View style={[s.row, s.rowFirst]}>
|
||||||
<View style={s.rowLeft}>
|
<View style={s.rowLeft}>
|
||||||
<Text style={s.rowLabel}>Amendes activées</Text>
|
<Text style={s.rowLabel}>Amendes activées</Text>
|
||||||
@@ -1001,7 +1563,7 @@ export default function SettingsScreen() {
|
|||||||
<Text style={[s.rowDesc, { width: inputMd, textAlign: "center" }]}>Montant</Text>
|
<Text style={[s.rowDesc, { width: inputMd, textAlign: "center" }]}>Montant</Text>
|
||||||
<View style={{ width: 28 }} />
|
<View style={{ width: 28 }} />
|
||||||
</View>
|
</View>
|
||||||
{settings.penalty_tiers.map((tier, i) => (
|
{(settings.penalty_tiers ?? []).map((tier, i) => (
|
||||||
<View key={i} style={{ flexDirection: "row", alignItems: "center", width: "100%", gap: spacing.s }}>
|
<View key={i} style={{ flexDirection: "row", alignItems: "center", width: "100%", gap: spacing.s }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
|
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
|
||||||
@@ -1044,11 +1606,10 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* Parrainage */}
|
{/* Parrainage */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Parrainage" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Parrainage</Text>
|
|
||||||
<View style={[s.row, s.rowFirst]}>
|
<View style={[s.row, s.rowFirst]}>
|
||||||
<View style={s.rowLeft}>
|
<View style={s.rowLeft}>
|
||||||
<Text style={s.rowLabel}>Parrainage activé</Text>
|
<Text style={s.rowLabel}>Parrainage activé</Text>
|
||||||
@@ -1066,11 +1627,10 @@ export default function SettingsScreen() {
|
|||||||
thumbColor="#fff"
|
thumbColor="#fff"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* Système de points */}
|
{/* Système de points */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Système de points" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Système de points</Text>
|
|
||||||
<View style={[s.row, s.rowFirst]}>
|
<View style={[s.row, s.rowFirst]}>
|
||||||
<View style={s.rowLeft}>
|
<View style={s.rowLeft}>
|
||||||
<Text style={s.rowLabel}>Points activés</Text>
|
<Text style={s.rowLabel}>Points activés</Text>
|
||||||
@@ -1126,11 +1686,10 @@ export default function SettingsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* Attribution des catégories */}
|
{/* Attribution des catégories */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Attribution des catégories aux points" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
|
|
||||||
<Text style={s.hint}>
|
<Text style={s.hint}>
|
||||||
Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"}
|
Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"}
|
||||||
Vous pouvez modifier les attributions à tout moment.
|
Vous pouvez modifier les attributions à tout moment.
|
||||||
@@ -1217,7 +1776,7 @@ export default function SettingsScreen() {
|
|||||||
Ajoutez au moins un type de point pour assigner des catégories.
|
Ajoutez au moins un type de point pour assigner des catégories.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* Barèmes de points — un par pool */}
|
{/* Barèmes de points — un par pool */}
|
||||||
{pools.map((pool, i) => (
|
{pools.map((pool, i) => (
|
||||||
@@ -1233,6 +1792,16 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Récompense centralisée par palier */}
|
||||||
|
<CentralRewardSection
|
||||||
|
reward={settings.points_reward ?? null}
|
||||||
|
allCategories={categories}
|
||||||
|
productsByCategory={productsByCategory}
|
||||||
|
onChange={(reward) => setSettings((p) => ({ ...p, points_reward: reward }))}
|
||||||
|
colors={colors}
|
||||||
|
s={s}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Horaires de livraison */}
|
{/* Horaires de livraison */}
|
||||||
<DeliveryScheduleSection
|
<DeliveryScheduleSection
|
||||||
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
||||||
@@ -1250,8 +1819,7 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Paiement crypto */}
|
{/* Paiement crypto */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Paiement crypto" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Paiement crypto</Text>
|
|
||||||
|
|
||||||
{/* Toggle activation */}
|
{/* Toggle activation */}
|
||||||
<View style={[s.row, s.rowFirst]}>
|
<View style={[s.row, s.rowFirst]}>
|
||||||
@@ -1393,13 +1961,12 @@ export default function SettingsScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* ============================================ */}
|
{/* ============================================ */}
|
||||||
{/* 🤖 TELEGRAM */}
|
{/* 🤖 TELEGRAM */}
|
||||||
{/* ============================================ */}
|
{/* ============================================ */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Notifications Telegram" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Notifications Telegram</Text>
|
|
||||||
<View style={s.row}>
|
<View style={s.row}>
|
||||||
<View style={s.rowLeft}>
|
<View style={s.rowLeft}>
|
||||||
<Text style={s.rowLabel}>Notifications activées</Text>
|
<Text style={s.rowLabel}>Notifications activées</Text>
|
||||||
@@ -1412,6 +1979,18 @@ export default function SettingsScreen() {
|
|||||||
thumbColor="#fff"
|
thumbColor="#fff"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={s.row}>
|
||||||
|
<View style={s.rowLeft}>
|
||||||
|
<Text style={s.rowLabel}>Authentification 2FA</Text>
|
||||||
|
<Text style={s.rowDesc}>Permettre aux clients d'activer la double authentification via Telegram lors de la connexion</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.telegram_2fa_enabled}
|
||||||
|
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))}
|
||||||
|
trackColor={{ false: colors.border, true: colors.accent }}
|
||||||
|
thumbColor="#fff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
|
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
|
||||||
<Text style={s.rowDesc}>
|
<Text style={s.rowDesc}>
|
||||||
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
|
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
|
||||||
@@ -1451,14 +2030,38 @@ export default function SettingsScreen() {
|
|||||||
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré — @{settings.telegram_bot_username}</Text>
|
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré — @{settings.telegram_bot_username}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<View>
|
||||||
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Contact SAV Telegram</Text>
|
||||||
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
|
||||||
|
Username du compte Telegram SAV (sans le @). Utilisé pour les boutons de contact client.
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||||
|
<Text style={{ fontSize: fontSize.md, color: colors.textMuted }}>@</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, { flex: 1 }]}
|
||||||
|
value={settings.contact_telegram}
|
||||||
|
onChangeText={(v) => setSettings((p) => ({ ...p, contact_telegram: v }))}
|
||||||
|
placeholder="MonSAV"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
{settings.contact_telegram !== "" && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.s, padding: spacing.m, backgroundColor: "#0088cc22", borderRadius: borderRadius.sm }}>
|
||||||
|
<Ionicons name="paper-plane-outline" size={16} color="#0088cc" />
|
||||||
|
<Text style={{ color: "#0088cc", fontSize: fontSize.sm }}>@{settings.contact_telegram}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
{/* ============================================ */}
|
{/* ============================================ */}
|
||||||
{/* 🚚 MODE DE LIVRAISON */}
|
{/* 🚚 MODE DE LIVRAISON */}
|
||||||
{/* ============================================ */}
|
{/* ============================================ */}
|
||||||
<View style={s.section}>
|
<AccordionSection title="Mode de livraison" colors={colors} s={s}>
|
||||||
<Text style={s.sectionTitle}>Mode de livraison</Text>
|
|
||||||
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
|
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
|
||||||
Choisissez comment les commandes sont assignées aux livreurs.
|
Choisissez comment les commandes sont assignées aux livreurs.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1577,7 +2180,7 @@ export default function SettingsScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</AccordionSection>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={s.saveButton}
|
style={s.saveButton}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
|
import { shadows } from "../../theme/shadows";
|
||||||
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
import { getAdminStats } from "../../api/api_admin";
|
||||||
|
import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat, ProductQuantityBreakdown } from "../../api/api_admin";
|
||||||
|
|
||||||
|
// ── Palette graphiques ────────────────────────────────────────────────────────
|
||||||
|
const CHART_ACCENT = "#6366f1";
|
||||||
|
const CHART_GREEN = "#10b981";
|
||||||
|
const CHART_AMBER = "#f59e0b";
|
||||||
|
const CHART_RED = "#ef4444";
|
||||||
|
const CHART_BLUE = "#3b82f6";
|
||||||
|
|
||||||
|
// ── Utilitaires ───────────────────────────────────────────────────────────────
|
||||||
|
const maxOf = (arr: number[]) => arr.length ? Math.max(...arr) : 1;
|
||||||
|
const fmtNum = (n: number) =>
|
||||||
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n));
|
||||||
|
const fmtEuro = (n: number) =>
|
||||||
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k€` : `${Math.round(n)}€`;
|
||||||
|
|
||||||
|
// ── Barre horizontale ─────────────────────────────────────────────────────────
|
||||||
|
function HBar({
|
||||||
|
label, value, max, color, right,
|
||||||
|
}: {
|
||||||
|
label: string; value: number; max: number; color: string; right?: string;
|
||||||
|
}) {
|
||||||
|
const pct = max > 0 ? Math.max((value / max) * 100, value > 0 ? 2 : 0) : 0;
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={hBarStyles.row}>
|
||||||
|
<Text style={[hBarStyles.label, { color: colors.textMuted }]} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View style={[hBarStyles.track, { backgroundColor: colors.borderLight }]}>
|
||||||
|
<View style={[hBarStyles.fill, { width: `${pct}%`, backgroundColor: color }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={[hBarStyles.value, { color: colors.textPrimary }]}>
|
||||||
|
{right ?? fmtNum(value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hBarStyles = StyleSheet.create({
|
||||||
|
row: { flexDirection: "row", alignItems: "center", marginBottom: spacing.s, gap: spacing.s },
|
||||||
|
label: { width: 80, fontSize: fontSize.xs, flexShrink: 0 },
|
||||||
|
track: { flex: 1, height: 18, borderRadius: 4, overflow: "hidden" },
|
||||||
|
fill: { height: "100%", borderRadius: 4 },
|
||||||
|
value: { width: 46, fontSize: fontSize.xs, textAlign: "right" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Barres verticales (sparkline 30 jours — commandes) ───────────────────────
|
||||||
|
function SparkLine({ data, color }: { data: DayStat[]; color: string }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
if (!data.length) return null;
|
||||||
|
const max = maxOf(data.map((d) => d.count));
|
||||||
|
const BAR_H = 56;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "flex-end", height: BAR_H, gap: 2 }}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: max > 0 ? Math.max((d.count / max) * BAR_H, d.count > 0 ? 3 : 0) : 0,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRadius: 2,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sparkline revenus 30 jours ────────────────────────────────────────────────
|
||||||
|
function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: string }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
if (!data.length) return null;
|
||||||
|
const max = Math.max(...data.map((d) => d.revenue), 1);
|
||||||
|
const BAR_H = 56;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "flex-end", height: BAR_H, gap: 2 }}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: max > 0 ? Math.max((d.revenue / max) * BAR_H, d.revenue > 0 ? 3 : 0) : 0,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRadius: 2,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Carte résumé ──────────────────────────────────────────────────────────────
|
||||||
|
function SummaryCard({
|
||||||
|
icon, label, value, color,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap; label: string; value: string; color: string;
|
||||||
|
}) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={[sumStyles.card, shadows.sm, { backgroundColor: colors.bgCard, borderColor: colors.borderLight }]}>
|
||||||
|
<View style={[sumStyles.iconWrap, { backgroundColor: color + "22" }]}>
|
||||||
|
<Ionicons name={icon} size={20} color={color} />
|
||||||
|
</View>
|
||||||
|
<Text style={[sumStyles.val, { color: colors.textPrimary }]}>{value}</Text>
|
||||||
|
<Text style={[sumStyles.lbl, { color: colors.textMuted }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sumStyles = StyleSheet.create({
|
||||||
|
card: { flex: 1, borderRadius: borderRadius.md, padding: spacing.m, borderWidth: 1, alignItems: "center", minWidth: "45%" },
|
||||||
|
iconWrap:{ width: 36, height: 36, borderRadius: 18, justifyContent: "center", alignItems: "center", marginBottom: spacing.xs },
|
||||||
|
val: { fontSize: fontSize.lg, fontWeight: "700" },
|
||||||
|
lbl: { fontSize: fontSize.xs, marginTop: 2, textAlign: "center" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Séparateur de section ─────────────────────────────────────────────────────
|
||||||
|
function Section({ title, icon, children }: { title: string; icon: keyof typeof Ionicons.glyphMap; children: React.ReactNode }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={[secStyles.card, { backgroundColor: colors.bgCard, borderColor: colors.borderLight }]}>
|
||||||
|
<View style={secStyles.header}>
|
||||||
|
<Ionicons name={icon} size={16} color={CHART_ACCENT} />
|
||||||
|
<Text style={[secStyles.title, { color: colors.textPrimary }]}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const secStyles = StyleSheet.create({
|
||||||
|
card: { borderRadius: borderRadius.md, padding: spacing.l, marginBottom: spacing.m, borderWidth: 1 },
|
||||||
|
header: { flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m },
|
||||||
|
title: { fontSize: fontSize.md, fontWeight: "600" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sélecteur de période top produits ────────────────────────────────────────
|
||||||
|
type ProdSort = "quantity" | "orders" | "revenue";
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
export default function StatsScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [prodSort, setProdSort] = useState<ProdSort>("quantity");
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAdminStats();
|
||||||
|
setStats(data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => { loadStats(); }, [loadStats]));
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadStats();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Produits triés selon le sélecteur
|
||||||
|
const sortedProducts = useMemo<ProductStat[]>(() => {
|
||||||
|
if (!stats?.top_products) return [];
|
||||||
|
return [...stats.top_products].sort((a, b) => {
|
||||||
|
if (prodSort === "orders") return b.order_count - a.order_count;
|
||||||
|
if (prodSort === "revenue") return b.revenue - a.revenue;
|
||||||
|
return b.quantity - a.quantity;
|
||||||
|
});
|
||||||
|
}, [stats, prodSort]);
|
||||||
|
|
||||||
|
const styles = useMemo(() => StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||||
|
content: { padding: spacing.l, paddingBottom: spacing.xxxl },
|
||||||
|
pageTitle: { fontSize: fontSize.xl, fontWeight: "700", color: colors.textPrimary, marginBottom: spacing.xs },
|
||||||
|
pageSubtitle: { fontSize: fontSize.sm, color: colors.textMuted, marginBottom: spacing.l },
|
||||||
|
summaryRow: { flexDirection: "row", gap: spacing.m, marginBottom: spacing.m, flexWrap: "wrap" },
|
||||||
|
sortRow: { flexDirection: "row", gap: spacing.s, marginBottom: spacing.m },
|
||||||
|
sortBtn: { paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.sm, borderWidth: 1 },
|
||||||
|
sortBtnText:{ fontSize: fontSize.xs, fontWeight: "600" },
|
||||||
|
emptyText: { color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center", paddingVertical: spacing.l },
|
||||||
|
}), [colors]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner message="Chargement des statistiques..." />;
|
||||||
|
|
||||||
|
const s = stats?.summary;
|
||||||
|
const wdMax = maxOf((stats?.by_weekday ?? []).map((w: WeekdayStat) => w.count));
|
||||||
|
const prodMax = maxOf(sortedProducts.map((p) =>
|
||||||
|
prodSort === "orders" ? p.order_count : prodSort === "revenue" ? p.revenue : p.quantity,
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={CHART_ACCENT} />}
|
||||||
|
>
|
||||||
|
<Text style={styles.pageTitle}>Statistiques</Text>
|
||||||
|
<Text style={styles.pageSubtitle}>Activité globale & produits</Text>
|
||||||
|
|
||||||
|
{/* ── Cartes résumé ── */}
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<SummaryCard
|
||||||
|
icon="receipt-outline"
|
||||||
|
label="Commandes totales"
|
||||||
|
value={fmtNum(s?.total_orders ?? 0)}
|
||||||
|
color={CHART_ACCENT}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon="cash-outline"
|
||||||
|
label="Revenus (terminées)"
|
||||||
|
value={fmtEuro(s?.total_revenue ?? 0)}
|
||||||
|
color={CHART_GREEN}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<SummaryCard
|
||||||
|
icon="trending-up-outline"
|
||||||
|
label="Moy. commandes/jour"
|
||||||
|
value={(s?.avg_per_day ?? 0).toFixed(1)}
|
||||||
|
color={CHART_BLUE}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon="trophy-outline"
|
||||||
|
label="Jour de pointe"
|
||||||
|
value={s?.peak_weekday ?? "—"}
|
||||||
|
color={CHART_AMBER}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Évolution 30 jours (commandes) ── */}
|
||||||
|
<Section title="30 derniers jours — commandes" icon="bar-chart-outline">
|
||||||
|
{stats?.by_day_30?.length ? (
|
||||||
|
<SparkLine data={stats.by_day_30} color={CHART_ACCENT} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.emptyText}>Aucune donnée</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Revenus par jour (30 jours) ── */}
|
||||||
|
{(() => {
|
||||||
|
const revData = stats?.by_day_revenue ?? [];
|
||||||
|
const revMax = Math.max(...revData.map((d) => d.revenue), 1);
|
||||||
|
const best = [...revData].sort((a, b) => b.revenue - a.revenue)[0];
|
||||||
|
return (
|
||||||
|
<Section title="Revenus par jour (30j)" icon="trending-up-outline">
|
||||||
|
{revData.length ? (
|
||||||
|
<>
|
||||||
|
<SparkLineRevenue data={revData} color={CHART_GREEN} />
|
||||||
|
<View style={{ marginTop: spacing.m }}>
|
||||||
|
{[...revData].reverse().map((d) => (
|
||||||
|
<HBar
|
||||||
|
key={d.day}
|
||||||
|
label={d.label}
|
||||||
|
value={d.revenue}
|
||||||
|
max={revMax}
|
||||||
|
color={best && d.day === best.day ? CHART_GREEN : CHART_ACCENT + "bb"}
|
||||||
|
right={fmtEuro(d.revenue)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{best && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
|
||||||
|
<Ionicons name="star-outline" size={13} color={CHART_GREEN} />
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
|
||||||
|
Meilleure journée :{" "}
|
||||||
|
<Text style={{ color: CHART_GREEN, fontWeight: "600" }}>
|
||||||
|
{best.label} · {fmtEuro(best.revenue)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.emptyText}>Aucune donnée de revenu</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Heures d'affluence ── */}
|
||||||
|
{(() => {
|
||||||
|
const hourData: HourStat[] = stats?.by_hour ?? [];
|
||||||
|
const peakHour = hourData.reduce<HourStat | null>(
|
||||||
|
(best, h) => (!best || h.count > best.count ? h : best),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const hourMax = maxOf(hourData.map((h) => h.count));
|
||||||
|
return (
|
||||||
|
<Section title="Heures d'affluence" icon="time-outline">
|
||||||
|
{hourData.some((h) => h.count > 0) ? (
|
||||||
|
<>
|
||||||
|
{hourData.map((h) => (
|
||||||
|
<HBar
|
||||||
|
key={h.hour}
|
||||||
|
label={h.label}
|
||||||
|
value={h.count}
|
||||||
|
max={hourMax}
|
||||||
|
color={h.count === hourMax && hourMax > 0 ? CHART_AMBER : CHART_BLUE}
|
||||||
|
right={h.count > 0 ? String(h.count) : "—"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{peakHour && peakHour.count > 0 && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
|
||||||
|
<Ionicons name="flame-outline" size={13} color={CHART_AMBER} />
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
|
||||||
|
Heure de pointe :{" "}
|
||||||
|
<Text style={{ color: CHART_AMBER, fontWeight: "600" }}>
|
||||||
|
{peakHour.label} · {peakHour.count} cmd · {fmtEuro(peakHour.revenue)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.emptyText}>Aucune donnée horaire</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Commandes par jour de la semaine ── */}
|
||||||
|
<Section title="Jours d'affluence" icon="calendar-outline">
|
||||||
|
{(stats?.by_weekday ?? []).map((w: WeekdayStat) => (
|
||||||
|
<HBar
|
||||||
|
key={w.weekday}
|
||||||
|
label={w.weekday.slice(0, 3)}
|
||||||
|
value={w.count}
|
||||||
|
max={wdMax}
|
||||||
|
color={w.count === wdMax && wdMax > 0 ? CHART_AMBER : CHART_ACCENT}
|
||||||
|
right={String(w.count)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{wdMax > 0 && s?.peak_weekday && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
|
||||||
|
<Ionicons name="flame-outline" size={13} color={CHART_AMBER} />
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
|
||||||
|
Pic d'activité : <Text style={{ color: CHART_AMBER, fontWeight: "600" }}>{s.peak_weekday}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Doses les plus populaires par produit ── */}
|
||||||
|
{(() => {
|
||||||
|
const qtyData: ProductQuantityBreakdown[] = (stats?.by_quantity ?? []).filter(
|
||||||
|
(p) => p.quantities.length >= 1,
|
||||||
|
);
|
||||||
|
if (!qtyData.length) return null;
|
||||||
|
return (
|
||||||
|
<Section title="Doses populaires par produit" icon="flask-outline">
|
||||||
|
{qtyData.map((product) => {
|
||||||
|
const peakCount = product.quantities[0]?.order_count ?? 1;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={product.product_id}
|
||||||
|
style={{
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
paddingBottom: spacing.m,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Nom du produit avec pastille couleur */}
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginBottom: spacing.s }}>
|
||||||
|
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: product.category_color }} />
|
||||||
|
<Text style={{ color: colors.textPrimary, fontSize: fontSize.sm, fontWeight: "600" }} numberOfLines={1}>
|
||||||
|
{product.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{/* Barre par dose */}
|
||||||
|
{product.quantities.map((q, i) => {
|
||||||
|
const pct = peakCount > 0 ? Math.max((q.order_count / peakCount) * 100, q.order_count > 0 ? 2 : 0) : 0;
|
||||||
|
const isPeak = i === 0;
|
||||||
|
const label = Number.isInteger(q.quantity)
|
||||||
|
? `${q.quantity}g`
|
||||||
|
: `${q.quantity}g`;
|
||||||
|
return (
|
||||||
|
<View key={q.quantity} style={{ flexDirection: "row", alignItems: "center", marginBottom: 4, gap: spacing.s }}>
|
||||||
|
<Text style={{ width: 44, fontSize: fontSize.xs, color: isPeak ? product.category_color : colors.textMuted, fontWeight: isPeak ? "700" : "400" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1, height: 14, borderRadius: 3, backgroundColor: colors.borderLight, overflow: "hidden" }}>
|
||||||
|
<View style={{ width: `${pct}%`, height: "100%", borderRadius: 3, backgroundColor: isPeak ? product.category_color : product.category_color + "66" }} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ width: 54, fontSize: fontSize.xs, textAlign: "right", color: isPeak ? product.category_color : colors.textMuted, fontWeight: isPeak ? "700" : "400" }}>
|
||||||
|
{q.order_count} cmd
|
||||||
|
</Text>
|
||||||
|
{isPeak && (
|
||||||
|
<Ionicons name="flame" size={11} color={CHART_AMBER} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Top produits ── */}
|
||||||
|
<Section title="Top produits" icon="cube-outline">
|
||||||
|
{/* Sélecteur tri */}
|
||||||
|
<View style={styles.sortRow}>
|
||||||
|
{(["quantity", "orders", "revenue"] as ProdSort[]).map((key) => {
|
||||||
|
const labels = { quantity: "Quantité", orders: "Commandes", revenue: "Revenus" };
|
||||||
|
const active = prodSort === key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={key}
|
||||||
|
style={[
|
||||||
|
styles.sortBtn,
|
||||||
|
{
|
||||||
|
backgroundColor: active ? CHART_ACCENT + "22" : "transparent",
|
||||||
|
borderColor: active ? CHART_ACCENT : colors.borderLight,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => setProdSort(key)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.sortBtnText, { color: active ? CHART_ACCENT : colors.textMuted }]}>
|
||||||
|
{labels[key]}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{sortedProducts.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>Aucune donnée produit</Text>
|
||||||
|
) : (
|
||||||
|
sortedProducts.map((p) => {
|
||||||
|
const val =
|
||||||
|
prodSort === "orders" ? p.order_count
|
||||||
|
: prodSort === "revenue" ? p.revenue
|
||||||
|
: p.quantity;
|
||||||
|
const rightLabel =
|
||||||
|
prodSort === "revenue" ? fmtEuro(p.revenue)
|
||||||
|
: prodSort === "orders" ? `${p.order_count} cmd`
|
||||||
|
: `×${fmtNum(p.quantity)}`;
|
||||||
|
return (
|
||||||
|
<HBar
|
||||||
|
key={p.product_id}
|
||||||
|
label={p.name}
|
||||||
|
value={val}
|
||||||
|
max={prodMax}
|
||||||
|
color={p.category_color || CHART_ACCENT}
|
||||||
|
right={rightLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Produit le moins vendu */}
|
||||||
|
{sortedProducts.length > 1 && prodSort === "quantity" && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
|
||||||
|
<Ionicons name="arrow-down-circle-outline" size={13} color={CHART_RED} />
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
|
||||||
|
Moins vendu :{" "}
|
||||||
|
<Text style={{ color: CHART_RED, fontWeight: "600" }}>
|
||||||
|
{sortedProducts[sortedProducts.length - 1]?.name}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export default function AdminLoginScreen() {
|
|||||||
heroTitle: {
|
heroTitle: {
|
||||||
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
||||||
fontWeight: "800",
|
fontWeight: "800",
|
||||||
color: colors.textWhite,
|
color: colors.textPrimary,
|
||||||
marginTop: spacing.m,
|
marginTop: spacing.m,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function CabineLoginScreen() {
|
|||||||
heroTitle: {
|
heroTitle: {
|
||||||
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
||||||
fontWeight: "800",
|
fontWeight: "800",
|
||||||
color: colors.textWhite,
|
color: colors.textPrimary,
|
||||||
marginTop: spacing.m,
|
marginTop: spacing.m,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function DeliveryLoginScreen() {
|
|||||||
heroTitle: {
|
heroTitle: {
|
||||||
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
||||||
fontWeight: "800",
|
fontWeight: "800",
|
||||||
color: colors.textWhite,
|
color: colors.textPrimary,
|
||||||
marginTop: spacing.m,
|
marginTop: spacing.m,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { spacing, fontSize, borderRadius } from "../../theme";
|
|||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { shadows } from "../../theme/shadows";
|
import { shadows } from "../../theme/shadows";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { getAllCommands } from "../../api/api_admin";
|
|
||||||
import {
|
import {
|
||||||
|
getCabineCommands,
|
||||||
getAllDeliveryPersonsWithDetails,
|
getAllDeliveryPersonsWithDetails,
|
||||||
getCabineTelegramStatus,
|
getCabineTelegramStatus,
|
||||||
generateCabineLinkToken,
|
generateCabineLinkToken,
|
||||||
@@ -46,7 +46,7 @@ export default function DashboardScreen() {
|
|||||||
const loadStats = useCallback(async () => {
|
const loadStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [allCmd, livreursRes] = await Promise.all([
|
const [allCmd, livreursRes] = await Promise.all([
|
||||||
getAllCommands(),
|
getCabineCommands(),
|
||||||
getAllDeliveryPersonsWithDetails(),
|
getAllDeliveryPersonsWithDetails(),
|
||||||
]);
|
]);
|
||||||
const cmds = allCmd.commands;
|
const cmds = allCmd.commands;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getAllCommands } from "../../api/api_admin";
|
|
||||||
import {
|
import {
|
||||||
getCommandItems,
|
getCommandItems,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
getCabineLivreursList,
|
getCabineLivreursList,
|
||||||
assignDeliveryPersonByCabine,
|
assignDeliveryPersonByCabine,
|
||||||
proposeAddressChangeCabine,
|
proposeAddressChangeCabine,
|
||||||
|
getCabineCommands,
|
||||||
} from "../../api/api_cabine";
|
} from "../../api/api_cabine";
|
||||||
import type { CommandResponse } from "../../api/types";
|
import type { CommandResponse } from "../../api/types";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
@@ -78,7 +78,7 @@ export default function OrdersScreen() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await getAllCommands();
|
const result = await getCabineCommands();
|
||||||
setCommands(
|
setCommands(
|
||||||
result.commands.filter(
|
result.commands.filter(
|
||||||
(c: CommandResponse) =>
|
(c: CommandResponse) =>
|
||||||
@@ -487,8 +487,18 @@ export default function OrdersScreen() {
|
|||||||
<Text style={styles.info}>Client: {item.username}</Text>
|
<Text style={styles.info}>Client: {item.username}</Text>
|
||||||
<Text style={styles.info}>Adresse: {item.adresse}</Text>
|
<Text style={styles.info}>Adresse: {item.adresse}</Text>
|
||||||
<Text style={styles.info}>
|
<Text style={styles.info}>
|
||||||
Total: {item.total_prix.toFixed(2)} €
|
Total{(item.referral_used ?? 0) > 0 ? " brut" : ""}: {item.total_prix.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
|
{(item.referral_used ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.info, { color: colors.success }]}>
|
||||||
|
Parrainage: -{(item.referral_used ?? 0).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.info, { fontWeight: "700" }]}>
|
||||||
|
Net: {(item.total_prix - (item.referral_used ?? 0)).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.selectBtn}
|
style={styles.selectBtn}
|
||||||
@@ -672,7 +682,7 @@ export default function OrdersScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.itemMeta}>
|
<Text style={styles.itemMeta}>
|
||||||
Qté:{" "}
|
Qté:{" "}
|
||||||
{item.quantite ?? item.quantity} ·{" "}
|
{item.quantite ?? item.quantity}{item.unit || ""} ·{" "}
|
||||||
{(item.prix ?? item.price)?.toFixed(
|
{(item.prix ?? item.price)?.toFixed(
|
||||||
2,
|
2,
|
||||||
)}{" "}
|
)}{" "}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|||||||
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
|
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
|
||||||
import { spacing, fontSize } from "../../theme";
|
import { spacing, fontSize } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getAllClients } from "../../api/api_admin";
|
import { applyClientPenalty, resetClientPenalties, resetClientPoints, getPublicSettings, getCabineAllClients } from "../../api/api_cabine";
|
||||||
import { applyClientPenalty, resetClientPenalties, resetClientPoints, getPublicSettings } from "../../api/api_cabine";
|
|
||||||
import type { PublicSettings } from "../../api/api_cabine";
|
import type { PublicSettings } from "../../api/api_cabine";
|
||||||
import type { ClientResponse } from "../../api/types";
|
import type { ClientResponse } from "../../api/types";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
@@ -35,7 +34,7 @@ export default function UsersScreen() {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [clientsData, settings] = await Promise.all([
|
const [clientsData, settings] = await Promise.all([
|
||||||
getAllClients(),
|
getCabineAllClients(),
|
||||||
getPublicSettings(),
|
getPublicSettings(),
|
||||||
]);
|
]);
|
||||||
setClients(clientsData);
|
setClients(clientsData);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
|
|||||||
clientUsername?: string;
|
clientUsername?: string;
|
||||||
clientNom?: string;
|
clientNom?: string;
|
||||||
clientPrenom?: string;
|
clientPrenom?: string;
|
||||||
items?: Array<{ produit: string; quantite: number; prix: number }>;
|
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
@@ -125,6 +125,12 @@ export default function DashboardScreen() {
|
|||||||
description: string;
|
description: string;
|
||||||
}>({ visible: false, deliveryId: null, issueType: null, description: "" });
|
}>({ visible: false, deliveryId: null, issueType: null, description: "" });
|
||||||
|
|
||||||
|
const ABSENT_TIMEOUT_SECS = 300; // 5 minutes
|
||||||
|
const arrivedAtRef = useRef<Record<number, number>>({});
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState<
|
||||||
|
Record<number, number>
|
||||||
|
>({});
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = useMemo(
|
const STATUS_COLORS: Record<string, string> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
available: colors.success,
|
available: colors.success,
|
||||||
@@ -276,11 +282,21 @@ export default function DashboardScreen() {
|
|||||||
|
|
||||||
setDeliveries(enriched);
|
setDeliveries(enriched);
|
||||||
|
|
||||||
|
// Enregistrer le timestamp d'arrivée pour les livraisons "arrived"
|
||||||
|
for (const d of enriched) {
|
||||||
|
if (
|
||||||
|
d.status === "arrived" ||
|
||||||
|
(d.status === "livre" && !arrivedAtRef.current[d.id])
|
||||||
|
) {
|
||||||
|
arrivedAtRef.current[d.id] = Date.now();
|
||||||
|
} else if (d.status !== "arrived" && d.status !== "livre") {
|
||||||
|
delete arrivedAtRef.current[d.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activeDelivery =
|
const activeDelivery =
|
||||||
enriched.find(
|
enriched.find((d) => d.status === "en_route") ||
|
||||||
(d) =>
|
enriched.find((d) => d.status === "assigned");
|
||||||
d.status === "en_route",
|
|
||||||
) || enriched.find((d) => d.status === "assigned");
|
|
||||||
if (activeDelivery && activeDelivery.adresse) {
|
if (activeDelivery && activeDelivery.adresse) {
|
||||||
calcRoute(activeDelivery.adresse);
|
calcRoute(activeDelivery.adresse);
|
||||||
}
|
}
|
||||||
@@ -292,7 +308,10 @@ export default function DashboardScreen() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
|
getLivreurTelegramStatus().then((s) => {
|
||||||
|
setTgLinked(s.linked);
|
||||||
|
setTgEnabled(s.enabled);
|
||||||
|
});
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -302,6 +321,18 @@ export default function DashboardScreen() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const updated: Record<number, number> = {};
|
||||||
|
for (const [id, ts] of Object.entries(arrivedAtRef.current)) {
|
||||||
|
updated[Number(id)] = Math.floor((now - ts) / 1000);
|
||||||
|
}
|
||||||
|
setElapsedSeconds(updated);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Quand GPS devient disponible, rejouer la route en attente
|
// Quand GPS devient disponible, rejouer la route en attente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastCoords && pendingRouteAddress.current) {
|
if (lastCoords && pendingRouteAddress.current) {
|
||||||
@@ -524,7 +555,12 @@ export default function DashboardScreen() {
|
|||||||
cancelModal.description,
|
cancelModal.description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" });
|
setCancelModal({
|
||||||
|
visible: false,
|
||||||
|
deliveryId: null,
|
||||||
|
issueType: null,
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showSuccess("Succès", "Livraison annulée");
|
showSuccess("Succès", "Livraison annulée");
|
||||||
loadData();
|
loadData();
|
||||||
@@ -533,9 +569,34 @@ export default function DashboardScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClientAbsent = async (deliveryId: number) => {
|
||||||
|
const lat = lastCoords?.lat || 0;
|
||||||
|
const lng = lastCoords?.lng || 0;
|
||||||
|
const res = await updateDeliveryStatus(
|
||||||
|
deliveryId,
|
||||||
|
"cancelled",
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
"Client absent",
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
await reportDeliveryIssue(
|
||||||
|
deliveryId,
|
||||||
|
"client_absent",
|
||||||
|
"Client non présent après attente",
|
||||||
|
);
|
||||||
|
delete arrivedAtRef.current[deliveryId];
|
||||||
|
showSuccess("Commande annulée", "Une amende a été appliquée au client");
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
showError("Erreur", res.error || "Erreur");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openNavigation = async (deliveryId: number, address: string) => {
|
const openNavigation = async (deliveryId: number, address: string) => {
|
||||||
const res = await getDeliveryNavLink(deliveryId);
|
const res = await getDeliveryNavLink(deliveryId);
|
||||||
const link = res.success && res.waze_app
|
const link =
|
||||||
|
res.success && res.waze_app
|
||||||
? res.waze_app
|
? res.waze_app
|
||||||
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
|
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
|
||||||
Linking.openURL(link);
|
Linking.openURL(link);
|
||||||
@@ -638,18 +699,32 @@ export default function DashboardScreen() {
|
|||||||
Produits ({item.items.length})
|
Produits ({item.items.length})
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{item.items.some(p => p.is_reward) && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 6, padding: 6, marginBottom: 6 }}>
|
||||||
|
<Ionicons name="gift-outline" size={15} color="#f59e0b" />
|
||||||
|
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
|
||||||
|
Cette commande contient un article offert (récompense client)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{item.items.map((prod, idx) => (
|
{item.items.map((prod, idx) => (
|
||||||
<View key={idx} style={styles.itemRow}>
|
<View key={idx} style={styles.itemRow}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.itemName}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
{prod.produit}
|
<Text style={styles.itemName}>{prod.produit}</Text>
|
||||||
</Text>
|
{prod.is_reward && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
||||||
|
<Ionicons name="gift-outline" size={10} color="#f59e0b" />
|
||||||
|
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<Text style={styles.itemQty}>
|
<Text style={styles.itemQty}>
|
||||||
Quantité: {prod.quantite}
|
Quantité: {prod.quantite}{prod.unit || ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.itemPrice}>
|
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||||
{(prod.prix ?? 0).toFixed(2)}€
|
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -660,14 +735,34 @@ export default function DashboardScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{(item.referral_used ?? 0) > 0 && (
|
{(item.referral_used ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
<View style={[styles.totalRow, { marginTop: 2 }]}>
|
<View style={[styles.totalRow, { marginTop: 2 }]}>
|
||||||
<Text style={[styles.totalLabel, { color: colors.success }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.totalLabel,
|
||||||
|
{ color: colors.success },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Parrainage client
|
Parrainage client
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.totalValue, { color: colors.success }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.totalValue,
|
||||||
|
{ color: colors.success },
|
||||||
|
]}
|
||||||
|
>
|
||||||
-{(item.referral_used ?? 0).toFixed(2)}€
|
-{(item.referral_used ?? 0).toFixed(2)}€
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={[styles.totalRow, { marginTop: 2 }]}>
|
||||||
|
<Text style={[styles.totalLabel, { fontWeight: "700" }]}>
|
||||||
|
Net à encaisser
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.totalValue, { fontWeight: "700" }]}>
|
||||||
|
{((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}€
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -675,12 +770,28 @@ export default function DashboardScreen() {
|
|||||||
{(!item.items || item.items.length === 0) && (
|
{(!item.items || item.items.length === 0) && (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.priceOnly}>
|
<Text style={styles.priceOnly}>
|
||||||
{item.total_prix ?? 0}€
|
{(item.referral_used ?? 0) > 0 ? "Brut : " : ""}
|
||||||
|
{(item.total_prix ?? 0)}€
|
||||||
</Text>
|
</Text>
|
||||||
{(item.referral_used ?? 0) > 0 && (
|
{(item.referral_used ?? 0) > 0 && (
|
||||||
<Text style={[styles.priceOnly, { color: colors.success, marginTop: 2 }]}>
|
<>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.priceOnly,
|
||||||
|
{ color: colors.success, marginTop: 2 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}€
|
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}€
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.priceOnly,
|
||||||
|
{ fontWeight: "700", marginTop: 2 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Net à encaisser: {((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}€
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -735,8 +846,15 @@ export default function DashboardScreen() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.status === "arrived" && (
|
{item.status === "arrived" && (() => {
|
||||||
<View style={{ flexDirection: "row", gap: spacing.s, marginTop: spacing.s }}>
|
const elapsed = elapsedSeconds[item.id] || 0;
|
||||||
|
const remaining = Math.max(0, ABSENT_TIMEOUT_SECS - elapsed);
|
||||||
|
const showAbsent = elapsed >= ABSENT_TIMEOUT_SECS;
|
||||||
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: spacing.s, gap: spacing.s }}>
|
||||||
|
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
||||||
<Button
|
<Button
|
||||||
title="Terminer"
|
title="Terminer"
|
||||||
onPress={() => handleCompleteDelivery(item.id)}
|
onPress={() => handleCompleteDelivery(item.id)}
|
||||||
@@ -755,7 +873,25 @@ export default function DashboardScreen() {
|
|||||||
style={{ flex: 1, backgroundColor: colors.danger }}
|
style={{ flex: 1, backgroundColor: colors.danger }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<Button
|
||||||
|
title="Client pas là"
|
||||||
|
onPress={() => handleClientAbsent(item.id)}
|
||||||
|
style={{ backgroundColor: colors.warning }}
|
||||||
|
/>
|
||||||
|
{showAbsent ? (
|
||||||
|
<Button
|
||||||
|
title="Client absent (5 min écoulées)"
|
||||||
|
onPress={() => handleClientAbsent(item.id)}
|
||||||
|
style={{ backgroundColor: colors.danger }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center" }}>
|
||||||
|
Client absent dans {mm}:{ss}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -777,7 +913,10 @@ export default function DashboardScreen() {
|
|||||||
const handleUnlinkTelegram = async () => {
|
const handleUnlinkTelegram = async () => {
|
||||||
await unlinkLivreurTelegram();
|
await unlinkLivreurTelegram();
|
||||||
setTgLinked(false);
|
setTgLinked(false);
|
||||||
showSuccess("Telegram délié", "Vous ne recevrez plus de notifications Telegram.");
|
showSuccess(
|
||||||
|
"Telegram délié",
|
||||||
|
"Vous ne recevrez plus de notifications Telegram.",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderHeader = () => (
|
const renderHeader = () => (
|
||||||
@@ -904,26 +1043,119 @@ export default function DashboardScreen() {
|
|||||||
|
|
||||||
{/* Carte Telegram */}
|
{/* Carte Telegram */}
|
||||||
{tgEnabled && (
|
{tgEnabled && (
|
||||||
<View style={{ marginHorizontal: spacing.l, marginBottom: spacing.m, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
|
<View
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
|
style={{
|
||||||
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
|
marginHorizontal: spacing.l,
|
||||||
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
|
marginBottom: spacing.m,
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.l,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="paper-plane-outline"
|
||||||
|
size={18}
|
||||||
|
color="#2AABEE"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Notifications Telegram
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{tgLinked ? (
|
{tgLinked ? (
|
||||||
<View>
|
<View>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
|
<View
|
||||||
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
|
style={{
|
||||||
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte lié</Text>
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-circle"
|
||||||
|
size={14}
|
||||||
|
color={colors.success}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.success,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Compte lié
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
|
<TouchableOpacity
|
||||||
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
|
onPress={handleUnlinkTelegram}
|
||||||
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier</Text>
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
padding: spacing.s,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.danger + "66",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="unlink-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Délier
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
|
<TouchableOpacity
|
||||||
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
|
onPress={handleLinkTelegram}
|
||||||
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
|
disabled={tgLoading}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
padding: spacing.m,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
backgroundColor: "#2AABEE",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="paper-plane-outline"
|
||||||
|
size={14}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tgLoading ? "Génération..." : "Lier Telegram"}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -1769,21 +2001,37 @@ export default function DashboardScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
{detailsDelivery?.items &&
|
{detailsDelivery?.items &&
|
||||||
detailsDelivery.items.length > 0 ? (
|
detailsDelivery.items.length > 0 ? (
|
||||||
detailsDelivery.items.map((prod, idx) => (
|
<>
|
||||||
|
{detailsDelivery.items.some(p => p.is_reward) && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 8, padding: 8, marginBottom: 8 }}>
|
||||||
|
<Ionicons name="gift-outline" size={16} color="#f59e0b" />
|
||||||
|
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
|
||||||
|
Cette commande contient un article offert (récompense client)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{detailsDelivery.items.map((prod, idx) => (
|
||||||
<View key={idx} style={styles.detailProductRow}>
|
<View key={idx} style={styles.detailProductRow}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.detailProductName}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
{prod.produit}
|
<Text style={styles.detailProductName}>{prod.produit}</Text>
|
||||||
</Text>
|
{prod.is_reward && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
||||||
|
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
|
||||||
|
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<Text style={styles.detailProductQty}>
|
<Text style={styles.detailProductQty}>
|
||||||
Quantité : {prod.quantite}
|
Quantité : {prod.quantite}{prod.unit || ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.detailProductPrice}>
|
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||||
{(prod.prix ?? 0).toFixed(2)}€
|
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))
|
))}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
||||||
)}
|
)}
|
||||||
@@ -1796,14 +2044,34 @@ export default function DashboardScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{(detailsDelivery?.referral_used ?? 0) > 0 && (
|
{(detailsDelivery?.referral_used ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
|
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
|
||||||
<Text style={[styles.detailTotalLabel, { color: colors.success }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailTotalLabel,
|
||||||
|
{ color: colors.success },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Parrainage client
|
Parrainage client
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.detailTotalValue, { color: colors.success }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailTotalValue,
|
||||||
|
{ color: colors.success },
|
||||||
|
]}
|
||||||
|
>
|
||||||
-{detailsDelivery?.referral_used?.toFixed(2)}€
|
-{detailsDelivery?.referral_used?.toFixed(2)}€
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
|
||||||
|
<Text style={[styles.detailTotalLabel, { fontWeight: "700" }]}>
|
||||||
|
Net à encaisser
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.detailTotalValue, { fontWeight: "700" }]}>
|
||||||
|
{((detailsDelivery?.total_prix ?? 0) - (detailsDelivery?.referral_used ?? 0)).toFixed(2)}€
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</DetailsModal>
|
</DetailsModal>
|
||||||
@@ -1873,31 +2141,71 @@ export default function DashboardScreen() {
|
|||||||
<DetailsModal
|
<DetailsModal
|
||||||
visible={cancelModal.visible}
|
visible={cancelModal.visible}
|
||||||
onClose={() =>
|
onClose={() =>
|
||||||
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" })
|
setCancelModal({
|
||||||
|
visible: false,
|
||||||
|
deliveryId: null,
|
||||||
|
issueType: null,
|
||||||
|
description: "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
title="Motif de non-livraison"
|
title="Motif de non-livraison"
|
||||||
icon="close-circle-outline"
|
icon="close-circle-outline"
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelInput, { color: colors.textSecondary, fontSize: 13, marginBottom: spacing.s, backgroundColor: "transparent", borderWidth: 0, padding: 0 }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.cancelInput,
|
||||||
|
{
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
Sélectionnez un motif
|
Sélectionnez un motif
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s, marginBottom: spacing.m }}>
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => {
|
{(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => {
|
||||||
const selected = cancelModal.issueType === type;
|
const selected = cancelModal.issueType === type;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={type}
|
key={type}
|
||||||
onPress={() => setCancelModal((prev) => ({ ...prev, issueType: type }))}
|
onPress={() =>
|
||||||
|
setCancelModal((prev) => ({
|
||||||
|
...prev,
|
||||||
|
issueType: type,
|
||||||
|
}))
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: spacing.m,
|
paddingHorizontal: spacing.m,
|
||||||
paddingVertical: spacing.s,
|
paddingVertical: spacing.s,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: selected ? colors.danger : colors.border,
|
borderColor: selected
|
||||||
backgroundColor: selected ? colors.danger + "22" : colors.bgCard,
|
? colors.danger
|
||||||
|
: colors.border,
|
||||||
|
backgroundColor: selected
|
||||||
|
? colors.danger + "22"
|
||||||
|
: colors.bgCard,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: selected
|
||||||
|
? colors.danger
|
||||||
|
: colors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: selected ? colors.danger : colors.textSecondary, fontSize: 13 }}>
|
|
||||||
{ISSUE_LABELS[type]}
|
{ISSUE_LABELS[type]}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -1915,11 +2223,16 @@ export default function DashboardScreen() {
|
|||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelConfirmBtn, !cancelModal.issueType && { opacity: 0.4 }]}
|
style={[
|
||||||
|
styles.cancelConfirmBtn,
|
||||||
|
!cancelModal.issueType && { opacity: 0.4 },
|
||||||
|
]}
|
||||||
onPress={handleCancelDelivery}
|
onPress={handleCancelDelivery}
|
||||||
disabled={!cancelModal.issueType}
|
disabled={!cancelModal.issueType}
|
||||||
>
|
>
|
||||||
<Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text>
|
<Text style={styles.cancelConfirmText}>
|
||||||
|
Confirmer l'annulation
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DetailsModal>
|
</DetailsModal>
|
||||||
|
|
||||||
|
|||||||
@@ -5,34 +5,114 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { spacing, fontSize } from "../../theme";
|
import { spacing, fontSize } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getMyDeliveries, getMyStatus } from "../../api/api_delivery";
|
import { getMyDeliveries, getMyStats } from "../../api/api_delivery";
|
||||||
import type { DeliveryItem } from "../../api/types";
|
import type { DeliveryItem, } from "../../api/types";
|
||||||
|
import type { StatPoint } from "../../api/api_delivery";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
import Card from "../../components/ui/Card";
|
import Card from "../../components/ui/Card";
|
||||||
|
|
||||||
|
type Period = "day" | "week" | "month";
|
||||||
|
|
||||||
|
const BAR_MAX_HEIGHT = 110;
|
||||||
|
const BAR_WIDTH = 36;
|
||||||
|
const BAR_GAP = 8;
|
||||||
|
|
||||||
|
function BarChart({ data, colors }: { data: StatPoint[]; colors: any }) {
|
||||||
|
const maxVal = Math.max(...data.map((d) => d.count), 1);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: "center", paddingVertical: spacing.xl }}>
|
||||||
|
<Ionicons name="bar-chart-outline" size={36} color={colors.textMuted} />
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, marginTop: spacing.s }}>
|
||||||
|
Aucune donnée sur cette période
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginTop: spacing.m }}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "flex-end", paddingBottom: spacing.s, paddingHorizontal: 4 }}>
|
||||||
|
{data.map((point, i) => {
|
||||||
|
const val = point.count;
|
||||||
|
const barH = Math.max(4, (val / maxVal) * BAR_MAX_HEIGHT);
|
||||||
|
const isLast = i === data.length - 1;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: isLast ? 0 : BAR_GAP,
|
||||||
|
width: BAR_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: 9, marginBottom: 3 }}>
|
||||||
|
{val > 0 ? String(val) : ""}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: BAR_WIDTH - 6,
|
||||||
|
height: barH,
|
||||||
|
backgroundColor: val > 0 ? colors.accent : colors.border,
|
||||||
|
borderRadius: 5,
|
||||||
|
opacity: val > 0 ? 1 : 0.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 9,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{point.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatsScreen() {
|
export default function StatsScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
|
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
|
||||||
|
const [byDay, setByDay] = useState<StatPoint[]>([]);
|
||||||
|
const [byWeek, setByWeek] = useState<StatPoint[]>([]);
|
||||||
|
const [byMonth, setByMonth] = useState<StatPoint[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [period, setPeriod] = useState<Period>("week");
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMyDeliveries();
|
const [delivRes, statsRes] = await Promise.all([
|
||||||
if (res.success && res.deliveries) setDeliveries(res.deliveries);
|
getMyDeliveries(),
|
||||||
|
getMyStats(),
|
||||||
|
]);
|
||||||
|
if (delivRes.success && delivRes.deliveries) setDeliveries(delivRes.deliveries);
|
||||||
|
if (statsRes.success) {
|
||||||
|
setByDay(statsRes.by_day ?? []);
|
||||||
|
setByWeek(statsRes.by_week ?? []);
|
||||||
|
setByMonth(statsRes.by_month ?? []);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -40,39 +120,22 @@ export default function StatsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const total = deliveries.length;
|
const total = deliveries.length;
|
||||||
const completed = deliveries.filter((d) => d.status === "livre").length;
|
const completed = deliveries.filter((d) => d.status === "livre" || d.status === "approved").length;
|
||||||
const inProgress = deliveries.filter((d) => d.status === "en_route").length;
|
const inProgress = deliveries.filter((d) => d.status === "en_route").length;
|
||||||
const pending = deliveries.filter((d) => d.status === "assigned").length;
|
const pending = deliveries.filter((d) => d.status === "assigned").length;
|
||||||
const totalRevenue = deliveries
|
|
||||||
.filter((d) => d.status === "livre")
|
|
||||||
.reduce((s, d) => s + d.total_prix, 0);
|
|
||||||
|
|
||||||
const stats = [
|
const chartData = period === "day" ? byDay : period === "week" ? byWeek : byMonth;
|
||||||
{
|
|
||||||
label: "Total livraisons",
|
const periodTotal = useMemo(
|
||||||
value: total.toString(),
|
() => chartData.reduce((acc, p) => acc + p.count, 0),
|
||||||
icon: "cube-outline" as const,
|
[chartData],
|
||||||
color: colors.accent,
|
);
|
||||||
},
|
|
||||||
{
|
const periodLabels: Record<Period, string> = {
|
||||||
label: "Complétées",
|
day: "30 derniers jours",
|
||||||
value: completed.toString(),
|
week: "12 dernières semaines",
|
||||||
icon: "checkmark-circle-outline" as const,
|
month: "12 derniers mois",
|
||||||
color: colors.success,
|
};
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "En cours",
|
|
||||||
value: inProgress.toString(),
|
|
||||||
icon: "time-outline" as const,
|
|
||||||
color: colors.warning,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "En attente",
|
|
||||||
value: pending.toString(),
|
|
||||||
icon: "hourglass-outline" as const,
|
|
||||||
color: colors.info,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const styles = useMemo(
|
const styles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -84,57 +147,123 @@ export default function StatsScreen() {
|
|||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
marginBottom: spacing.l,
|
marginBottom: spacing.l,
|
||||||
},
|
},
|
||||||
grid: {
|
grid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.m },
|
||||||
flexDirection: "row",
|
statCard: { width: "47%", alignItems: "center", paddingVertical: spacing.l },
|
||||||
flexWrap: "wrap",
|
statValue: { fontSize: fontSize.xxl, fontWeight: "700", marginTop: spacing.s },
|
||||||
gap: spacing.m,
|
|
||||||
},
|
|
||||||
statCard: {
|
|
||||||
width: "47%",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: spacing.l,
|
|
||||||
},
|
|
||||||
statValue: {
|
|
||||||
fontSize: fontSize.xxl,
|
|
||||||
fontWeight: "700",
|
|
||||||
marginTop: spacing.s,
|
|
||||||
},
|
|
||||||
statLabel: {
|
statLabel: {
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
fontSize: fontSize.xs,
|
fontSize: fontSize.xs,
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: "700",
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
},
|
||||||
|
periodRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.s,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
periodBtn: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: spacing.s,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
periodBtnActive: {
|
||||||
|
backgroundColor: colors.accent + "22",
|
||||||
|
borderColor: colors.accent,
|
||||||
|
},
|
||||||
|
periodBtnText: {
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
periodBtnTextActive: { color: colors.accent },
|
||||||
|
chartCard: { paddingBottom: spacing.s },
|
||||||
|
summaryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: spacing.s,
|
||||||
|
paddingTop: spacing.s,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
summaryItem: { alignItems: "center", flex: 1 },
|
||||||
|
summaryValue: { color: colors.textWhite, fontSize: fontSize.lg, fontWeight: "700" },
|
||||||
|
summaryLabel: { color: colors.textMuted, fontSize: fontSize.xs, marginTop: 2 },
|
||||||
|
periodHint: { color: colors.textMuted, fontSize: fontSize.xs, marginBottom: spacing.s },
|
||||||
}),
|
}),
|
||||||
[colors],
|
[colors],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ label: "Total livraisons", value: total.toString(), icon: "cube-outline" as const, color: colors.accent },
|
||||||
|
{ label: "Complétées", value: completed.toString(), icon: "checkmark-circle-outline" as const, color: colors.success },
|
||||||
|
{ label: "En cours", value: inProgress.toString(), icon: "time-outline" as const, color: colors.warning },
|
||||||
|
{ label: "En attente", value: pending.toString(), icon: "hourglass-outline" as const, color: colors.info },
|
||||||
|
];
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner message="Chargement stats..." />;
|
if (loading) return <LoadingSpinner message="Chargement stats..." />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={{ padding: spacing.l }}
|
contentContainerStyle={{ padding: spacing.l }}
|
||||||
refreshControl={
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.success} />}
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
tintColor={colors.success}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Mes performances</Text>
|
<Text style={styles.title}>Mes performances</Text>
|
||||||
|
|
||||||
|
{/* Cartes résumé */}
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{stats.map((s, i) => (
|
{summaryCards.map((s, i) => (
|
||||||
<Card key={i} style={styles.statCard}>
|
<Card key={i} style={styles.statCard}>
|
||||||
<Ionicons name={s.icon} size={28} color={s.color} />
|
<Ionicons name={s.icon} size={28} color={s.color} />
|
||||||
<Text style={[styles.statValue, { color: s.color }]}>
|
<Text style={[styles.statValue, { color: s.color }]}>{s.value}</Text>
|
||||||
{s.value}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.statLabel}>{s.label}</Text>
|
<Text style={styles.statLabel}>{s.label}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Section graphiques */}
|
||||||
|
<Text style={styles.sectionTitle}>Évolution</Text>
|
||||||
|
|
||||||
|
{/* Sélecteur période */}
|
||||||
|
<View style={styles.periodRow}>
|
||||||
|
{(["day", "week", "month"] as Period[]).map((p) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p}
|
||||||
|
style={[styles.periodBtn, period === p && styles.periodBtnActive]}
|
||||||
|
onPress={() => setPeriod(p)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.periodBtnText, period === p && styles.periodBtnTextActive]}>
|
||||||
|
{p === "day" ? "Jour" : p === "week" ? "Semaine" : "Mois"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card style={styles.chartCard}>
|
||||||
|
<Text style={styles.periodHint}>{periodLabels[period]}</Text>
|
||||||
|
|
||||||
|
<BarChart data={chartData} colors={colors} />
|
||||||
|
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={styles.summaryValue}>{periodTotal}</Text>
|
||||||
|
<Text style={styles.summaryLabel}>livraisons</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
updates: {
|
||||||
|
...config.updates,
|
||||||
|
...(updateUrl ? { url: updateUrl } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
+2
-1
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Milieu Nantais",
|
"name": "Milieu Nantais",
|
||||||
"slug": "frontend-client",
|
"slug": "frontend-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
|
"softwareKeyboardLayoutMode": "pan",
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "com.uberstup.clientpanel",
|
"package": "com.uberstup.clientpanel",
|
||||||
"versionCode": 1,
|
"versionCode": 1,
|
||||||
|
|||||||
+7
-8
@@ -11,23 +11,21 @@
|
|||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"gradleCommand": ":app:assembleDebug"
|
"gradleCommand": ":app:assembleDebug"
|
||||||
},
|
},
|
||||||
"ios": {
|
|
||||||
"simulator": true
|
|
||||||
},
|
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://5.181.0.112.nip.io"
|
"EXPO_PUBLIC_API_URL": "http://localhost:8080"
|
||||||
},
|
},
|
||||||
"channel": "development"
|
"channel": "development"
|
||||||
},
|
},
|
||||||
"preview": {
|
"pre-prod": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://5.181.0.112.nip.io"
|
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
|
||||||
|
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
|
||||||
},
|
},
|
||||||
"channel": "preview"
|
"channel": "pre-prod"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -36,7 +34,8 @@
|
|||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://mln-uber.club"
|
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
|
||||||
|
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
|
||||||
},
|
},
|
||||||
"channel": "production"
|
"channel": "production"
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-20
@@ -1,4 +1,5 @@
|
|||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
import { API_BASE_URL } from "./client";
|
||||||
import type {
|
import type {
|
||||||
ConfirmReceptionResponse,
|
ConfirmReceptionResponse,
|
||||||
CheckoutCartResponse,
|
CheckoutCartResponse,
|
||||||
@@ -12,7 +13,7 @@ import type {
|
|||||||
import { getToken } from "../auth/tokenStorage";
|
import { getToken } from "../auth/tokenStorage";
|
||||||
import { extractUsernameFromToken } from "../auth/jwtUtils";
|
import { extractUsernameFromToken } from "../auth/jwtUtils";
|
||||||
|
|
||||||
const V1 = `${process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club"}/api/v1`;
|
const V1 = `${API_BASE_URL}/api/v1`;
|
||||||
|
|
||||||
export const getJwtUsername = async (): Promise<string | null> => {
|
export const getJwtUsername = async (): Promise<string | null> => {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
@@ -110,10 +111,6 @@ export const logoutUser = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PRODUCTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -160,10 +157,6 @@ export const getProductById = async (id: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CART
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getCart = async (username: string) => {
|
export const getCart = async (username: string) => {
|
||||||
const jwtUsername = await getJwtUsername();
|
const jwtUsername = await getJwtUsername();
|
||||||
if (!jwtUsername || jwtUsername !== username) {
|
if (!jwtUsername || jwtUsername !== username) {
|
||||||
@@ -247,10 +240,6 @@ export const clearCart = async (username: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// ORDERS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getMyOrders = async () => {
|
export const getMyOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(`${V1}/my-commands`);
|
const { data } = await apiClient.get(`${V1}/my-commands`);
|
||||||
@@ -787,6 +776,8 @@ export interface PublicSettings {
|
|||||||
crypto_only: boolean;
|
crypto_only: boolean;
|
||||||
nowpayments_currencies: string[];
|
nowpayments_currencies: string[];
|
||||||
telegram_notifications_enabled: boolean;
|
telegram_notifications_enabled: boolean;
|
||||||
|
two_fa_enabled: boolean;
|
||||||
|
contact_telegram: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||||
@@ -801,6 +792,8 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
|||||||
crypto_only: false,
|
crypto_only: false,
|
||||||
nowpayments_currencies: [],
|
nowpayments_currencies: [],
|
||||||
telegram_notifications_enabled: false,
|
telegram_notifications_enabled: false,
|
||||||
|
two_fa_enabled: false,
|
||||||
|
contact_telegram: "",
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(`${V1}/app-settings`);
|
const { data } = await apiClient.get(`${V1}/app-settings`);
|
||||||
@@ -821,6 +814,8 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
|||||||
: [],
|
: [],
|
||||||
telegram_notifications_enabled:
|
telegram_notifications_enabled:
|
||||||
data.telegram_notifications_enabled ?? false,
|
data.telegram_notifications_enabled ?? false,
|
||||||
|
two_fa_enabled: data.two_fa_enabled ?? false,
|
||||||
|
contact_telegram: data.contact_telegram || "",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return defaults;
|
||||||
@@ -889,17 +884,119 @@ export const unlinkTelegram = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const get2FAStatus = async (): Promise<{
|
||||||
|
two_fa_enabled: boolean;
|
||||||
|
telegram_linked: boolean;
|
||||||
|
admin_2fa_enabled: boolean;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`${V1}/two-fa/status`);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
two_fa_enabled: false,
|
||||||
|
telegram_linked: false,
|
||||||
|
admin_2fa_enabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggle2FA = async (
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post(`${V1}/two-fa/toggle`, {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return { success: data.success ?? true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e?.response?.data?.error || "Erreur" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🏆 POINTS — RÉCOMPENSES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type RewardCategoryConfig = {
|
||||||
|
category: string;
|
||||||
|
all_products: boolean;
|
||||||
|
product_ids: number[];
|
||||||
|
product_names: string[];
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointsPoolInfo = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
points: number;
|
||||||
|
rewards_earned: number;
|
||||||
|
rewards_claimed: number;
|
||||||
|
rewards_available: number;
|
||||||
|
eligible_configs: RewardCategoryConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RewardItemConfig = {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointsRewardConfig = {
|
||||||
|
threshold: number;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
reward_items: RewardItemConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMyPointsRewards = async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
pools: PointsPoolInfo[];
|
||||||
|
reward: PointsRewardConfig | null;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`${V1}/points/rewards`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
enabled: data.enabled ?? false,
|
||||||
|
pools: data.pools ?? [],
|
||||||
|
reward: data.reward ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { success: false, enabled: false, pools: [], reward: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const claimMyReward = async (poolKey: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
description?: string;
|
||||||
|
remaining_rewards?: number;
|
||||||
|
product_added?: boolean;
|
||||||
|
product_name?: string;
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post(`${V1}/points/claim`, { pool_key: poolKey });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
description: data.description,
|
||||||
|
remaining_rewards: data.remaining_rewards,
|
||||||
|
product_added: data.product_added,
|
||||||
|
product_name: data.product_name,
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e?.response?.data?.error || "Erreur" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const calculateOrderTotal = (order: any): number => {
|
export const calculateOrderTotal = (order: any): number => {
|
||||||
|
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 (typeof order.total === "number" && order.total > 0) return order.total;
|
||||||
if (typeof order.total_prix === "number" && order.total_prix > 0)
|
|
||||||
return order.total_prix;
|
|
||||||
if (Array.isArray(order.items) && order.items.length > 0) {
|
if (Array.isArray(order.items) && order.items.length > 0) {
|
||||||
return order.items.reduce((sum: number, item: any) => {
|
return order.items.reduce((sum: number, item: any) => {
|
||||||
return (
|
return sum + (item.prix || item.price || 0) * (item.quantite || item.quantity || 1);
|
||||||
sum +
|
|
||||||
(item.prix || item.price || 0) *
|
|
||||||
(item.quantite || item.quantity || 1)
|
|
||||||
);
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export interface TrackingResponse {
|
|||||||
export interface ProductPrice {
|
export interface ProductPrice {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
active_price?: boolean;
|
||||||
}
|
}
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -567,6 +568,7 @@ export interface CompletedOrder {
|
|||||||
status: string;
|
status: string;
|
||||||
adresse: string;
|
adresse: string;
|
||||||
total_prix: number;
|
total_prix: number;
|
||||||
|
referral_used?: number;
|
||||||
livreur_assign?: string;
|
livreur_assign?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
||||||
|
|
||||||
// Change this to your server IP/domain
|
export const API_BASE_URL =
|
||||||
export const API_BASE_URL = "https://mln-uber.club";
|
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -26,7 +26,6 @@ apiClient.interceptors.request.use(async (config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Response interceptor: handle common errors
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const USERNAME_KEY = "username";
|
|||||||
const ADMIN_USERNAME_KEY = "admin_username";
|
const ADMIN_USERNAME_KEY = "admin_username";
|
||||||
const ROLE_KEY = "user_role";
|
const ROLE_KEY = "user_role";
|
||||||
|
|
||||||
// Client token
|
|
||||||
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
||||||
export const setToken = (token: string) =>
|
export const setToken = (token: string) =>
|
||||||
AsyncStorage.setItem(TOKEN_KEY, token);
|
AsyncStorage.setItem(TOKEN_KEY, token);
|
||||||
@@ -22,7 +21,6 @@ export const setUsername = (username: string) =>
|
|||||||
AsyncStorage.setItem(USERNAME_KEY, username);
|
AsyncStorage.setItem(USERNAME_KEY, username);
|
||||||
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
||||||
|
|
||||||
// Admin username
|
|
||||||
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
||||||
export const setAdminUsername = (username: string) =>
|
export const setAdminUsername = (username: string) =>
|
||||||
AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
|
|
||||||
function getTextColor(hex: string): string {
|
function getTextColor(hex: string): string {
|
||||||
const h = hex.replace("#", "");
|
const h = hex.replace("#", "");
|
||||||
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
const full =
|
||||||
|
h.length === 3
|
||||||
|
? h
|
||||||
|
.split("")
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join("")
|
||||||
|
: h;
|
||||||
const r = parseInt(full.slice(0, 2), 16);
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
const g = parseInt(full.slice(2, 4), 16);
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
const b = parseInt(full.slice(4, 6), 16);
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
@@ -36,19 +42,31 @@ interface ProductCardProps {
|
|||||||
category: string;
|
category: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
prices?: Array<{ quantity: number; price: number }>;
|
coming_soon?: boolean;
|
||||||
|
prices?: Array<{
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
active_price?: boolean;
|
||||||
|
}>;
|
||||||
media?: Array<{ url: string; type: string }>;
|
media?: Array<{ url: string; type: string }>;
|
||||||
};
|
};
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
categoryColor?: string;
|
categoryColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product, onPress, categoryColor }: ProductCardProps) {
|
export default function ProductCard({
|
||||||
|
product,
|
||||||
|
onPress,
|
||||||
|
categoryColor,
|
||||||
|
}: ProductCardProps) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { addToCart } = useCart();
|
const { addToCart } = useCart();
|
||||||
const catColor = categoryColor ?? colors.accent;
|
const catColor = categoryColor ?? colors.accent;
|
||||||
const isSoldOut = product.stock <= 0;
|
const isSoldOut = product.stock <= 0;
|
||||||
const firstPrice = product.prices?.[0]?.price ?? null;
|
const isComingSoon = product.coming_soon === true;
|
||||||
|
const activePrices =
|
||||||
|
product.prices?.filter((p) => p.active_price !== false) ?? [];
|
||||||
|
const firstPrice = activePrices[0]?.price ?? null;
|
||||||
|
|
||||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||||
@@ -156,6 +174,13 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{isComingSoon && (
|
||||||
|
<View style={styles.comingSoonOverlay}>
|
||||||
|
<View style={styles.comingSoonBox}>
|
||||||
|
<Text style={styles.comingSoonText}>BIENTÔT DISPONIBLE</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -210,13 +235,13 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
style={[
|
style={[
|
||||||
styles.quickAddBtn,
|
styles.quickAddBtn,
|
||||||
{ backgroundColor: catColor },
|
{ backgroundColor: catColor },
|
||||||
isSoldOut && {
|
(isSoldOut || isComingSoon) && {
|
||||||
backgroundColor: colors.textMuted,
|
backgroundColor: colors.textMuted,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onPress={handleQuickAdd}
|
onPress={handleQuickAdd}
|
||||||
disabled={isSoldOut}
|
disabled={isSoldOut || isComingSoon}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -227,6 +252,8 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
>
|
>
|
||||||
{isSoldOut
|
{isSoldOut
|
||||||
? "Rupture de stock"
|
? "Rupture de stock"
|
||||||
|
: isComingSoon
|
||||||
|
? "Bientôt disponible"
|
||||||
: "Ajouter rapidement"}
|
: "Ajouter rapidement"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -267,7 +294,7 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
style={styles.pickerScroll}
|
style={styles.pickerScroll}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{product.prices?.map((p) => (
|
{activePrices.map((p) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={p.quantity}
|
key={p.quantity}
|
||||||
style={[
|
style={[
|
||||||
@@ -287,7 +314,8 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
{ color: colors.textWhite },
|
{ color: colors.textWhite },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{p.quantity}{product.unit || "g"}
|
{p.quantity}
|
||||||
|
{product.unit || "g"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -449,6 +477,32 @@ const styles = StyleSheet.create({
|
|||||||
textShadowOffset: { width: 2, height: 2 },
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
textShadowRadius: 6,
|
textShadowRadius: 6,
|
||||||
},
|
},
|
||||||
|
comingSoonOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
comingSoonBox: {
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(34,197,94,0.95)",
|
||||||
|
paddingHorizontal: 22,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
color: "rgba(34,197,94,0.95)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 6,
|
||||||
|
},
|
||||||
info: { padding: spacing.m, borderTopWidth: 1 },
|
info: { padding: spacing.m, borderTopWidth: 1 },
|
||||||
name: {
|
name: {
|
||||||
fontSize: fontSize.lg,
|
fontSize: fontSize.lg,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function Modal({
|
|||||||
>
|
>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function ChangePasswordScreen() {
|
|||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.container, { backgroundColor: colors.bgPrimary }]}
|
style={[styles.container, { backgroundColor: colors.bgPrimary }]}
|
||||||
behavior="height"
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -85,25 +85,89 @@ const LoginClient = () => {
|
|||||||
if (apiError) setApiError("");
|
if (apiError) setApiError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgSecondary },
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
header: { alignItems: "center", marginBottom: 24 },
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 8,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
apiError: {
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
color: "#991b1b",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
inputGroup: { marginBottom: 16 },
|
||||||
|
label: { marginBottom: 4, fontWeight: "500", color: colors.textSecondary },
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.bgInput,
|
||||||
|
},
|
||||||
|
icon: { marginRight: 8 },
|
||||||
|
input: { flex: 1, height: 40, color: colors.textPrimary },
|
||||||
|
inputError: { borderColor: "#ef4444" },
|
||||||
|
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
|
||||||
|
eyeButton: { padding: 4 },
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
submitText: { color: colors.white, fontWeight: "600", fontSize: 16 },
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
|
style={styles.container}
|
||||||
behavior="height"
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
|
<View style={styles.card}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<FontAwesome name="user-circle" size={48} color="#7c3aed" />
|
<FontAwesome name="user-circle" size={48} color={colors.accent} />
|
||||||
<Text style={[styles.title, { color: colors.textWhite }]}>
|
<Text style={styles.title}>
|
||||||
Connexion Client
|
Connexion Client
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={styles.subtitle}>
|
||||||
style={[styles.subtitle, { color: colors.textMuted }]}
|
|
||||||
>
|
|
||||||
Accédez à votre espace personnel
|
Accédez à votre espace personnel
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -113,20 +177,10 @@ const LoginClient = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<Text
|
<Text style={styles.label}>
|
||||||
style={[styles.label, { color: colors.textSecondary }]}
|
|
||||||
>
|
|
||||||
Username Telegram
|
Username Telegram
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View style={styles.inputWrapper}>
|
||||||
style={[
|
|
||||||
styles.inputWrapper,
|
|
||||||
{
|
|
||||||
borderColor: colors.border,
|
|
||||||
backgroundColor: colors.bgInput,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Feather
|
<Feather
|
||||||
name="user"
|
name="user"
|
||||||
size={20}
|
size={20}
|
||||||
@@ -134,11 +188,7 @@ const LoginClient = () => {
|
|||||||
style={styles.icon}
|
style={styles.icon}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[styles.input, errors.username && styles.inputError]}
|
||||||
styles.input,
|
|
||||||
{ color: colors.textWhite },
|
|
||||||
errors.username && styles.inputError,
|
|
||||||
]}
|
|
||||||
placeholder="Votre username"
|
placeholder="Votre username"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
@@ -156,20 +206,10 @@ const LoginClient = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<Text
|
<Text style={styles.label}>
|
||||||
style={[styles.label, { color: colors.textSecondary }]}
|
|
||||||
>
|
|
||||||
Mot de passe
|
Mot de passe
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View style={styles.inputWrapper}>
|
||||||
style={[
|
|
||||||
styles.inputWrapper,
|
|
||||||
{
|
|
||||||
borderColor: colors.border,
|
|
||||||
backgroundColor: colors.bgInput,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Feather
|
<Feather
|
||||||
name="lock"
|
name="lock"
|
||||||
size={20}
|
size={20}
|
||||||
@@ -177,11 +217,7 @@ const LoginClient = () => {
|
|||||||
style={styles.icon}
|
style={styles.icon}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[styles.input, errors.password && styles.inputError]}
|
||||||
styles.input,
|
|
||||||
{ color: colors.textWhite },
|
|
||||||
errors.password && styles.inputError,
|
|
||||||
]}
|
|
||||||
placeholder="••••••"
|
placeholder="••••••"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
secureTextEntry={!showPassword}
|
secureTextEntry={!showPassword}
|
||||||
@@ -215,7 +251,7 @@ const LoginClient = () => {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator color="white" />
|
<ActivityIndicator color={colors.white} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.submitText}>Se connecter</Text>
|
<Text style={styles.submitText}>Se connecter</Text>
|
||||||
)}
|
)}
|
||||||
@@ -227,52 +263,3 @@ const LoginClient = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default LoginClient;
|
export default LoginClient;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
card: { width: "100%", maxWidth: 400, borderRadius: 16, padding: 24 },
|
|
||||||
header: { alignItems: "center", marginBottom: 24 },
|
|
||||||
title: { fontSize: 24, fontWeight: "bold", marginTop: 8 },
|
|
||||||
subtitle: { fontSize: 14, marginTop: 4, textAlign: "center" },
|
|
||||||
apiError: {
|
|
||||||
backgroundColor: "#fee2e2",
|
|
||||||
color: "#991b1b",
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
marginBottom: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
inputGroup: { marginBottom: 16 },
|
|
||||||
label: { marginBottom: 4, fontWeight: "500" },
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
},
|
|
||||||
icon: { marginRight: 8 },
|
|
||||||
input: { flex: 1, height: 40 },
|
|
||||||
inputError: { borderColor: "#ef4444" },
|
|
||||||
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
|
|
||||||
eyeButton: { padding: 4 },
|
|
||||||
submitButton: {
|
|
||||||
backgroundColor: "#7c3aed",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
submitText: { color: "white", fontWeight: "600", fontSize: 16 },
|
|
||||||
signup: { marginTop: 16, alignItems: "center" },
|
|
||||||
signupText: {},
|
|
||||||
signupLink: { color: "#a78bfa", fontWeight: "600" },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
getReferralBalance,
|
getReferralBalance,
|
||||||
getPublicSettings,
|
getPublicSettings,
|
||||||
getCryptoPaymentStatus,
|
getCryptoPaymentStatus,
|
||||||
|
getTelegramStatus,
|
||||||
|
getMyProfile,
|
||||||
} from "../../api/api";
|
} from "../../api/api";
|
||||||
import type { CryptoPaymentStatus } from "../../api/api";
|
import type { CryptoPaymentStatus } from "../../api/api";
|
||||||
import type { ClientStackParamList } from "../../navigation/types";
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
@@ -57,6 +59,7 @@ export default function CheckoutScreen() {
|
|||||||
// Notif telegram
|
// Notif telegram
|
||||||
const [telegramNotificationEnabled, setTelegramNotificationEnabled] =
|
const [telegramNotificationEnabled, setTelegramNotificationEnabled] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [telegramLinked, setTelegramLinked] = useState(false);
|
||||||
|
|
||||||
// Crypto
|
// Crypto
|
||||||
const [cryptoEnabled, setCryptoEnabled] = useState(false);
|
const [cryptoEnabled, setCryptoEnabled] = useState(false);
|
||||||
@@ -105,6 +108,18 @@ export default function CheckoutScreen() {
|
|||||||
if (savedPhone) setTelephone(savedPhone);
|
if (savedPhone) setTelephone(savedPhone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getMyProfile().then((res) => {
|
||||||
|
if (res.success && res.client) {
|
||||||
|
if (res.client.nom) setNom((prev) => prev || res.client.nom);
|
||||||
|
if (res.client.prenom) setPrenom((prev) => prev || res.client.prenom);
|
||||||
|
if (res.client.telephone) setTelephone((prev) => prev || res.client.telephone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
getTelegramStatus().then((res) => {
|
||||||
|
if (res.linked) setTelegramLinked(true);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
};
|
};
|
||||||
@@ -657,9 +672,12 @@ export default function CheckoutScreen() {
|
|||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior="padding"
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
|
||||||
{/* Résumé commande */}
|
{/* Résumé commande */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>
|
<Text style={styles.sectionTitle}>
|
||||||
@@ -687,9 +705,22 @@ export default function CheckoutScreen() {
|
|||||||
<View style={styles.summaryTotal}>
|
<View style={styles.summaryTotal}>
|
||||||
<Text style={styles.summaryTotalLabel}>Total</Text>
|
<Text style={styles.summaryTotalLabel}>Total</Text>
|
||||||
<Text style={styles.summaryTotalValue}>
|
<Text style={styles.summaryTotalValue}>
|
||||||
{cartTotal.toFixed(2)} €
|
{(useReferral && referralBalance > 0
|
||||||
|
? Math.max(0, cartTotal - referralBalance)
|
||||||
|
: cartTotal
|
||||||
|
).toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{useReferral && referralBalance > 0 && (
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
|
||||||
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm }}>
|
||||||
|
Dont crédit parrainage
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>
|
||||||
|
-{Math.min(referralBalance, cartTotal).toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1011,7 +1042,7 @@ export default function CheckoutScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Note Telegram */}
|
{/* Note Telegram */}
|
||||||
{telegramNotificationEnabled && (
|
{telegramNotificationEnabled && !telegramLinked && (
|
||||||
<View style={styles.telegramNote}>
|
<View style={styles.telegramNote}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="paper-plane-outline"
|
name="paper-plane-outline"
|
||||||
@@ -1166,7 +1197,10 @@ export default function CheckoutScreen() {
|
|||||||
<View style={styles.recapTotalRow}>
|
<View style={styles.recapTotalRow}>
|
||||||
<Text style={styles.recapTotalLabel}>Total</Text>
|
<Text style={styles.recapTotalLabel}>Total</Text>
|
||||||
<Text style={styles.recapTotalValue}>
|
<Text style={styles.recapTotalValue}>
|
||||||
{cartTotal.toFixed(2)} €
|
{(useReferral && referralBalance > 0
|
||||||
|
? Math.max(0, cartTotal - referralBalance)
|
||||||
|
: cartTotal
|
||||||
|
).toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -315,10 +315,12 @@ export default function OrderDetailsScreen() {
|
|||||||
const currentIdx = STATUS_INDEX[status] ?? -1;
|
const currentIdx = STATUS_INDEX[status] ?? -1;
|
||||||
const isCancelled = status === "cancelled";
|
const isCancelled = status === "cancelled";
|
||||||
const address = order.delivery_address || order.adresse || "N/A";
|
const address = order.delivery_address || order.adresse || "N/A";
|
||||||
const total =
|
const rawTotal =
|
||||||
order.total ||
|
order.total ||
|
||||||
order.total_prix ||
|
order.total_prix ||
|
||||||
products.reduce((s: number, p: any) => s + (p.prix || p.price || 0), 0);
|
products.reduce((s: number, p: any) => s + (p.prix || p.price || 0), 0);
|
||||||
|
const referralUsed: number = order.referral_used || 0;
|
||||||
|
const total = rawTotal - referralUsed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -524,6 +526,14 @@ export default function OrderDetailsScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
{referralUsed > 0 && (
|
||||||
|
<View style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>Crédit parrainage</Text>
|
||||||
|
<Text style={[styles.totalValue, { color: colors.accent }]}>
|
||||||
|
-{formatPrice(referralUsed)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Total</Text>
|
<Text style={styles.totalLabel}>Total</Text>
|
||||||
<Text style={styles.totalValue}>{formatPrice(total)}</Text>
|
<Text style={styles.totalValue}>{formatPrice(total)}</Text>
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
getMyPenalties,
|
getMyPenalties,
|
||||||
getPublicSettings,
|
getPublicSettings,
|
||||||
getReferralBalance,
|
getReferralBalance,
|
||||||
|
getMyPointsRewards,
|
||||||
|
claimMyReward,
|
||||||
formatOrderDate,
|
formatOrderDate,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
} from "../../api/api";
|
} from "../../api/api";
|
||||||
import type { PublicSettings } from "../../api/api";
|
import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api";
|
||||||
import type {
|
import type {
|
||||||
CompletedOrder,
|
CompletedOrder,
|
||||||
ClientStats,
|
ClientStats,
|
||||||
@@ -45,18 +47,26 @@ export default function OrderHistoryScreen() {
|
|||||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||||
const [stats, setStats] = useState<ClientStats | null>(null);
|
const [stats, setStats] = useState<ClientStats | null>(null);
|
||||||
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
|
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
|
||||||
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: false, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [], crypto_only: false, telegram_notifications_enabled: false });
|
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: false, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [], crypto_only: false, telegram_notifications_enabled: false, two_fa_enabled: false, contact_telegram: "" });
|
||||||
const [referralBalance, setReferralBalance] = useState(0);
|
const [referralBalance, setReferralBalance] = useState(0);
|
||||||
|
const [pointsRewards, setPointsRewards] = useState<{
|
||||||
|
enabled: boolean;
|
||||||
|
pools: PointsPoolInfo[];
|
||||||
|
reward: PointsRewardConfig | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [claimingPool, setClaimingPool] = useState<string | null>(null);
|
||||||
|
const [claimFeedback, setClaimFeedback] = useState<{ pool: string; type: "success" | "error"; text: string } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [histRes, penRes, settings, refRes] = await Promise.all([
|
const [histRes, penRes, settings, refRes, rewardsRes] = await Promise.all([
|
||||||
getMyCompletedOrders(),
|
getMyCompletedOrders(),
|
||||||
getMyPenalties(),
|
getMyPenalties(),
|
||||||
getPublicSettings(),
|
getPublicSettings(),
|
||||||
getReferralBalance(),
|
getReferralBalance(),
|
||||||
|
getMyPointsRewards(),
|
||||||
]);
|
]);
|
||||||
if (histRes.success) {
|
if (histRes.success) {
|
||||||
setOrders(histRes.commands || []);
|
setOrders(histRes.commands || []);
|
||||||
@@ -69,6 +79,9 @@ export default function OrderHistoryScreen() {
|
|||||||
setReferralBalance(refRes.balance);
|
setReferralBalance(refRes.balance);
|
||||||
}
|
}
|
||||||
setAppSettings(settings);
|
setAppSettings(settings);
|
||||||
|
if (rewardsRes.success && rewardsRes.enabled) {
|
||||||
|
setPointsRewards(rewardsRes);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,6 +90,22 @@ export default function OrderHistoryScreen() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleClaim = async (poolKey: string) => {
|
||||||
|
setClaimingPool(poolKey);
|
||||||
|
setClaimFeedback(null);
|
||||||
|
const res = await claimMyReward(poolKey);
|
||||||
|
setClaimingPool(null);
|
||||||
|
if (res.success) {
|
||||||
|
const text = res.product_added && res.product_name
|
||||||
|
? `🎁 ${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.`
|
||||||
|
: res.description || "Récompense réclamée !";
|
||||||
|
setClaimFeedback({ pool: poolKey, type: "success", text });
|
||||||
|
getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); });
|
||||||
|
} else {
|
||||||
|
setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -132,6 +161,107 @@ export default function OrderHistoryScreen() {
|
|||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: spacing.m,
|
marginBottom: spacing.m,
|
||||||
},
|
},
|
||||||
|
rewardsSection: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#f59e0b44",
|
||||||
|
padding: spacing.m,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
rewardsSectionTitle: {
|
||||||
|
color: "#f59e0b",
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
},
|
||||||
|
rewardsSectionDesc: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
},
|
||||||
|
rewardPoolCard: {
|
||||||
|
backgroundColor: "rgba(245,158,11,0.07)",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(245,158,11,0.2)",
|
||||||
|
padding: spacing.m,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
},
|
||||||
|
rewardPoolName: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
rewardPoolPts: {
|
||||||
|
color: "#f59e0b",
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
rewardAmountBadge: {
|
||||||
|
backgroundColor: "rgba(16,185,129,0.15)",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
paddingHorizontal: spacing.s,
|
||||||
|
paddingVertical: 2,
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
},
|
||||||
|
rewardAmountText: {
|
||||||
|
color: "#10b981",
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
progressBarBg: {
|
||||||
|
height: 5,
|
||||||
|
backgroundColor: "rgba(245,158,11,0.15)",
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#f59e0b",
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
rewardAvailable: {
|
||||||
|
color: "#10b981",
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
rewardRemaining: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
feedbackSuccess: {
|
||||||
|
color: "#10b981",
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
feedbackError: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
claimBtn: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
backgroundColor: "#f59e0b",
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
paddingVertical: spacing.s,
|
||||||
|
paddingHorizontal: spacing.m,
|
||||||
|
},
|
||||||
|
claimBtnText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
referralBtn: {
|
referralBtn: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -342,6 +472,86 @@ export default function OrderHistoryScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Section récompenses */}
|
||||||
|
{pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && (
|
||||||
|
<View style={styles.rewardsSection}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginBottom: spacing.xs }}>
|
||||||
|
<Ionicons name="trophy-outline" size={14} color="#f59e0b" />
|
||||||
|
<Text style={styles.rewardsSectionTitle}>Récompenses</Text>
|
||||||
|
</View>
|
||||||
|
{pointsRewards.reward.description !== "" && (
|
||||||
|
<Text style={styles.rewardsSectionDesc}>{pointsRewards.reward.description}</Text>
|
||||||
|
)}
|
||||||
|
{(pointsRewards.reward.reward_items ?? []).filter((it) => it.price > 0 || it.product_name).length > 0 && (
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginBottom: spacing.s }}>
|
||||||
|
{(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => (
|
||||||
|
<View key={idx} style={[styles.rewardAmountBadge, { flexDirection: "row", alignItems: "center", gap: 4 }]}>
|
||||||
|
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
|
||||||
|
<Text style={styles.rewardAmountText}>
|
||||||
|
{it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? ` — ${it.price}€` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{pointsRewards.pools.map((pool) => {
|
||||||
|
const threshold = pointsRewards.reward!.threshold;
|
||||||
|
const progress = Math.min(1, (pool.points % threshold) / threshold);
|
||||||
|
const remaining = threshold - (pool.points % threshold);
|
||||||
|
const isClaiming = claimingPool === pool.key;
|
||||||
|
const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null;
|
||||||
|
return (
|
||||||
|
<View key={pool.key} style={styles.rewardPoolCard}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: spacing.xs }}>
|
||||||
|
<Text style={styles.rewardPoolName}>{pool.name}</Text>
|
||||||
|
<Text style={styles.rewardPoolPts}>{pool.points} pts</Text>
|
||||||
|
</View>
|
||||||
|
{pool.eligible_configs.length > 0 && (
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginBottom: spacing.xs }}>
|
||||||
|
{pool.eligible_configs.flatMap((cfg) =>
|
||||||
|
cfg.all_products
|
||||||
|
? [<View key={cfg.category} style={styles.rewardAmountBadge}>
|
||||||
|
<Text style={styles.rewardAmountText}>{cfg.category}</Text>
|
||||||
|
</View>]
|
||||||
|
: (cfg.product_names ?? []).map((name) => (
|
||||||
|
<View key={`${cfg.category}-${name}`} style={styles.rewardAmountBadge}>
|
||||||
|
<Text style={styles.rewardAmountText}>{name}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.progressBarBg}>
|
||||||
|
<View style={[styles.progressBarFill, { width: `${Math.round(progress * 100)}%` as any }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={pool.rewards_available > 0 ? styles.rewardAvailable : styles.rewardRemaining}>
|
||||||
|
{pool.rewards_available > 0
|
||||||
|
? `${pool.rewards_available} récompense${pool.rewards_available > 1 ? "s" : ""} disponible${pool.rewards_available > 1 ? "s" : ""}`
|
||||||
|
: `Encore ${remaining} pts pour une récompense`}
|
||||||
|
</Text>
|
||||||
|
{feedback && (
|
||||||
|
<Text style={feedback.type === "success" ? styles.feedbackSuccess : styles.feedbackError}>
|
||||||
|
{feedback.text}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{pool.rewards_available > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.claimBtn, isClaiming && { opacity: 0.5 }]}
|
||||||
|
onPress={() => handleClaim(pool.key)}
|
||||||
|
disabled={isClaiming}
|
||||||
|
>
|
||||||
|
<Ionicons name="gift-outline" size={14} color="#fff" />
|
||||||
|
<Text style={styles.claimBtnText}>
|
||||||
|
{isClaiming ? "..." : "Réclamer ma récompense"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bouton parrainage — visible seulement si activé dans les settings */}
|
{/* Bouton parrainage — visible seulement si activé dans les settings */}
|
||||||
{appSettings.referral_enabled && <TouchableOpacity
|
{appSettings.referral_enabled && <TouchableOpacity
|
||||||
style={styles.referralBtn}
|
style={styles.referralBtn}
|
||||||
@@ -432,7 +642,7 @@ export default function OrderHistoryScreen() {
|
|||||||
)}
|
)}
|
||||||
<View style={styles.orderFooter}>
|
<View style={styles.orderFooter}>
|
||||||
<Text style={styles.orderTotal}>
|
<Text style={styles.orderTotal}>
|
||||||
{formatPrice(order.total_prix || 0)}
|
{formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-forward"
|
name="chevron-forward"
|
||||||
|
|||||||
@@ -385,7 +385,8 @@ export default function OrderTrackingScreen() {
|
|||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
renderItem={({ item: order }) => {
|
renderItem={({ item: order }) => {
|
||||||
const expanded = expandedId === order.id;
|
const expanded = expandedId === order.id;
|
||||||
const total = calculateOrderTotal(order);
|
const gross = calculateOrderTotal(order);
|
||||||
|
const total = Math.max(0, gross - (order.referral_used ?? 0));
|
||||||
const progress = STATUS_PROGRESS[order.status] || 0;
|
const progress = STATUS_PROGRESS[order.status] || 0;
|
||||||
const track = tracking[order.id];
|
const track = tracking[order.id];
|
||||||
const eta = etas[order.id];
|
const eta = etas[order.id];
|
||||||
@@ -444,9 +445,16 @@ export default function OrderTrackingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.cardFooter}>
|
<View style={styles.cardFooter}>
|
||||||
|
<View>
|
||||||
<Text style={styles.cardTotal}>
|
<Text style={styles.cardTotal}>
|
||||||
{formatPrice(total)}
|
{formatPrice(total)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{(order.referral_used ?? 0) > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: colors.success, marginTop: 2 }}>
|
||||||
|
dont -{(order.referral_used as number).toFixed(2)} € parrainage
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={
|
name={
|
||||||
expanded
|
expanded
|
||||||
|
|||||||
@@ -5,27 +5,36 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Linking,
|
Linking,
|
||||||
|
TouchableOpacity,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
import { getReferralBalance } from "../../api/api";
|
import { getReferralBalance, getPublicSettings } from "../../api/api";
|
||||||
|
|
||||||
export default function ParrainageScreen() {
|
export default function ParrainageScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [balance, setBalance] = useState<number>(0);
|
const [balance, setBalance] = useState<number>(0);
|
||||||
|
const [contactTelegram, setContactTelegram] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getReferralBalance().then((res) => {
|
getReferralBalance().then((res) => {
|
||||||
if (res.success) setBalance(res.balance);
|
if (res.success) setBalance(res.balance);
|
||||||
});
|
});
|
||||||
|
getPublicSettings().then((settings) => {
|
||||||
|
if (settings.contact_telegram) {
|
||||||
|
setContactTelegram(settings.contact_telegram);
|
||||||
|
}
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
icon: "chatbubble-ellipses-outline" as const,
|
icon: "chatbubble-ellipses-outline" as const,
|
||||||
title: "1. Contacte-nous sur Telegram",
|
title: "1. Contacte-nous sur Telegram",
|
||||||
desc: "Envoie un message à @milieu_nantais en indiquant ton username et le username de la personne que tu as parrainée.",
|
desc: contactTelegram
|
||||||
|
? `Envoie un message à @${contactTelegram} en indiquant ton username et le username de la personne que tu as parrainée.`
|
||||||
|
: "Envoie-nous un message sur Telegram en indiquant ton username et le username de la personne que tu as parrainée.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "checkmark-circle-outline" as const,
|
icon: "checkmark-circle-outline" as const,
|
||||||
@@ -152,6 +161,12 @@ export default function ParrainageScreen() {
|
|||||||
[colors],
|
[colors],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTelegramPress = () => {
|
||||||
|
if (contactTelegram) {
|
||||||
|
Linking.openURL(`https://t.me/${contactTelegram}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
{/* Solde actuel */}
|
{/* Solde actuel */}
|
||||||
@@ -190,18 +205,14 @@ export default function ParrainageScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bouton Telegram */}
|
{/* Bouton Telegram */}
|
||||||
<View
|
{contactTelegram ? (
|
||||||
style={styles.telegramBtn}
|
<TouchableOpacity style={styles.telegramBtn} onPress={handleTelegramPress}>
|
||||||
// Utiliser TouchableOpacity si besoin d'interaction
|
|
||||||
>
|
|
||||||
<Ionicons name="paper-plane-outline" size={20} color="#fff" />
|
<Ionicons name="paper-plane-outline" size={20} color="#fff" />
|
||||||
<Text
|
<Text style={styles.telegramText}>
|
||||||
style={styles.telegramText}
|
Contacter @{contactTelegram}
|
||||||
onPress={() => Linking.openURL("https://t.me/milieu_nantais")}
|
|
||||||
>
|
|
||||||
Contacter @milieu_nantais
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ export default function ProductDetailScreen() {
|
|||||||
const fixedProduct = {
|
const fixedProduct = {
|
||||||
...p,
|
...p,
|
||||||
prices:
|
prices:
|
||||||
p.prices?.map((pr: any) => ({
|
p.prices
|
||||||
|
?.filter((pr: any) => pr.active_price !== false)
|
||||||
|
.map((pr: any) => ({
|
||||||
quantity: parseFloat(String(pr.quantity)),
|
quantity: parseFloat(String(pr.quantity)),
|
||||||
price: parseFloat(String(pr.price)),
|
price: parseFloat(String(pr.price)),
|
||||||
|
active_price: pr.active_price,
|
||||||
})) || [],
|
})) || [],
|
||||||
};
|
};
|
||||||
setProduct(fixedProduct);
|
setProduct(fixedProduct);
|
||||||
@@ -67,7 +70,9 @@ export default function ProductDetailScreen() {
|
|||||||
setSelectedPrice(fixedProduct.prices[0].price);
|
setSelectedPrice(fixedProduct.prices[0].price);
|
||||||
}
|
}
|
||||||
const matched = categories.find(
|
const matched = categories.find(
|
||||||
(c) => c.name.toLowerCase() === (p.category || "").toLowerCase(),
|
(c) =>
|
||||||
|
c.name.toLowerCase() ===
|
||||||
|
(p.category || "").toLowerCase(),
|
||||||
);
|
);
|
||||||
if (matched?.color) setCatColor(matched.color);
|
if (matched?.color) setCatColor(matched.color);
|
||||||
} else {
|
} else {
|
||||||
@@ -108,11 +113,19 @@ export default function ProductDetailScreen() {
|
|||||||
|
|
||||||
const catTextColor = (() => {
|
const catTextColor = (() => {
|
||||||
const h = catColor.replace("#", "");
|
const h = catColor.replace("#", "");
|
||||||
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
const full =
|
||||||
|
h.length === 3
|
||||||
|
? h
|
||||||
|
.split("")
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join("")
|
||||||
|
: h;
|
||||||
const r = parseInt(full.slice(0, 2), 16);
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
const g = parseInt(full.slice(2, 4), 16);
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
const b = parseInt(full.slice(4, 6), 16);
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
return (r * 299 + g * 587 + b * 114) / 1000 > 128
|
||||||
|
? "#000000"
|
||||||
|
: "#ffffff";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const overlayBg = isDark ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)";
|
const overlayBg = isDark ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)";
|
||||||
@@ -206,6 +219,32 @@ export default function ProductDetailScreen() {
|
|||||||
textShadowOffset: { width: 2, height: 2 },
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
textShadowRadius: 12,
|
textShadowRadius: 12,
|
||||||
},
|
},
|
||||||
|
comingSoonBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: [
|
||||||
|
{ translateX: -80 },
|
||||||
|
{ translateY: -30 },
|
||||||
|
{ rotate: "-15deg" },
|
||||||
|
],
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(34,197,94,0.95)",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
paddingVertical: 16,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
color: "rgba(34,197,94,0.95)",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 12,
|
||||||
|
},
|
||||||
videoBtn: {
|
videoBtn: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 16,
|
top: 16,
|
||||||
@@ -467,6 +506,7 @@ export default function ProductDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOutOfStock = product.stock === 0;
|
const isOutOfStock = product.stock === 0;
|
||||||
|
const isComingSoon = product.coming_soon === true;
|
||||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||||
@@ -514,6 +554,13 @@ export default function ProductDetailScreen() {
|
|||||||
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{isComingSoon && (
|
||||||
|
<View style={styles.comingSoonBadge}>
|
||||||
|
<Text style={styles.comingSoonText}>
|
||||||
|
COMMING SOON
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{videoUri && !isOutOfStock && (
|
{videoUri && !isOutOfStock && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -538,7 +585,8 @@ export default function ProductDetailScreen() {
|
|||||||
<View style={styles.priceIndicator} />
|
<View style={styles.priceIndicator} />
|
||||||
<Text style={styles.priceText}>
|
<Text style={styles.priceText}>
|
||||||
{selectedPrice.toFixed(2)} €{" "}
|
{selectedPrice.toFixed(2)} €{" "}
|
||||||
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
|
{selectedGrams &&
|
||||||
|
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -580,24 +628,33 @@ export default function ProductDetailScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.addToCartBtn,
|
styles.addToCartBtn,
|
||||||
(isOutOfStock || selectedGrams === null) &&
|
(isOutOfStock ||
|
||||||
|
isComingSoon ||
|
||||||
|
selectedGrams === null) &&
|
||||||
styles.addToCartBtnDisabled,
|
styles.addToCartBtnDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleAddToCart}
|
onPress={handleAddToCart}
|
||||||
disabled={
|
disabled={
|
||||||
isOutOfStock || selectedGrams === null || adding
|
isOutOfStock ||
|
||||||
|
isComingSoon ||
|
||||||
|
selectedGrams === null ||
|
||||||
|
adding
|
||||||
}
|
}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.addToCartText,
|
styles.addToCartText,
|
||||||
(isOutOfStock || selectedGrams === null) &&
|
(isOutOfStock ||
|
||||||
|
isComingSoon ||
|
||||||
|
selectedGrams === null) &&
|
||||||
styles.addToCartTextDisabled,
|
styles.addToCartTextDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isOutOfStock
|
{isOutOfStock
|
||||||
? "Rupture de stock"
|
? "Rupture de stock"
|
||||||
|
: isComingSoon
|
||||||
|
? "Bientôt disponible"
|
||||||
: "Ajouter au panier"}
|
: "Ajouter au panier"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -654,7 +711,8 @@ export default function ProductDetailScreen() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{p.quantity}{product.unit || "g"}
|
{p.quantity}
|
||||||
|
{product.unit || "g"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
Switch,
|
||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -14,10 +15,10 @@ import {
|
|||||||
Linking,
|
Linking,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, changePassword } from "../../api/api";
|
import { getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, changePassword, get2FAStatus, toggle2FA, getPublicSettings } from "../../api/api";
|
||||||
import TextInput from "../../components/ui/TextInput";
|
import TextInput from "../../components/ui/TextInput";
|
||||||
import Button from "../../components/ui/Button";
|
import Button from "../../components/ui/Button";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
@@ -29,6 +30,7 @@ const STORAGE_SIGNAL = "profile_signal_pseudo";
|
|||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation<any>();
|
||||||
|
|
||||||
// Données compte (backend)
|
// Données compte (backend)
|
||||||
const [nom, setNom] = useState("");
|
const [nom, setNom] = useState("");
|
||||||
@@ -49,11 +51,29 @@ export default function ProfileScreen() {
|
|||||||
const [telegramEnabled, setTelegramEnabled] = useState(false);
|
const [telegramEnabled, setTelegramEnabled] = useState(false);
|
||||||
const [telegramLoading, setTelegramLoading] = useState(false);
|
const [telegramLoading, setTelegramLoading] = useState(false);
|
||||||
|
|
||||||
// Modal confirmation infos par défaut
|
// 2FA
|
||||||
|
const [twoFAEnabled, setTwoFAEnabled] = useState(false);
|
||||||
|
const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false);
|
||||||
|
const [twoFALoading, setTwoFALoading] = useState(false);
|
||||||
|
|
||||||
|
// Modals confirmation sauvegarde
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
const [showConfirmContactModal, setShowConfirmContactModal] = useState(false);
|
||||||
|
const [showConfirmAddressModal, setShowConfirmAddressModal] = useState(false);
|
||||||
|
|
||||||
|
// Modal succès / erreur générique
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
|
const [successTitle, setSuccessTitle] = useState("");
|
||||||
|
const [successMsg, setSuccessMsg] = useState("");
|
||||||
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
// Modal changement de mot de passe
|
// Modal changement de mot de passe
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
|
||||||
|
// Modals Telegram unlink
|
||||||
|
const [showTelegramUnlinkModal, setShowTelegramUnlinkModal] = useState(false);
|
||||||
|
const [showTelegramSuccessModal, setShowTelegramSuccessModal] = useState(false);
|
||||||
const [currentPwd, setCurrentPwd] = useState("");
|
const [currentPwd, setCurrentPwd] = useState("");
|
||||||
const [newPwd, setNewPwd] = useState("");
|
const [newPwd, setNewPwd] = useState("");
|
||||||
const [confirmPwd, setConfirmPwd] = useState("");
|
const [confirmPwd, setConfirmPwd] = useState("");
|
||||||
@@ -64,15 +84,19 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoadingProfile(true);
|
setLoadingProfile(true);
|
||||||
const [savedAddress, savedPhone, savedSignal, profileRes, tgStatus] = await Promise.all([
|
const [savedAddress, savedPhone, savedSignal, profileRes, tgStatus, twoFAStatus, pubSettings] = await Promise.all([
|
||||||
AsyncStorage.getItem(STORAGE_ADDRESS),
|
AsyncStorage.getItem(STORAGE_ADDRESS),
|
||||||
AsyncStorage.getItem(STORAGE_PHONE),
|
AsyncStorage.getItem(STORAGE_PHONE),
|
||||||
AsyncStorage.getItem(STORAGE_SIGNAL),
|
AsyncStorage.getItem(STORAGE_SIGNAL),
|
||||||
getMyProfile(),
|
getMyProfile(),
|
||||||
getTelegramStatus(),
|
getTelegramStatus(),
|
||||||
|
get2FAStatus(),
|
||||||
|
getPublicSettings(),
|
||||||
]);
|
]);
|
||||||
setTelegramLinked(tgStatus.linked);
|
setTelegramLinked(tgStatus.linked);
|
||||||
setTelegramEnabled(tgStatus.enabled);
|
setTelegramEnabled(tgStatus.enabled);
|
||||||
|
setTwoFAEnabled(twoFAStatus.two_fa_enabled);
|
||||||
|
setTwoFAAdminEnabled(pubSettings.two_fa_enabled);
|
||||||
|
|
||||||
if (savedAddress !== null) setDefaultAddress(savedAddress);
|
if (savedAddress !== null) setDefaultAddress(savedAddress);
|
||||||
if (savedPhone !== null) setDefaultPhone(savedPhone);
|
if (savedPhone !== null) setDefaultPhone(savedPhone);
|
||||||
@@ -103,7 +127,17 @@ export default function ProfileScreen() {
|
|||||||
AsyncStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim()),
|
AsyncStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim()),
|
||||||
]);
|
]);
|
||||||
setShowSaveModal(false);
|
setShowSaveModal(false);
|
||||||
Alert.alert("Enregistré", "Informations par défaut sauvegardées");
|
setSuccessTitle("Informations sauvegardées");
|
||||||
|
setSuccessMsg("Téléphone et pseudo Signal seront pré-remplis lors de vos prochaines commandes.");
|
||||||
|
setShowSuccessModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAddress = async () => {
|
||||||
|
setShowConfirmAddressModal(false);
|
||||||
|
await AsyncStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||||
|
setSuccessTitle("Adresse sauvegardée");
|
||||||
|
setSuccessMsg("Votre adresse par défaut sera pré-remplie à la prochaine commande.");
|
||||||
|
setShowSuccessModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkTelegram = async () => {
|
const handleLinkTelegram = async () => {
|
||||||
@@ -124,35 +158,32 @@ export default function ProfileScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlinkTelegram = async () => {
|
const handleUnlinkTelegram = () => {
|
||||||
Alert.alert(
|
setShowTelegramUnlinkModal(true);
|
||||||
"Délier Telegram",
|
};
|
||||||
"Vous ne recevrez plus de notifications Telegram. Continuer ?",
|
|
||||||
[
|
const confirmUnlinkTelegram = async () => {
|
||||||
{ text: "Annuler", style: "cancel" },
|
setShowTelegramUnlinkModal(false);
|
||||||
{
|
|
||||||
text: "Délier",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
await unlinkTelegram();
|
await unlinkTelegram();
|
||||||
setTelegramLinked(false);
|
setTelegramLinked(false);
|
||||||
},
|
setTwoFAEnabled(false);
|
||||||
},
|
setShowTelegramSuccessModal(true);
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePassword = async () => {
|
||||||
if (!currentPwd || !newPwd || !confirmPwd) {
|
if (!currentPwd || !newPwd || !confirmPwd) {
|
||||||
Alert.alert("Erreur", "Veuillez remplir tous les champs");
|
setErrorMsg("Veuillez remplir tous les champs.");
|
||||||
|
setShowErrorModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPwd.length < 8) {
|
if (newPwd.length < 8) {
|
||||||
Alert.alert("Erreur", "Le nouveau mot de passe doit contenir au moins 8 caractères");
|
setErrorMsg("Le nouveau mot de passe doit contenir au moins 8 caractères.");
|
||||||
|
setShowErrorModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPwd !== confirmPwd) {
|
if (newPwd !== confirmPwd) {
|
||||||
Alert.alert("Erreur", "Les nouveaux mots de passe ne correspondent pas");
|
setErrorMsg("Les nouveaux mots de passe ne correspondent pas.");
|
||||||
|
setShowErrorModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSavingPassword(true);
|
setSavingPassword(true);
|
||||||
@@ -161,23 +192,41 @@ export default function ProfileScreen() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowPasswordModal(false);
|
setShowPasswordModal(false);
|
||||||
setCurrentPwd(""); setNewPwd(""); setConfirmPwd("");
|
setCurrentPwd(""); setNewPwd(""); setConfirmPwd("");
|
||||||
Alert.alert("Succès", "Mot de passe modifié avec succès");
|
setSuccessTitle("Mot de passe modifié");
|
||||||
|
setSuccessMsg("Votre mot de passe a été changé avec succès.");
|
||||||
|
setShowSuccessModal(true);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert("Erreur", result.message || "Erreur inattendue");
|
setErrorMsg(result.message || "Erreur inattendue lors du changement de mot de passe.");
|
||||||
|
setShowErrorModal(true);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPassword(false);
|
setSavingPassword(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggle2FA = async (value: boolean) => {
|
||||||
|
setTwoFALoading(true);
|
||||||
|
const res = await toggle2FA(value);
|
||||||
|
setTwoFALoading(false);
|
||||||
|
if (res.success) {
|
||||||
|
setTwoFAEnabled(value);
|
||||||
|
} else {
|
||||||
|
Alert.alert("Erreur", res.error || "Impossible de modifier la 2FA");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveContact = async () => {
|
const saveContact = async () => {
|
||||||
|
setShowConfirmContactModal(false);
|
||||||
setSavingContact(true);
|
setSavingContact(true);
|
||||||
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
|
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
|
||||||
setSavingContact(false);
|
setSavingContact(false);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
Alert.alert("Succès", res.message ?? "Profil mis à jour");
|
setSuccessTitle("Compte mis à jour");
|
||||||
|
setSuccessMsg(res.message ?? "Vos informations de compte ont été enregistrées avec succès.");
|
||||||
|
setShowSuccessModal(true);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert("Erreur", res.message ?? "Erreur lors de la mise à jour");
|
setErrorMsg(res.message ?? "Erreur lors de la mise à jour du profil.");
|
||||||
|
setShowErrorModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,6 +376,17 @@ export default function ProfileScreen() {
|
|||||||
},
|
},
|
||||||
pwdInputIcon: { marginRight: spacing.s },
|
pwdInputIcon: { marginRight: spacing.s },
|
||||||
pwdInput: { flex: 1, fontSize: fontSize.sm },
|
pwdInput: { flex: 1, fontSize: fontSize.sm },
|
||||||
|
twoFARow: {
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
alignItems: "center" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
paddingTop: spacing.xs,
|
||||||
|
},
|
||||||
|
twoFALabel: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
}), [colors]);
|
}), [colors]);
|
||||||
|
|
||||||
if (loadingProfile) {
|
if (loadingProfile) {
|
||||||
@@ -338,7 +398,7 @@ export default function ProfileScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
<KeyboardAvoidingView style={styles.container} behavior="padding">
|
||||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -387,7 +447,7 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.saveBtn}
|
style={styles.saveBtn}
|
||||||
onPress={saveContact}
|
onPress={() => setShowConfirmContactModal(true)}
|
||||||
disabled={savingContact}
|
disabled={savingContact}
|
||||||
>
|
>
|
||||||
<Ionicons name="save-outline" size={16} color="#fff" />
|
<Ionicons name="save-outline" size={16} color="#fff" />
|
||||||
@@ -414,6 +474,15 @@ export default function ProfileScreen() {
|
|||||||
multiline
|
multiline
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
/>
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveBtn, styles.saveBtnSecondary, { marginTop: spacing.m }]}
|
||||||
|
onPress={() => setShowConfirmAddressModal(true)}
|
||||||
|
>
|
||||||
|
<Ionicons name="save-outline" size={16} color={colors.accent} />
|
||||||
|
<Text style={[styles.saveBtnText, styles.saveBtnTextSecondary]}>
|
||||||
|
Enregistrer l'adresse
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Carte contact livraison */}
|
{/* Carte contact livraison */}
|
||||||
@@ -466,6 +535,21 @@ export default function ProfileScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Carte Parrainage */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.cardTitle}>
|
||||||
|
<Ionicons name="gift-outline" size={18} color="#8b5cf6" />
|
||||||
|
<Text style={styles.cardTitleText}>Parrainage</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveBtn, { backgroundColor: "#8b5cf622", borderWidth: 1, borderColor: "#8b5cf666" }]}
|
||||||
|
onPress={() => navigation.navigate("Parrainage")}
|
||||||
|
>
|
||||||
|
<Ionicons name="gift-outline" size={16} color="#8b5cf6" />
|
||||||
|
<Text style={[styles.saveBtnText, { color: "#8b5cf6" }]}>Voir mon parrainage</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Carte Telegram */}
|
{/* Carte Telegram */}
|
||||||
{telegramEnabled && (
|
{telegramEnabled && (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
@@ -505,6 +589,42 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Carte 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
|
||||||
|
{twoFAAdminEnabled && telegramLinked && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.cardTitle}>
|
||||||
|
<Ionicons name="shield-checkmark-outline" size={18} color="#6366f1" />
|
||||||
|
<Text style={styles.cardTitleText}>Double authentification (2FA)</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.twoFARow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.twoFALabel}>
|
||||||
|
{twoFAEnabled ? "Activée" : "Désactivée"}
|
||||||
|
</Text>
|
||||||
|
{twoFAEnabled && (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: 2 }}>
|
||||||
|
<Ionicons name="checkmark-circle" size={13} color="#10b981" />
|
||||||
|
<Text style={{ color: "#10b981", fontSize: fontSize.xs }}>Protection activée</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{twoFALoading ? (
|
||||||
|
<ActivityIndicator size="small" color="#6366f1" />
|
||||||
|
) : (
|
||||||
|
<Switch
|
||||||
|
value={twoFAEnabled}
|
||||||
|
onValueChange={handleToggle2FA}
|
||||||
|
trackColor={{ false: colors.border, true: "#6366f155" }}
|
||||||
|
thumbColor={twoFAEnabled ? "#6366f1" : colors.textMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -643,6 +763,224 @@ export default function ProfileScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal confirmation déliaison Telegram */}
|
||||||
|
<Modal
|
||||||
|
visible={showTelegramUnlinkModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowTelegramUnlinkModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowTelegramUnlinkModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
|
<View style={styles.modalBox}>
|
||||||
|
<View style={[styles.modalIconWrap, { borderColor: "#ef444433", backgroundColor: "#ef444411" }]}>
|
||||||
|
<Ionicons name="unlink-outline" size={24} color="#ef4444" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Délier Telegram ?</Text>
|
||||||
|
<Text style={styles.modalBody}>
|
||||||
|
Vous ne recevrez plus de notifications Telegram. La double authentification sera également désactivée.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBtnCancel}
|
||||||
|
onPress={() => setShowTelegramUnlinkModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalBtnCancelText}>Annuler</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtnConfirm, { backgroundColor: "#ef444422", borderColor: "#ef444466" }]}
|
||||||
|
onPress={confirmUnlinkTelegram}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnConfirmText, { color: "#ef4444" }]}>Délier</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal succès déliaison Telegram */}
|
||||||
|
<Modal
|
||||||
|
visible={showTelegramSuccessModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowTelegramSuccessModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowTelegramSuccessModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
|
<View style={styles.modalBox}>
|
||||||
|
<View style={[styles.modalIconWrap, { borderColor: "#10b98133", backgroundColor: "#10b98111" }]}>
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={24} color="#10b981" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Compte délié</Text>
|
||||||
|
<Text style={styles.modalBody}>
|
||||||
|
Votre compte Telegram a été délié avec succès. Vous ne recevrez plus de notifications via Telegram.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtnConfirm, { flex: 1, borderColor: "#10b98166" }]}
|
||||||
|
onPress={() => setShowTelegramSuccessModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnConfirmText, { color: "#10b981" }]}>Fermer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal confirmation — Enregistrer le compte */}
|
||||||
|
<Modal
|
||||||
|
visible={showConfirmContactModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowConfirmContactModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowConfirmContactModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
|
<View style={styles.modalBox}>
|
||||||
|
<View style={styles.modalIconWrap}>
|
||||||
|
<Ionicons name="person-outline" size={24} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Mettre à jour le compte ?</Text>
|
||||||
|
<Text style={styles.modalBody}>
|
||||||
|
Vos informations (prénom, nom, téléphone) seront enregistrées sur votre compte.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBtnCancel}
|
||||||
|
onPress={() => setShowConfirmContactModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalBtnCancelText}>Annuler</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBtnConfirm}
|
||||||
|
onPress={saveContact}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalBtnConfirmText}>Confirmer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal confirmation — Enregistrer l'adresse */}
|
||||||
|
<Modal
|
||||||
|
visible={showConfirmAddressModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowConfirmAddressModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowConfirmAddressModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
|
<View style={styles.modalBox}>
|
||||||
|
<View style={[styles.modalIconWrap, { borderColor: "#10b98133", backgroundColor: "#10b98111" }]}>
|
||||||
|
<Ionicons name="location-outline" size={24} color="#10b981" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Enregistrer l'adresse ?</Text>
|
||||||
|
<Text style={styles.modalBody}>
|
||||||
|
Cette adresse sera pré-remplie automatiquement lors de vos prochaines commandes.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBtnCancel}
|
||||||
|
onPress={() => setShowConfirmAddressModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalBtnCancelText}>Annuler</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtnConfirm, { borderColor: "#10b98166" }]}
|
||||||
|
onPress={saveAddress}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnConfirmText, { color: "#10b981" }]}>Confirmer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal succès générique */}
|
||||||
|
<Modal
|
||||||
|
visible={showSuccessModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowSuccessModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowSuccessModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
|
<View style={styles.modalBox}>
|
||||||
|
<View style={[styles.modalIconWrap, { borderColor: "#10b98133", backgroundColor: "#10b98111" }]}>
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={24} color="#10b981" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>{successTitle}</Text>
|
||||||
|
<Text style={styles.modalBody}>{successMsg}</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtnConfirm, { flex: 1, borderColor: "#10b98166" }]}
|
||||||
|
onPress={() => setShowSuccessModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnConfirmText, { color: "#10b981" }]}>Fermer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal erreur générique */}
|
||||||
|
<Modal
|
||||||
|
visible={showErrorModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowErrorModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowErrorModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
|
<View style={styles.modalBox}>
|
||||||
|
<View style={[styles.modalIconWrap, { borderColor: "#ef444433", backgroundColor: "#ef444411" }]}>
|
||||||
|
<Ionicons name="alert-circle-outline" size={24} color="#ef4444" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Une erreur est survenue</Text>
|
||||||
|
<Text style={styles.modalBody}>{errorMsg}</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtnConfirm, { flex: 1, borderColor: "#ef444466", backgroundColor: "#ef444411" }]}
|
||||||
|
onPress={() => setShowErrorModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnConfirmText, { color: "#ef4444" }]}>Fermer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user