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 { ActivityIndicator, View, StyleSheet } from "react-native";
|
||||
import * as Updates from "expo-updates";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
|
||||
@@ -91,6 +92,22 @@ function RootNavigator() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<ThemeProvider>
|
||||
<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": {
|
||||
"name": "Admin Panel",
|
||||
"slug": "frontend-admin",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"android": {
|
||||
"package": "com.uberstup.adminpanel",
|
||||
"abiFilters": ["arm64-v8a"],
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/icon.png",
|
||||
"backgroundColor": "#000000"
|
||||
@@ -36,6 +37,7 @@
|
||||
"plugins": [
|
||||
"expo-font",
|
||||
"expo-location",
|
||||
"expo-updates",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -50,6 +52,12 @@
|
||||
"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",
|
||||
"gradleCommand": ":app:assembleDebug"
|
||||
},
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"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",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"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": {
|
||||
"distribution": "internal",
|
||||
@@ -34,8 +34,10 @@
|
||||
"buildType": "apk"
|
||||
},
|
||||
"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-location": "~19.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.17",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -4210,6 +4211,12 @@
|
||||
"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": {
|
||||
"version": "19.0.22",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
||||
@@ -4253,6 +4260,12 @@
|
||||
"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": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
||||
@@ -4270,6 +4283,19 @@
|
||||
"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": {
|
||||
"version": "3.0.25",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||
@@ -4320,6 +4346,57 @@
|
||||
"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": {
|
||||
"version": "54.0.24",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.17",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "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 {
|
||||
AuthResponse,
|
||||
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[]> => {
|
||||
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
||||
return data.clients || [];
|
||||
@@ -84,9 +151,6 @@ export const updateUserByAdmin = async (
|
||||
return { success: true, message: data.message, user: data.user };
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// COMMANDES
|
||||
|
||||
export const getAllCommands = async (status?: string, username?: string) => {
|
||||
let url = `${V2}/admin/protected/orders`;
|
||||
const params: string[] = [];
|
||||
@@ -171,8 +235,11 @@ export const updateCommandAddress = async (
|
||||
export const validateCommand = async (commandId: number) => {
|
||||
const { data } = await apiClient.post(
|
||||
`${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 (
|
||||
@@ -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 () => {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
`${V2}/admin/protected/delivery-persons`,
|
||||
);
|
||||
@@ -222,6 +277,9 @@ export const getAvailableDeliveryPersons = async () => {
|
||||
livreurs: data.livreurs || [],
|
||||
count: data.count || 0,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, livreurs: [], count: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export const assignDeliveryPerson = async (
|
||||
@@ -370,10 +428,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PRODUITS
|
||||
// ============================================
|
||||
|
||||
export const getAllProductsAdmin = async (): Promise<{
|
||||
success: boolean;
|
||||
data: Product[];
|
||||
@@ -445,7 +499,7 @@ export const createUserByAdmin = async (data: {
|
||||
role: string;
|
||||
}) => {
|
||||
const { data: res } = await apiClient.post(
|
||||
`${V2}/admin/auth/register`,
|
||||
`${V2}/admin/protected/users`,
|
||||
data,
|
||||
);
|
||||
return {
|
||||
@@ -710,6 +764,20 @@ export const deleteProductMediaAdmin = async (
|
||||
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
|
||||
// ============================================
|
||||
@@ -849,6 +917,26 @@ export interface PointsTier {
|
||||
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 {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -947,6 +1035,7 @@ export interface AppSettings {
|
||||
penalty_tiers: PenaltyTier[];
|
||||
points_enabled: boolean;
|
||||
points_pools: PointsPool[];
|
||||
points_reward?: PointsReward | null;
|
||||
referral_enabled: boolean;
|
||||
delivery_schedule: DeliverySchedule;
|
||||
postal_zones: PostalZone[];
|
||||
@@ -958,7 +1047,10 @@ export interface AppSettings {
|
||||
telegram_bot_token: string;
|
||||
telegram_bot_username: string;
|
||||
telegram_notifications_enabled: boolean;
|
||||
telegram_2fa_enabled: boolean;
|
||||
delivery_mode: DeliveryModeConfig;
|
||||
shop_name: string;
|
||||
contact_telegram: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
OrderItem,
|
||||
DeliveryPerson,
|
||||
@@ -39,10 +42,6 @@ export const confirmReceptionCabine = async (commandId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PENALITES
|
||||
// ============================================
|
||||
|
||||
export const applyClientPenalty = async (
|
||||
clientUsername: string,
|
||||
reason: string,
|
||||
@@ -70,7 +69,6 @@ export const resetClientPenalties = async (clientUsername: string) => {
|
||||
return { success: true, message: data.message };
|
||||
};
|
||||
|
||||
// pool: index dans pool_names/pool_keys, -1 = reset tous les points
|
||||
export const resetClientPoints = async (
|
||||
clientUsername: string,
|
||||
pool: number = -1,
|
||||
@@ -96,10 +94,6 @@ export const getPenaltiesStats = async () => {
|
||||
return { success: true, data: data.data };
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// COMMANDES
|
||||
// ============================================
|
||||
|
||||
export const getCancelledOrders = async () => {
|
||||
const { data } = await apiClient.get(`${API}/commands/cancelled`);
|
||||
return {
|
||||
@@ -200,7 +194,7 @@ export const getAllDeliveryPersonsWithDetails = async (): Promise<{
|
||||
users.map(async (u: any): Promise<DeliveryPerson> => {
|
||||
try {
|
||||
const { data: details } = await apiClient.get(
|
||||
`${V2}/admin/protected/delivery-persons/${u.username}`,
|
||||
`${API}/delivery-persons/${u.username}`,
|
||||
);
|
||||
const d = details.deliveryman || details;
|
||||
const parsedStatus = parseStatus(d.status);
|
||||
@@ -373,7 +367,7 @@ export const markCabineNotificationsRead = async (): Promise<void> => {
|
||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
`https://5.181.0.112.nip.io/api/v1/app-settings`,
|
||||
`${API_BASE_URL}/api/v1/app-settings`,
|
||||
);
|
||||
return {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
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<{
|
||||
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 {
|
||||
DeliveryStatus,
|
||||
QueueInfo,
|
||||
@@ -9,10 +10,6 @@ import type {
|
||||
|
||||
const API = `${API_BASE_URL}/api/v1/livreur`;
|
||||
|
||||
// ============================================
|
||||
// STATUT
|
||||
// ============================================
|
||||
|
||||
export const getMyStatus = async (): Promise<{
|
||||
success: boolean;
|
||||
status?: DeliveryStatus;
|
||||
@@ -45,10 +42,6 @@ export const updateMyStatus = async (
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// QUEUE
|
||||
// ============================================
|
||||
|
||||
export const getMyQueue = async (): Promise<{
|
||||
success: boolean;
|
||||
queue_info?: QueueInfo;
|
||||
@@ -65,10 +58,6 @@ export const getMyQueue = async (): Promise<{
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// LIVRAISONS
|
||||
// ============================================
|
||||
|
||||
export const getMyDeliveries = async (): Promise<{
|
||||
success: boolean;
|
||||
deliveries?: DeliveryItem[];
|
||||
@@ -383,6 +372,28 @@ export const ISSUE_LABELS: Record<IssueType, string> = {
|
||||
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 (
|
||||
deliveryId: number,
|
||||
issueType: IssueType,
|
||||
|
||||
@@ -12,7 +12,6 @@ const apiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor: attach JWT token
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
const isAdminRoute =
|
||||
config.url?.includes("/api/v2/") ||
|
||||
@@ -27,7 +26,6 @@ apiClient.interceptors.request.use(async (config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor: handle common errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import axios from "axios";
|
||||
|
||||
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
|
||||
|
||||
// ---- Types ----
|
||||
export interface LatLng {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -24,7 +23,6 @@ export interface NavigationInstruction {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ---- Maneuver translations (FR) ----
|
||||
export const maneuverTranslations: Record<string, string> = {
|
||||
TURN_LEFT: "Tournez à gauche",
|
||||
TURN_RIGHT: "Tournez à droite",
|
||||
@@ -48,7 +46,6 @@ export const maneuverTranslations: Record<string, string> = {
|
||||
WAYPOINT_REACHED: "Point de passage atteint",
|
||||
};
|
||||
|
||||
// Ionicons name per maneuver
|
||||
export const maneuverIcons: Record<string, string> = {
|
||||
TURN_LEFT: "arrow-back",
|
||||
TURN_RIGHT: "arrow-forward",
|
||||
@@ -79,7 +76,6 @@ function formatDistance(meters: number): string {
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
|
||||
// ---- Geocode address → coords ----
|
||||
export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- Calculate route ----
|
||||
export async function calculateRoute(
|
||||
origin: LatLng,
|
||||
destination: LatLng,
|
||||
|
||||
@@ -102,8 +102,9 @@ export interface Product {
|
||||
category: string;
|
||||
stock: number;
|
||||
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 }>;
|
||||
coming_soon?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fontSize, spacing } from "../theme";
|
||||
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
||||
|
||||
import DashboardScreen from "../screens/admin/DashboardScreen";
|
||||
import StatsScreen from "../screens/admin/StatsScreen";
|
||||
import OrdersScreen from "../screens/admin/OrdersScreen";
|
||||
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
|
||||
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
|
||||
name="Orders"
|
||||
component={OrdersScreen}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AuthStackParamList = {
|
||||
|
||||
export type AdminTabParamList = {
|
||||
Dashboard: undefined;
|
||||
Stats: undefined;
|
||||
Orders: undefined;
|
||||
Users: undefined;
|
||||
Products: undefined;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
StatusBar,
|
||||
Alert,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
type RouteProp,
|
||||
} from "@react-navigation/native";
|
||||
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 { useTheme } from "../../context/ThemeContext";
|
||||
import {
|
||||
@@ -24,13 +23,12 @@ import {
|
||||
getCommandItems,
|
||||
updateCommandStatus,
|
||||
validateCommand,
|
||||
confirmReceptionAdmin,
|
||||
notifyClientToDescend,
|
||||
getDeliveryPersonDetails,
|
||||
deleteCommandItem,
|
||||
deleteCommand,
|
||||
} 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 { AdminStackParamList } from "../../navigation/types";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
@@ -55,11 +53,12 @@ export default function OrderDetailScreen() {
|
||||
const [showItemsModal, setShowItemsModal] = useState(false);
|
||||
|
||||
// Map / tracking
|
||||
const mapRef = useRef<MapView | null>(null);
|
||||
const fullscreenMapRef = useRef<MapView | null>(null);
|
||||
const mapRef = useRef<TomTomMapRef | null>(null);
|
||||
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
|
||||
const [mapFullscreen, setMapFullscreen] = useState(false);
|
||||
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
|
||||
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
|
||||
const [livreurMarkers, setLivreurMarkers] = useState<TomTomMarker[]>([]);
|
||||
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
||||
const [mapLoading, setMapLoading] = useState(false);
|
||||
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
||||
@@ -72,7 +71,7 @@ export default function OrderDetailScreen() {
|
||||
getCommandItems(orderId),
|
||||
]);
|
||||
setCommand(cmdRes.command);
|
||||
setItems(itemsRes.items);
|
||||
setItems(itemsRes.items ?? []);
|
||||
|
||||
// If livreur assigned, fetch their location and calc route
|
||||
const cmd = cmdRes.command;
|
||||
@@ -99,13 +98,21 @@ export default function OrderDetailScreen() {
|
||||
return () => clearInterval(interval);
|
||||
}, [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 (
|
||||
livreurUsername: string,
|
||||
deliveryAddress: string,
|
||||
) => {
|
||||
setMapLoading(true);
|
||||
try {
|
||||
// Get livreur location
|
||||
const details = await getDeliveryPersonDetails(livreurUsername);
|
||||
const loc = details?.location;
|
||||
if (!loc?.latitude || !loc?.longitude) {
|
||||
@@ -113,24 +120,26 @@ export default function OrderDetailScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const origin: LatLng = {
|
||||
const origin: LatLng = { latitude: loc.latitude, longitude: loc.longitude };
|
||||
setLivreurCoords(origin);
|
||||
setLivreurMarkers([{
|
||||
id: livreurUsername,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
};
|
||||
setLivreurCoords(origin);
|
||||
color: "#22c55e",
|
||||
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);
|
||||
if (!dest) {
|
||||
setMapLoading(false);
|
||||
return;
|
||||
}
|
||||
if (dest) {
|
||||
setDestCoords(dest);
|
||||
|
||||
// Calculate route
|
||||
const result = await calculateRoute(origin, dest);
|
||||
if (result) {
|
||||
setRouteInfo(result.route);
|
||||
mapRef.current?.calcRoute(origin, dest);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
@@ -138,27 +147,6 @@ export default function OrderDetailScreen() {
|
||||
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 () => {
|
||||
try {
|
||||
await validateCommand(orderId);
|
||||
@@ -205,7 +193,7 @@ export default function OrderDetailScreen() {
|
||||
try {
|
||||
await deleteCommandItem(orderId, itemId);
|
||||
const itemsRes = await getCommandItems(orderId);
|
||||
setItems(itemsRes.items);
|
||||
setItems(itemsRes.items ?? []);
|
||||
const cmdRes = await getCommandByID(orderId);
|
||||
setCommand(cmdRes.command);
|
||||
} 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 = () => {
|
||||
Alert.alert(
|
||||
"Supprimer la commande",
|
||||
@@ -316,39 +290,6 @@ export default function OrderDetailScreen() {
|
||||
},
|
||||
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: {
|
||||
position: "absolute",
|
||||
top: spacing.s,
|
||||
@@ -409,6 +350,81 @@ export default function OrderDetailScreen() {
|
||||
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
|
||||
itemsModalOverlay: {
|
||||
flex: 1,
|
||||
@@ -476,6 +492,30 @@ export default function OrderDetailScreen() {
|
||||
[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 (!command)
|
||||
return (
|
||||
@@ -496,74 +536,21 @@ export default function OrderDetailScreen() {
|
||||
visible={mapFullscreen}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setMapFullscreen(false)}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<StatusBar hidden={mapFullscreen} />
|
||||
<View style={styles.fullscreenContainer}>
|
||||
{livreurCoords && (
|
||||
<MapView
|
||||
<TomTomMap
|
||||
ref={fullscreenMapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.fullscreenMap}
|
||||
initialRegion={{
|
||||
latitude: livreurCoords.latitude,
|
||||
longitude: livreurCoords.longitude,
|
||||
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}
|
||||
markers={livreurMarkers}
|
||||
initialCenter={livreurCoords ?? undefined}
|
||||
initialZoom={14}
|
||||
/>
|
||||
</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}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeBtn}
|
||||
onPress={() => setMapFullscreen(false)}
|
||||
>
|
||||
<Ionicons
|
||||
name="close"
|
||||
size={24}
|
||||
color={colors.white}
|
||||
/>
|
||||
<Ionicons name="close" size={24} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.fullscreenTitle}>
|
||||
{routeInfo
|
||||
@@ -584,12 +571,30 @@ export default function OrderDetailScreen() {
|
||||
<Text style={styles.info}>Client: {command.username}</Text>
|
||||
<Text style={styles.info}>Adresse: {command.adresse}</Text>
|
||||
<Text style={styles.info}>
|
||||
Total: {command.total_prix?.toFixed(2)} €
|
||||
Total brut: {command.total_prix?.toFixed(2)} €
|
||||
</Text>
|
||||
{command.referral_used > 0 && (
|
||||
<>
|
||||
<Text style={[styles.info, { color: colors.success }]}>
|
||||
Parrainage utilisé: -{command.referral_used?.toFixed(2)} €
|
||||
Parrainage utilisé: -
|
||||
{command.referral_used?.toFixed(2)} €
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.info,
|
||||
{
|
||||
fontWeight: "700",
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Net:{" "}
|
||||
{(
|
||||
command.total_prix - command.referral_used
|
||||
).toFixed(2)}{" "}
|
||||
€
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{command.livreur_assign && (
|
||||
<Text style={styles.info}>
|
||||
@@ -600,29 +605,53 @@ export default function OrderDetailScreen() {
|
||||
{new Date(command.created_at).toLocaleString("fr-FR")}
|
||||
</Text>
|
||||
{command.status === "cancelled" && command.cancel_reason ? (
|
||||
<View style={{
|
||||
<View
|
||||
style={{
|
||||
marginTop: spacing.m,
|
||||
padding: spacing.m,
|
||||
backgroundColor: colors.danger + "18",
|
||||
borderRadius: borderRadius.sm,
|
||||
borderLeftWidth: 3,
|
||||
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
|
||||
</Text>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: fontSize.sm }}>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textSecondary,
|
||||
fontSize: fontSize.sm,
|
||||
}}
|
||||
>
|
||||
{command.cancel_reason}
|
||||
</Text>
|
||||
</View>
|
||||
) : command.status === "cancelled" ? (
|
||||
<View style={{
|
||||
<View
|
||||
style={{
|
||||
marginTop: spacing.m,
|
||||
padding: spacing.m,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
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
|
||||
</Text>
|
||||
</View>
|
||||
@@ -635,69 +664,21 @@ export default function OrderDetailScreen() {
|
||||
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
|
||||
{hasMap ? (
|
||||
<View style={styles.mapContainer}>
|
||||
<MapView
|
||||
<TomTomMap
|
||||
ref={mapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.map}
|
||||
initialRegion={{
|
||||
latitude: livreurCoords!.latitude,
|
||||
longitude: livreurCoords!.longitude,
|
||||
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}
|
||||
markers={livreurMarkers}
|
||||
initialCenter={livreurCoords ?? undefined}
|
||||
initialZoom={14}
|
||||
/>
|
||||
</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 && (
|
||||
<View style={styles.routeOverlay}>
|
||||
<Text style={styles.routeOverlayUser}>
|
||||
{command.livreur_assign}
|
||||
</Text>
|
||||
<Text style={styles.routeOverlayInfo}>
|
||||
{routeInfo.distance} ·{" "}
|
||||
{routeInfo.duration}
|
||||
{routeInfo.distance} · {routeInfo.duration}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -742,22 +723,38 @@ export default function OrderDetailScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
{/* Items groupés par produit */}
|
||||
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
|
||||
{items.map((item: any) => (
|
||||
<Card key={item.id} style={{ marginBottom: spacing.s }}>
|
||||
{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 (
|
||||
<Card
|
||||
key={`${rep.product_id}-${gi}`}
|
||||
style={{ marginBottom: spacing.s }}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.itemName, { flex: 1 }]}>
|
||||
{item.produit ?? item.product_name}
|
||||
{name}
|
||||
</Text>
|
||||
{rep.category ? (
|
||||
<Text style={styles.categoryBadge}>
|
||||
{rep.category}
|
||||
</Text>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={styles.itemDeleteBtn}
|
||||
onPress={() =>
|
||||
handleDeleteItem(
|
||||
item.id,
|
||||
item.produit ?? item.product_name,
|
||||
)
|
||||
}
|
||||
onPress={() => handleDeleteItem(rep.id, name)}
|
||||
>
|
||||
<Ionicons
|
||||
name="trash-outline"
|
||||
@@ -766,16 +763,61 @@ export default function OrderDetailScreen() {
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.itemDetails}>
|
||||
<Text style={styles.info}>
|
||||
Quantité: {item.quantite}
|
||||
{isMultiple &&
|
||||
group.map((item: any, i: number) => (
|
||||
<View key={item.id} style={styles.subItemRow}>
|
||||
<Text style={styles.subItemText}>
|
||||
{item.quantite}
|
||||
{unit} — {item.prix?.toFixed(2)} €
|
||||
</Text>
|
||||
<Text style={styles.info}>
|
||||
Prix: {item.prix?.toFixed(2)} €
|
||||
<TouchableOpacity
|
||||
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>
|
||||
</View>
|
||||
</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 */}
|
||||
<Text style={styles.sectionTitle}>Actions</Text>
|
||||
@@ -823,15 +865,7 @@ export default function OrderDetailScreen() {
|
||||
style={{ marginTop: spacing.s }}
|
||||
/>
|
||||
)}
|
||||
{!["approved", "cancelled"].includes(command.status) && (
|
||||
<Button
|
||||
title="Confirmer la commande"
|
||||
onPress={handleConfirmReception}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
style={{ marginTop: spacing.s }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title="Supprimer la commande"
|
||||
onPress={handleDeleteCommand}
|
||||
@@ -865,9 +899,23 @@ export default function OrderDetailScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<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
|
||||
key={item.id}
|
||||
key={`modal-${rep.product_id}-${gi}`}
|
||||
style={styles.itemsModalCard}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
@@ -877,16 +925,22 @@ export default function OrderDetailScreen() {
|
||||
{ flex: 1 },
|
||||
]}
|
||||
>
|
||||
{item.produit ?? item.product_name}
|
||||
{name}
|
||||
</Text>
|
||||
{rep.category ? (
|
||||
<Text
|
||||
style={styles.categoryBadge}
|
||||
>
|
||||
{rep.category}
|
||||
</Text>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={styles.itemDeleteBtn}
|
||||
onPress={() => {
|
||||
setShowItemsModal(false);
|
||||
handleDeleteItem(
|
||||
item.id,
|
||||
item.produit ??
|
||||
item.product_name,
|
||||
rep.id,
|
||||
name,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -897,16 +951,80 @@ export default function OrderDetailScreen() {
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.itemDetails}>
|
||||
<Text style={styles.info}>
|
||||
Quantité: {item.quantite}
|
||||
{isMultiple &&
|
||||
group.map((item: any) => (
|
||||
<View
|
||||
key={item.id}
|
||||
style={styles.subItemRow}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
styles.subItemText
|
||||
}
|
||||
>
|
||||
{item.quantite}
|
||||
{unit} —{" "}
|
||||
{item.prix?.toFixed(2)}{" "}
|
||||
€
|
||||
</Text>
|
||||
<Text style={styles.info}>
|
||||
Prix: {item.prix?.toFixed(2)} €
|
||||
</Text>
|
||||
</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 && (
|
||||
<Text style={styles.empty}>Aucun article</Text>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
updateCommandStatus,
|
||||
getCommandItems,
|
||||
notifyClientToDescend,
|
||||
confirmReceptionAdmin,
|
||||
|
||||
validateCommand,
|
||||
deleteCommand,
|
||||
deleteCommandItem,
|
||||
proposeAddressChangeAdmin,
|
||||
@@ -220,23 +221,23 @@ export default function OrdersScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReception = (commandId: number) => {
|
||||
const handleForceValidate = (commandId: number) => {
|
||||
showConfirm(
|
||||
"Confirmer la commande",
|
||||
`Confirmer la réception de la commande #${commandId} ?`,
|
||||
"Finaliser la commande",
|
||||
`Finaliser définitivement la commande #${commandId} et attribuer les points au client ?`,
|
||||
async () => {
|
||||
try {
|
||||
const res = await confirmReceptionAdmin(commandId);
|
||||
const res = await validateCommand(commandId);
|
||||
showSuccess(
|
||||
"Réception confirmée",
|
||||
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
|
||||
"Commande finalisée",
|
||||
`${res.points_awarded} point(s) attribués au client`,
|
||||
);
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
showError("Erreur", e.message);
|
||||
}
|
||||
},
|
||||
"Confirmer",
|
||||
"Finaliser",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -443,6 +444,11 @@ export default function OrdersScreen() {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fontSize.xs,
|
||||
},
|
||||
livreurCancelBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
proposedAddressBox: {
|
||||
backgroundColor: colors.bgPrimary,
|
||||
borderRadius: borderRadius.sm,
|
||||
@@ -642,14 +648,21 @@ export default function OrdersScreen() {
|
||||
|
||||
{/* Total + parrainage */}
|
||||
<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)} €
|
||||
</Text>
|
||||
{(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 style={[styles.cardText, { fontWeight: "700" }]}>
|
||||
Net : {(item.total_prix - item.referral_used!).toFixed(2)} €
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Livreur */}
|
||||
{!!item.livreur_assign && (
|
||||
@@ -662,8 +675,18 @@ export default function OrdersScreen() {
|
||||
{/* Raison annulation */}
|
||||
{isCancelled && !!item.cancel_reason && (
|
||||
<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}>
|
||||
Raison : {item.cancel_reason}
|
||||
{item.cancel_reason.startsWith("[Livreur:")
|
||||
? item.cancel_reason.replace(/^\[Livreur: [^\]]+\] /, "")
|
||||
: item.cancel_reason}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -751,14 +774,15 @@ export default function OrdersScreen() {
|
||||
},
|
||||
condition: item.status === "assigned",
|
||||
},
|
||||
|
||||
{
|
||||
label: "Confirmer réception",
|
||||
icon: "checkmark-circle-outline" as keyof typeof Ionicons.glyphMap,
|
||||
label: "Finaliser commande",
|
||||
icon: "trophy-outline" as keyof typeof Ionicons.glyphMap,
|
||||
onPress: () => {
|
||||
setOpenMenuId(null);
|
||||
handleConfirmReception(item.id);
|
||||
handleForceValidate(item.id);
|
||||
},
|
||||
condition: item.status === "livre",
|
||||
condition: !isDone && item.status !== "livre",
|
||||
},
|
||||
{
|
||||
label: "Supprimer",
|
||||
@@ -787,8 +811,18 @@ export default function OrdersScreen() {
|
||||
<Text style={styles.cardText}>Client: {item.username}</Text>
|
||||
<Text style={styles.cardText}>Adresse: {item.adresse}</Text>
|
||||
<Text style={styles.cardText}>
|
||||
Total: {item.total_prix.toFixed(2)} €
|
||||
{(item.referral_used ?? 0) > 0 ? "Total brut" : "Total"}: {item.total_prix.toFixed(2)} €
|
||||
</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 && (
|
||||
<Text style={styles.cardText}>
|
||||
Livreur: {item.livreur_assign}
|
||||
@@ -1096,12 +1130,22 @@ export default function OrdersScreen() {
|
||||
</View>
|
||||
)}
|
||||
{itemsModal.commandInfo?.total_prix != null && (
|
||||
<>
|
||||
<Text style={styles.modalTotal}>
|
||||
{Number(
|
||||
itemsModal.commandInfo.total_prix,
|
||||
).toFixed(2)}{" "}
|
||||
€
|
||||
{(itemsModal.commandInfo.referral_used ?? 0) > 0 ? "Brut : " : ""}
|
||||
{Number(itemsModal.commandInfo.total_prix).toFixed(2)} €
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
deleteProductAdmin,
|
||||
uploadProductMediaAdmin,
|
||||
deleteProductMediaAdmin,
|
||||
activateProductPrice,
|
||||
deactivateProductPrice,
|
||||
getCategories,
|
||||
} from "../../api/api_admin";
|
||||
import type { Category } from "../../api/api_admin";
|
||||
@@ -40,8 +42,10 @@ import { useAlert } from "../../hooks/useAlert";
|
||||
// Types
|
||||
// --------------------------------------------------
|
||||
interface PriceRow {
|
||||
id?: number;
|
||||
quantity: string;
|
||||
price: string;
|
||||
active: boolean;
|
||||
}
|
||||
interface MediaItem {
|
||||
id?: number;
|
||||
@@ -74,6 +78,7 @@ interface FormState {
|
||||
stock: string;
|
||||
unit: string;
|
||||
prices: PriceRow[];
|
||||
comingSoon: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (firstCategory = ""): FormState => ({
|
||||
@@ -82,7 +87,8 @@ const emptyForm = (firstCategory = ""): FormState => ({
|
||||
description: "",
|
||||
stock: "",
|
||||
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 || "",
|
||||
stock: product.stock.toString(),
|
||||
unit: product.unit || "u",
|
||||
comingSoon: product.coming_soon ?? false,
|
||||
prices:
|
||||
product.prices && product.prices.length > 0
|
||||
? product.prices.map((p) => ({
|
||||
id: p.id,
|
||||
quantity: p.quantity.toString(),
|
||||
price: p.price.toString(),
|
||||
active: p.active_price ?? true,
|
||||
}))
|
||||
: [{ quantity: "1", price: "" }],
|
||||
: [{ quantity: "1", price: "", active: true }],
|
||||
});
|
||||
setExistingMedia(
|
||||
(product.media || []).map((m) => ({
|
||||
@@ -191,13 +200,27 @@ export default function ProductsScreen() {
|
||||
const addPriceRow = () =>
|
||||
setForm((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) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
prices: f.prices.filter((_, i) => i !== idx),
|
||||
}));
|
||||
setForm((f) => {
|
||||
const target = f.prices[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 = (
|
||||
idx: number,
|
||||
field: "quantity" | "price",
|
||||
@@ -292,6 +315,7 @@ export default function ProductsScreen() {
|
||||
const prices = form.prices.map((p) => ({
|
||||
quantity: parseFloat(p.quantity),
|
||||
price: parseFloat(p.price),
|
||||
active_price: p.active,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -318,6 +342,7 @@ export default function ProductsScreen() {
|
||||
stock: parseFloat(form.stock),
|
||||
unit: form.unit,
|
||||
prices,
|
||||
coming_soon: form.comingSoon,
|
||||
});
|
||||
productId = editingProduct.id;
|
||||
} else {
|
||||
@@ -328,12 +353,12 @@ export default function ProductsScreen() {
|
||||
fd.append("description", form.description.trim());
|
||||
fd.append("stock", form.stock);
|
||||
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) => {
|
||||
fd.append(`prices[${i}][quantity]`, String(p.quantity));
|
||||
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
|
||||
@@ -607,6 +632,22 @@ export default function ProductsScreen() {
|
||||
catBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||
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
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
@@ -647,6 +688,7 @@ export default function ProductsScreen() {
|
||||
paddingHorizontal: spacing.m,
|
||||
},
|
||||
removePriceBtn: { padding: spacing.s, marginBottom: 4 },
|
||||
toggleActiveBtn: { padding: spacing.s, marginBottom: 4 },
|
||||
|
||||
// Media section
|
||||
mediaSectionBox: { marginTop: spacing.m },
|
||||
@@ -803,11 +845,24 @@ export default function ProductsScreen() {
|
||||
)}
|
||||
<Text style={styles.info}>Stock: {item.stock} {item.unit || "u"}</Text>
|
||||
{item.prices && item.prices.length > 0 && (
|
||||
<Text style={styles.info}>
|
||||
{item.prices
|
||||
.map((p) => `${p.quantity}${item.unit || "u"} = ${p.price}€`)
|
||||
.join(" | ")}
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginTop: 2 }}>
|
||||
{item.prices.map((p, i) => (
|
||||
<Text
|
||||
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>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{item.description && (
|
||||
<Text style={styles.desc} numberOfLines={2}>
|
||||
@@ -965,6 +1020,31 @@ export default function ProductsScreen() {
|
||||
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 */}
|
||||
<Text style={styles.label}>Unité de mesure *</Text>
|
||||
<View style={styles.catRow}>
|
||||
@@ -1055,6 +1135,16 @@ export default function ProductsScreen() {
|
||||
}
|
||||
/>
|
||||
</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 && (
|
||||
<TouchableOpacity
|
||||
style={styles.removePriceBtn}
|
||||
|
||||
@@ -15,8 +15,9 @@ import { useNavigation } from "@react-navigation/native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, 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 { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } 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 { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
@@ -45,6 +46,51 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [
|
||||
{ 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({
|
||||
schedule,
|
||||
onChange,
|
||||
@@ -62,8 +108,7 @@ function DeliveryScheduleSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Horaires de livraison</Text>
|
||||
<AccordionSection title="Horaires de livraison" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||
Définissez les jours et horaires d'ouverture pour la livraison.
|
||||
</Text>
|
||||
@@ -132,7 +177,7 @@ function DeliveryScheduleSection({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -199,8 +244,7 @@ function PostalZonesSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Zones de livraison</Text>
|
||||
<AccordionSection title="Zones de livraison" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||
Définissez les zones de livraison et leur montant minimum de commande.
|
||||
</Text>
|
||||
@@ -435,7 +479,7 @@ function PostalZonesSection({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -546,24 +590,20 @@ function TiersSection({
|
||||
onChange(tiers.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
|
||||
<Text style={s.sectionTitle}>{title}</Text>
|
||||
const badge = (
|
||||
<View style={{
|
||||
paddingHorizontal: spacing.s,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||
backgroundColor: active ? accentColor + "25" : colors.border + "40",
|
||||
borderWidth: 1,
|
||||
borderColor: active ? accentColor : colors.border,
|
||||
marginBottom: spacing.s,
|
||||
borderWidth: 1, borderColor: active ? accentColor : colors.border,
|
||||
}}>
|
||||
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
|
||||
{active ? "Actif" : "Ignoré"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionSection title={title} badge={badge} colors={colors} s={s}>
|
||||
<Text style={s.hint}>
|
||||
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
|
||||
Max = 0 signifie illimité (pas de borne supérieure).
|
||||
@@ -639,7 +679,479 @@ function TiersSection({
|
||||
Ajouter un palier
|
||||
</Text>
|
||||
</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>
|
||||
|
||||
{/* 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_username: "",
|
||||
telegram_notifications_enabled: false,
|
||||
telegram_2fa_enabled: false,
|
||||
delivery_mode: { mode: "single" as const, category_routes: [] },
|
||||
shop_name: "Milieu-Nantais",
|
||||
contact_telegram: "",
|
||||
points_reward: null,
|
||||
});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [productsByCategory, setProductsByCategory] = useState<Record<string, Product[]>>({});
|
||||
const [livreurs, setLivreurs] = useState<string[]>([]);
|
||||
|
||||
// Refs pour l'auto-sauvegarde au départ de la page
|
||||
@@ -711,18 +1228,33 @@ export default function SettingsScreen() {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
isLoaded.current = false;
|
||||
const [settingsRes, categoriesRes, livreursRes] = await Promise.all([
|
||||
try {
|
||||
const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([
|
||||
getSettings(),
|
||||
getCategories(),
|
||||
getAvailableDeliveryPersons(),
|
||||
getAllProductsAdmin(),
|
||||
]);
|
||||
if (settingsRes.success && settingsRes.settings) {
|
||||
const s = settingsRes.settings;
|
||||
setSettings({
|
||||
...settingsRes.settings,
|
||||
points_pools: settingsRes.settings.points_pools ?? [],
|
||||
delivery_schedule: settingsRes.settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
||||
postal_zones: settingsRes.settings.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
||||
delivery_mode: settingsRes.settings.delivery_mode ?? { mode: "single", category_routes: [] },
|
||||
...s,
|
||||
penalty_tiers: s.penalty_tiers ?? [],
|
||||
points_pools: (s.points_pools ?? []).map((p: any) => ({
|
||||
...p,
|
||||
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) {
|
||||
@@ -731,10 +1263,21 @@ export default function SettingsScreen() {
|
||||
if (livreursRes.success) {
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -939,9 +1482,28 @@ export default function SettingsScreen() {
|
||||
return (
|
||||
<View style={s.container}>
|
||||
<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 */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Amendes</Text>
|
||||
<AccordionSection title="Amendes" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<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>
|
||||
<View style={{ width: 28 }} />
|
||||
</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 }}>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
|
||||
@@ -1044,11 +1606,10 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Parrainage */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Parrainage</Text>
|
||||
<AccordionSection title="Parrainage" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Parrainage activé</Text>
|
||||
@@ -1066,11 +1627,10 @@ export default function SettingsScreen() {
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Système de points */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Système de points</Text>
|
||||
<AccordionSection title="Système de points" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Points activés</Text>
|
||||
@@ -1126,11 +1686,10 @@ export default function SettingsScreen() {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Attribution des catégories */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
|
||||
<AccordionSection title="Attribution des catégories aux points" colors={colors} s={s}>
|
||||
<Text style={s.hint}>
|
||||
Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"}
|
||||
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.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Barèmes de points — un par pool */}
|
||||
{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 */}
|
||||
<DeliveryScheduleSection
|
||||
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
||||
@@ -1250,8 +1819,7 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
|
||||
{/* Paiement crypto */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Paiement crypto</Text>
|
||||
<AccordionSection title="Paiement crypto" colors={colors} s={s}>
|
||||
|
||||
{/* Toggle activation */}
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
@@ -1393,13 +1961,12 @@ export default function SettingsScreen() {
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* 🤖 TELEGRAM */}
|
||||
{/* ============================================ */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Notifications Telegram</Text>
|
||||
<AccordionSection title="Notifications Telegram" colors={colors} s={s}>
|
||||
<View style={s.row}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Notifications activées</Text>
|
||||
@@ -1412,6 +1979,18 @@ export default function SettingsScreen() {
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</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 }}>
|
||||
<Text style={s.rowDesc}>
|
||||
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>
|
||||
</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>
|
||||
{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>
|
||||
</AccordionSection>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* 🚚 MODE DE LIVRAISON */}
|
||||
{/* ============================================ */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Mode de livraison</Text>
|
||||
<AccordionSection title="Mode de livraison" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
|
||||
Choisissez comment les commandes sont assignées aux livreurs.
|
||||
</Text>
|
||||
@@ -1577,7 +2180,7 @@ export default function SettingsScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
<TouchableOpacity
|
||||
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: {
|
||||
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
||||
fontWeight: "800",
|
||||
color: colors.textWhite,
|
||||
color: colors.textPrimary,
|
||||
marginTop: spacing.m,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function CabineLoginScreen() {
|
||||
heroTitle: {
|
||||
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
||||
fontWeight: "800",
|
||||
color: colors.textWhite,
|
||||
color: colors.textPrimary,
|
||||
marginTop: spacing.m,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function DeliveryLoginScreen() {
|
||||
heroTitle: {
|
||||
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
|
||||
fontWeight: "800",
|
||||
color: colors.textWhite,
|
||||
color: colors.textPrimary,
|
||||
marginTop: spacing.m,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
@@ -15,8 +15,8 @@ import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { shadows } from "../../theme/shadows";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { getAllCommands } from "../../api/api_admin";
|
||||
import {
|
||||
getCabineCommands,
|
||||
getAllDeliveryPersonsWithDetails,
|
||||
getCabineTelegramStatus,
|
||||
generateCabineLinkToken,
|
||||
@@ -46,7 +46,7 @@ export default function DashboardScreen() {
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const [allCmd, livreursRes] = await Promise.all([
|
||||
getAllCommands(),
|
||||
getCabineCommands(),
|
||||
getAllDeliveryPersonsWithDetails(),
|
||||
]);
|
||||
const cmds = allCmd.commands;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getAllCommands } from "../../api/api_admin";
|
||||
import {
|
||||
getCommandItems,
|
||||
deleteCommand,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
getCabineLivreursList,
|
||||
assignDeliveryPersonByCabine,
|
||||
proposeAddressChangeCabine,
|
||||
getCabineCommands,
|
||||
} from "../../api/api_cabine";
|
||||
import type { CommandResponse } from "../../api/types";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
@@ -78,7 +78,7 @@ export default function OrdersScreen() {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const result = await getAllCommands();
|
||||
const result = await getCabineCommands();
|
||||
setCommands(
|
||||
result.commands.filter(
|
||||
(c: CommandResponse) =>
|
||||
@@ -487,8 +487,18 @@ export default function OrdersScreen() {
|
||||
<Text style={styles.info}>Client: {item.username}</Text>
|
||||
<Text style={styles.info}>Adresse: {item.adresse}</Text>
|
||||
<Text style={styles.info}>
|
||||
Total: {item.total_prix.toFixed(2)} €
|
||||
Total{(item.referral_used ?? 0) > 0 ? " brut" : ""}: {item.total_prix.toFixed(2)} €
|
||||
</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
|
||||
style={styles.selectBtn}
|
||||
@@ -672,7 +682,7 @@ export default function OrdersScreen() {
|
||||
</Text>
|
||||
<Text style={styles.itemMeta}>
|
||||
Qté:{" "}
|
||||
{item.quantite ?? item.quantity} ·{" "}
|
||||
{item.quantite ?? item.quantity}{item.unit || ""} ·{" "}
|
||||
{(item.prix ?? item.price)?.toFixed(
|
||||
2,
|
||||
)}{" "}
|
||||
|
||||
@@ -2,8 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
|
||||
import { spacing, fontSize } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getAllClients } from "../../api/api_admin";
|
||||
import { applyClientPenalty, resetClientPenalties, resetClientPoints, getPublicSettings } from "../../api/api_cabine";
|
||||
import { applyClientPenalty, resetClientPenalties, resetClientPoints, getPublicSettings, getCabineAllClients } from "../../api/api_cabine";
|
||||
import type { PublicSettings } from "../../api/api_cabine";
|
||||
import type { ClientResponse } from "../../api/types";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
@@ -35,7 +34,7 @@ export default function UsersScreen() {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [clientsData, settings] = await Promise.all([
|
||||
getAllClients(),
|
||||
getCabineAllClients(),
|
||||
getPublicSettings(),
|
||||
]);
|
||||
setClients(clientsData);
|
||||
|
||||
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
|
||||
clientUsername?: string;
|
||||
clientNom?: 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() {
|
||||
@@ -125,6 +125,12 @@ export default function DashboardScreen() {
|
||||
description: string;
|
||||
}>({ 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(
|
||||
() => ({
|
||||
available: colors.success,
|
||||
@@ -276,11 +282,21 @@ export default function DashboardScreen() {
|
||||
|
||||
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 =
|
||||
enriched.find(
|
||||
(d) =>
|
||||
d.status === "en_route",
|
||||
) || enriched.find((d) => d.status === "assigned");
|
||||
enriched.find((d) => d.status === "en_route") ||
|
||||
enriched.find((d) => d.status === "assigned");
|
||||
if (activeDelivery && activeDelivery.adresse) {
|
||||
calcRoute(activeDelivery.adresse);
|
||||
}
|
||||
@@ -292,7 +308,10 @@ export default function DashboardScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
|
||||
getLivreurTelegramStatus().then((s) => {
|
||||
setTgLinked(s.linked);
|
||||
setTgEnabled(s.enabled);
|
||||
});
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -302,6 +321,18 @@ export default function DashboardScreen() {
|
||||
return () => clearInterval(interval);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (lastCoords && pendingRouteAddress.current) {
|
||||
@@ -524,7 +555,12 @@ export default function DashboardScreen() {
|
||||
cancelModal.description,
|
||||
);
|
||||
}
|
||||
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" });
|
||||
setCancelModal({
|
||||
visible: false,
|
||||
deliveryId: null,
|
||||
issueType: null,
|
||||
description: "",
|
||||
});
|
||||
if (res.success) {
|
||||
showSuccess("Succès", "Livraison annulée");
|
||||
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 res = await getDeliveryNavLink(deliveryId);
|
||||
const link = res.success && res.waze_app
|
||||
const link =
|
||||
res.success && res.waze_app
|
||||
? res.waze_app
|
||||
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
|
||||
Linking.openURL(link);
|
||||
@@ -638,18 +699,32 @@ export default function DashboardScreen() {
|
||||
Produits ({item.items.length})
|
||||
</Text>
|
||||
</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) => (
|
||||
<View key={idx} style={styles.itemRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.itemName}>
|
||||
{prod.produit}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<Text style={styles.itemName}>{prod.produit}</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}>
|
||||
Quantité: {prod.quantite}
|
||||
Quantité: {prod.quantite}{prod.unit || ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.itemPrice}>
|
||||
{(prod.prix ?? 0).toFixed(2)}€
|
||||
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -660,14 +735,34 @@ export default function DashboardScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
{(item.referral_used ?? 0) > 0 && (
|
||||
<>
|
||||
<View style={[styles.totalRow, { marginTop: 2 }]}>
|
||||
<Text style={[styles.totalLabel, { color: colors.success }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.totalLabel,
|
||||
{ color: colors.success },
|
||||
]}
|
||||
>
|
||||
Parrainage client
|
||||
</Text>
|
||||
<Text style={[styles.totalValue, { color: colors.success }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.totalValue,
|
||||
{ color: colors.success },
|
||||
]}
|
||||
>
|
||||
-{(item.referral_used ?? 0).toFixed(2)}€
|
||||
</Text>
|
||||
</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>
|
||||
)}
|
||||
@@ -675,12 +770,28 @@ export default function DashboardScreen() {
|
||||
{(!item.items || item.items.length === 0) && (
|
||||
<>
|
||||
<Text style={styles.priceOnly}>
|
||||
{item.total_prix ?? 0}€
|
||||
{(item.referral_used ?? 0) > 0 ? "Brut : " : ""}
|
||||
{(item.total_prix ?? 0)}€
|
||||
</Text>
|
||||
{(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)}€
|
||||
</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" && (
|
||||
<View style={{ flexDirection: "row", gap: spacing.s, marginTop: spacing.s }}>
|
||||
{item.status === "arrived" && (() => {
|
||||
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
|
||||
title="Terminer"
|
||||
onPress={() => handleCompleteDelivery(item.id)}
|
||||
@@ -755,7 +873,25 @@ export default function DashboardScreen() {
|
||||
style={{ flex: 1, backgroundColor: colors.danger }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -777,7 +913,10 @@ export default function DashboardScreen() {
|
||||
const handleUnlinkTelegram = async () => {
|
||||
await unlinkLivreurTelegram();
|
||||
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 = () => (
|
||||
@@ -904,26 +1043,119 @@ export default function DashboardScreen() {
|
||||
|
||||
{/* Carte Telegram */}
|
||||
{tgEnabled && (
|
||||
<View style={{ marginHorizontal: spacing.l, 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
|
||||
style={{
|
||||
marginHorizontal: spacing.l,
|
||||
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>
|
||||
{tgLinked ? (
|
||||
<View>
|
||||
<View style={{ 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
|
||||
style={{
|
||||
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>
|
||||
<TouchableOpacity onPress={handleUnlinkTelegram} 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
|
||||
onPress={handleUnlinkTelegram}
|
||||
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>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleLinkTelegram} 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
|
||||
onPress={handleLinkTelegram}
|
||||
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>
|
||||
)}
|
||||
</View>
|
||||
@@ -1769,21 +2001,37 @@ export default function DashboardScreen() {
|
||||
</Text>
|
||||
{detailsDelivery?.items &&
|
||||
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 style={{ flex: 1 }}>
|
||||
<Text style={styles.detailProductName}>
|
||||
{prod.produit}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<Text style={styles.detailProductName}>{prod.produit}</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}>
|
||||
Quantité : {prod.quantite}
|
||||
Quantité : {prod.quantite}{prod.unit || ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.detailProductPrice}>
|
||||
{(prod.prix ?? 0).toFixed(2)}€
|
||||
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
||||
)}
|
||||
@@ -1796,14 +2044,34 @@ export default function DashboardScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
{(detailsDelivery?.referral_used ?? 0) > 0 && (
|
||||
<>
|
||||
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
|
||||
<Text style={[styles.detailTotalLabel, { color: colors.success }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailTotalLabel,
|
||||
{ color: colors.success },
|
||||
]}
|
||||
>
|
||||
Parrainage client
|
||||
</Text>
|
||||
<Text style={[styles.detailTotalValue, { color: colors.success }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailTotalValue,
|
||||
{ color: colors.success },
|
||||
]}
|
||||
>
|
||||
-{detailsDelivery?.referral_used?.toFixed(2)}€
|
||||
</Text>
|
||||
</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>
|
||||
</DetailsModal>
|
||||
@@ -1873,31 +2141,71 @@ export default function DashboardScreen() {
|
||||
<DetailsModal
|
||||
visible={cancelModal.visible}
|
||||
onClose={() =>
|
||||
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" })
|
||||
setCancelModal({
|
||||
visible: false,
|
||||
deliveryId: null,
|
||||
issueType: null,
|
||||
description: "",
|
||||
})
|
||||
}
|
||||
title="Motif de non-livraison"
|
||||
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
|
||||
</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) => {
|
||||
const selected = cancelModal.issueType === type;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type}
|
||||
onPress={() => setCancelModal((prev) => ({ ...prev, issueType: type }))}
|
||||
onPress={() =>
|
||||
setCancelModal((prev) => ({
|
||||
...prev,
|
||||
issueType: type,
|
||||
}))
|
||||
}
|
||||
style={{
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.s,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: selected ? colors.danger : colors.border,
|
||||
backgroundColor: selected ? colors.danger + "22" : colors.bgCard,
|
||||
borderColor: selected
|
||||
? 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]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -1915,11 +2223,16 @@ export default function DashboardScreen() {
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelConfirmBtn, !cancelModal.issueType && { opacity: 0.4 }]}
|
||||
style={[
|
||||
styles.cancelConfirmBtn,
|
||||
!cancelModal.issueType && { opacity: 0.4 },
|
||||
]}
|
||||
onPress={handleCancelDelivery}
|
||||
disabled={!cancelModal.issueType}
|
||||
>
|
||||
<Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text>
|
||||
<Text style={styles.cancelConfirmText}>
|
||||
Confirmer l'annulation
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DetailsModal>
|
||||
|
||||
|
||||
@@ -5,34 +5,114 @@ import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getMyDeliveries, getMyStatus } from "../../api/api_delivery";
|
||||
import type { DeliveryItem } from "../../api/types";
|
||||
import { getMyDeliveries, getMyStats } from "../../api/api_delivery";
|
||||
import type { DeliveryItem, } from "../../api/types";
|
||||
import type { StatPoint } from "../../api/api_delivery";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
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() {
|
||||
const { colors } = useTheme();
|
||||
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 [refreshing, setRefreshing] = useState(false);
|
||||
const [period, setPeriod] = useState<Period>("week");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const res = await getMyDeliveries();
|
||||
if (res.success && res.deliveries) setDeliveries(res.deliveries);
|
||||
const [delivRes, statsRes] = await Promise.all([
|
||||
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 {
|
||||
/* ignore */
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
@@ -40,39 +120,22 @@ export default function StatsScreen() {
|
||||
};
|
||||
|
||||
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 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 = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
const chartData = period === "day" ? byDay : period === "week" ? byWeek : byMonth;
|
||||
|
||||
const periodTotal = useMemo(
|
||||
() => chartData.reduce((acc, p) => acc + p.count, 0),
|
||||
[chartData],
|
||||
);
|
||||
|
||||
const periodLabels: Record<Period, string> = {
|
||||
day: "30 derniers jours",
|
||||
week: "12 dernières semaines",
|
||||
month: "12 derniers mois",
|
||||
};
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
@@ -84,57 +147,123 @@ export default function StatsScreen() {
|
||||
fontWeight: "700",
|
||||
marginBottom: spacing.l,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.m,
|
||||
},
|
||||
statCard: {
|
||||
width: "47%",
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.l,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: fontSize.xxl,
|
||||
fontWeight: "700",
|
||||
marginTop: spacing.s,
|
||||
},
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.m },
|
||||
statCard: { width: "47%", alignItems: "center", paddingVertical: spacing.l },
|
||||
statValue: { fontSize: fontSize.xxl, fontWeight: "700", marginTop: spacing.s },
|
||||
statLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
marginTop: spacing.xs,
|
||||
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],
|
||||
);
|
||||
|
||||
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..." />;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={{ padding: spacing.l }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.success}
|
||||
/>
|
||||
}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.success} />}
|
||||
>
|
||||
<Text style={styles.title}>Mes performances</Text>
|
||||
|
||||
{/* Cartes résumé */}
|
||||
<View style={styles.grid}>
|
||||
{stats.map((s, i) => (
|
||||
{summaryCards.map((s, i) => (
|
||||
<Card key={i} style={styles.statCard}>
|
||||
<Ionicons name={s.icon} size={28} color={s.color} />
|
||||
<Text style={[styles.statValue, { color: s.color }]}>
|
||||
{s.value}
|
||||
</Text>
|
||||
<Text style={[styles.statValue, { color: s.color }]}>{s.value}</Text>
|
||||
<Text style={styles.statLabel}>{s.label}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"name": "Milieu Nantais",
|
||||
"slug": "frontend-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
@@ -22,6 +22,7 @@
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"softwareKeyboardLayoutMode": "pan",
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.uberstup.clientpanel",
|
||||
"versionCode": 1,
|
||||
|
||||
+7
-8
@@ -11,23 +11,21 @@
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleDebug"
|
||||
},
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://5.181.0.112.nip.io"
|
||||
"EXPO_PUBLIC_API_URL": "http://localhost:8080"
|
||||
},
|
||||
"channel": "development"
|
||||
},
|
||||
"preview": {
|
||||
"pre-prod": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"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": {
|
||||
"distribution": "internal",
|
||||
@@ -36,7 +34,8 @@
|
||||
"buildType": "apk"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
||||
+117
-20
@@ -1,4 +1,5 @@
|
||||
import apiClient from "./client";
|
||||
import { API_BASE_URL } from "./client";
|
||||
import type {
|
||||
ConfirmReceptionResponse,
|
||||
CheckoutCartResponse,
|
||||
@@ -12,7 +13,7 @@ import type {
|
||||
import { getToken } from "../auth/tokenStorage";
|
||||
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> => {
|
||||
const token = await getToken();
|
||||
@@ -110,10 +111,6 @@ export const logoutUser = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PRODUCTS
|
||||
// ============================================
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -160,10 +157,6 @@ export const getProductById = async (id: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// CART
|
||||
// ============================================
|
||||
|
||||
export const getCart = async (username: string) => {
|
||||
const jwtUsername = await getJwtUsername();
|
||||
if (!jwtUsername || jwtUsername !== username) {
|
||||
@@ -247,10 +240,6 @@ export const clearCart = async (username: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// ORDERS
|
||||
// ============================================
|
||||
|
||||
export const getMyOrders = async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get(`${V1}/my-commands`);
|
||||
@@ -787,6 +776,8 @@ export interface PublicSettings {
|
||||
crypto_only: boolean;
|
||||
nowpayments_currencies: string[];
|
||||
telegram_notifications_enabled: boolean;
|
||||
two_fa_enabled: boolean;
|
||||
contact_telegram: string;
|
||||
}
|
||||
|
||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
@@ -801,6 +792,8 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
crypto_only: false,
|
||||
nowpayments_currencies: [],
|
||||
telegram_notifications_enabled: false,
|
||||
two_fa_enabled: false,
|
||||
contact_telegram: "",
|
||||
};
|
||||
try {
|
||||
const { data } = await apiClient.get(`${V1}/app-settings`);
|
||||
@@ -821,6 +814,8 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
: [],
|
||||
telegram_notifications_enabled:
|
||||
data.telegram_notifications_enabled ?? false,
|
||||
two_fa_enabled: data.two_fa_enabled ?? false,
|
||||
contact_telegram: data.contact_telegram || "",
|
||||
};
|
||||
} catch {
|
||||
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 => {
|
||||
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_prix === "number" && order.total_prix > 0)
|
||||
return order.total_prix;
|
||||
if (Array.isArray(order.items) && order.items.length > 0) {
|
||||
return order.items.reduce((sum: number, item: any) => {
|
||||
return (
|
||||
sum +
|
||||
(item.prix || item.price || 0) *
|
||||
(item.quantite || item.quantity || 1)
|
||||
);
|
||||
return sum + (item.prix || item.price || 0) * (item.quantite || item.quantity || 1);
|
||||
}, 0);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -356,6 +356,7 @@ export interface TrackingResponse {
|
||||
export interface ProductPrice {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}
|
||||
export interface Product {
|
||||
id: number;
|
||||
@@ -567,6 +568,7 @@ export interface CompletedOrder {
|
||||
status: string;
|
||||
adresse: string;
|
||||
total_prix: number;
|
||||
referral_used?: number;
|
||||
livreur_assign?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import axios from "axios";
|
||||
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
||||
|
||||
// Change this to your server IP/domain
|
||||
export const API_BASE_URL = "https://mln-uber.club";
|
||||
export const API_BASE_URL =
|
||||
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -26,7 +26,6 @@ apiClient.interceptors.request.use(async (config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor: handle common errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ const USERNAME_KEY = "username";
|
||||
const ADMIN_USERNAME_KEY = "admin_username";
|
||||
const ROLE_KEY = "user_role";
|
||||
|
||||
// Client token
|
||||
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
||||
export const setToken = (token: string) =>
|
||||
AsyncStorage.setItem(TOKEN_KEY, token);
|
||||
@@ -22,7 +21,6 @@ export const setUsername = (username: string) =>
|
||||
AsyncStorage.setItem(USERNAME_KEY, username);
|
||||
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
||||
|
||||
// Admin username
|
||||
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
||||
export const setAdminUsername = (username: string) =>
|
||||
AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
|
||||
function getTextColor(hex: string): string {
|
||||
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 g = parseInt(full.slice(2, 4), 16);
|
||||
const b = parseInt(full.slice(4, 6), 16);
|
||||
@@ -36,19 +42,31 @@ interface ProductCardProps {
|
||||
category: string;
|
||||
stock: number;
|
||||
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 }>;
|
||||
};
|
||||
onPress: () => void;
|
||||
categoryColor?: string;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, onPress, categoryColor }: ProductCardProps) {
|
||||
export default function ProductCard({
|
||||
product,
|
||||
onPress,
|
||||
categoryColor,
|
||||
}: ProductCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const { addToCart } = useCart();
|
||||
const catColor = categoryColor ?? colors.accent;
|
||||
const isSoldOut = product.stock <= 0;
|
||||
const 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 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>
|
||||
</View>
|
||||
)}
|
||||
{isComingSoon && (
|
||||
<View style={styles.comingSoonOverlay}>
|
||||
<View style={styles.comingSoonBox}>
|
||||
<Text style={styles.comingSoonText}>BIENTÔT DISPONIBLE</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
@@ -210,13 +235,13 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
||||
style={[
|
||||
styles.quickAddBtn,
|
||||
{ backgroundColor: catColor },
|
||||
isSoldOut && {
|
||||
(isSoldOut || isComingSoon) && {
|
||||
backgroundColor: colors.textMuted,
|
||||
opacity: 0.6,
|
||||
},
|
||||
]}
|
||||
onPress={handleQuickAdd}
|
||||
disabled={isSoldOut}
|
||||
disabled={isSoldOut || isComingSoon}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
@@ -227,6 +252,8 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
||||
>
|
||||
{isSoldOut
|
||||
? "Rupture de stock"
|
||||
: isComingSoon
|
||||
? "Bientôt disponible"
|
||||
: "Ajouter rapidement"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -267,7 +294,7 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
||||
style={styles.pickerScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{product.prices?.map((p) => (
|
||||
{activePrices.map((p) => (
|
||||
<TouchableOpacity
|
||||
key={p.quantity}
|
||||
style={[
|
||||
@@ -287,7 +314,8 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
||||
{ color: colors.textWhite },
|
||||
]}
|
||||
>
|
||||
{p.quantity}{product.unit || "g"}
|
||||
{p.quantity}
|
||||
{product.unit || "g"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
@@ -449,6 +477,32 @@ const styles = StyleSheet.create({
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
comingSoonOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
comingSoonBox: {
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
borderWidth: 4,
|
||||
borderColor: "rgba(34,197,94,0.95)",
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
comingSoonText: {
|
||||
color: "rgba(34,197,94,0.95)",
|
||||
fontSize: 16,
|
||||
fontWeight: "900",
|
||||
letterSpacing: 2,
|
||||
textTransform: "uppercase",
|
||||
textShadowColor: "rgba(0,0,0,0.9)",
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
info: { padding: spacing.m, borderTopWidth: 1 },
|
||||
name: {
|
||||
fontSize: fontSize.lg,
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function Modal({
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
behavior="padding"
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Animated.View
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function ChangePasswordScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.container, { backgroundColor: colors.bgPrimary }]}
|
||||
behavior="height"
|
||||
behavior="padding"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -85,25 +85,89 @@ const LoginClient = () => {
|
||||
if (apiError) setApiError("");
|
||||
};
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgSecondary },
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
backgroundColor: colors.bgPrimary,
|
||||
},
|
||||
header: { alignItems: "center", marginBottom: 24 },
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginTop: 8,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
textAlign: "center",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
apiError: {
|
||||
backgroundColor: "#fee2e2",
|
||||
color: "#991b1b",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
inputGroup: { marginBottom: 16 },
|
||||
label: { marginBottom: 4, fontWeight: "500", color: colors.textSecondary },
|
||||
inputWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.bgInput,
|
||||
},
|
||||
icon: { marginRight: 8 },
|
||||
input: { flex: 1, height: 40, color: colors.textPrimary },
|
||||
inputError: { borderColor: "#ef4444" },
|
||||
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
|
||||
eyeButton: { padding: 4 },
|
||||
submitButton: {
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
submitText: { color: colors.white, fontWeight: "600", fontSize: 16 },
|
||||
}),
|
||||
[colors],
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
|
||||
behavior="height"
|
||||
style={styles.container}
|
||||
behavior="padding"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<FontAwesome name="user-circle" size={48} color="#7c3aed" />
|
||||
<Text style={[styles.title, { color: colors.textWhite }]}>
|
||||
<FontAwesome name="user-circle" size={48} color={colors.accent} />
|
||||
<Text style={styles.title}>
|
||||
Connexion Client
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.subtitle, { color: colors.textMuted }]}
|
||||
>
|
||||
<Text style={styles.subtitle}>
|
||||
Accédez à votre espace personnel
|
||||
</Text>
|
||||
</View>
|
||||
@@ -113,20 +177,10 @@ const LoginClient = () => {
|
||||
) : null}
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text
|
||||
style={[styles.label, { color: colors.textSecondary }]}
|
||||
>
|
||||
<Text style={styles.label}>
|
||||
Username Telegram
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.bgInput,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Feather
|
||||
name="user"
|
||||
size={20}
|
||||
@@ -134,11 +188,7 @@ const LoginClient = () => {
|
||||
style={styles.icon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: colors.textWhite },
|
||||
errors.username && styles.inputError,
|
||||
]}
|
||||
style={[styles.input, errors.username && styles.inputError]}
|
||||
placeholder="Votre username"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={formData.username}
|
||||
@@ -156,20 +206,10 @@ const LoginClient = () => {
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text
|
||||
style={[styles.label, { color: colors.textSecondary }]}
|
||||
>
|
||||
<Text style={styles.label}>
|
||||
Mot de passe
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.bgInput,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Feather
|
||||
name="lock"
|
||||
size={20}
|
||||
@@ -177,11 +217,7 @@ const LoginClient = () => {
|
||||
style={styles.icon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: colors.textWhite },
|
||||
errors.password && styles.inputError,
|
||||
]}
|
||||
style={[styles.input, errors.password && styles.inputError]}
|
||||
placeholder="••••••"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
secureTextEntry={!showPassword}
|
||||
@@ -215,7 +251,7 @@ const LoginClient = () => {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
<ActivityIndicator color={colors.white} />
|
||||
) : (
|
||||
<Text style={styles.submitText}>Se connecter</Text>
|
||||
)}
|
||||
@@ -227,52 +263,3 @@ const 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,
|
||||
getPublicSettings,
|
||||
getCryptoPaymentStatus,
|
||||
getTelegramStatus,
|
||||
getMyProfile,
|
||||
} from "../../api/api";
|
||||
import type { CryptoPaymentStatus } from "../../api/api";
|
||||
import type { ClientStackParamList } from "../../navigation/types";
|
||||
@@ -57,6 +59,7 @@ export default function CheckoutScreen() {
|
||||
// Notif telegram
|
||||
const [telegramNotificationEnabled, setTelegramNotificationEnabled] =
|
||||
useState(false);
|
||||
const [telegramLinked, setTelegramLinked] = useState(false);
|
||||
|
||||
// Crypto
|
||||
const [cryptoEnabled, setCryptoEnabled] = useState(false);
|
||||
@@ -105,6 +108,18 @@ export default function CheckoutScreen() {
|
||||
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 () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
@@ -657,9 +672,12 @@ export default function CheckoutScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
behavior="padding"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{/* Résumé commande */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
@@ -687,9 +705,22 @@ export default function CheckoutScreen() {
|
||||
<View style={styles.summaryTotal}>
|
||||
<Text style={styles.summaryTotalLabel}>Total</Text>
|
||||
<Text style={styles.summaryTotalValue}>
|
||||
{cartTotal.toFixed(2)} €
|
||||
{(useReferral && referralBalance > 0
|
||||
? Math.max(0, cartTotal - referralBalance)
|
||||
: cartTotal
|
||||
).toFixed(2)} €
|
||||
</Text>
|
||||
</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>
|
||||
|
||||
@@ -1011,7 +1042,7 @@ export default function CheckoutScreen() {
|
||||
)}
|
||||
|
||||
{/* Note Telegram */}
|
||||
{telegramNotificationEnabled && (
|
||||
{telegramNotificationEnabled && !telegramLinked && (
|
||||
<View style={styles.telegramNote}>
|
||||
<Ionicons
|
||||
name="paper-plane-outline"
|
||||
@@ -1166,7 +1197,10 @@ export default function CheckoutScreen() {
|
||||
<View style={styles.recapTotalRow}>
|
||||
<Text style={styles.recapTotalLabel}>Total</Text>
|
||||
<Text style={styles.recapTotalValue}>
|
||||
{cartTotal.toFixed(2)} €
|
||||
{(useReferral && referralBalance > 0
|
||||
? Math.max(0, cartTotal - referralBalance)
|
||||
: cartTotal
|
||||
).toFixed(2)} €
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -315,10 +315,12 @@ export default function OrderDetailsScreen() {
|
||||
const currentIdx = STATUS_INDEX[status] ?? -1;
|
||||
const isCancelled = status === "cancelled";
|
||||
const address = order.delivery_address || order.adresse || "N/A";
|
||||
const total =
|
||||
const rawTotal =
|
||||
order.total ||
|
||||
order.total_prix ||
|
||||
products.reduce((s: number, p: any) => s + (p.prix || p.price || 0), 0);
|
||||
const referralUsed: number = order.referral_used || 0;
|
||||
const total = rawTotal - referralUsed;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -524,6 +526,14 @@ export default function OrderDetailsScreen() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{referralUsed > 0 && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Crédit parrainage</Text>
|
||||
<Text style={[styles.totalValue, { color: colors.accent }]}>
|
||||
-{formatPrice(referralUsed)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Total</Text>
|
||||
<Text style={styles.totalValue}>{formatPrice(total)}</Text>
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
getMyPenalties,
|
||||
getPublicSettings,
|
||||
getReferralBalance,
|
||||
getMyPointsRewards,
|
||||
claimMyReward,
|
||||
formatOrderDate,
|
||||
formatPrice,
|
||||
} from "../../api/api";
|
||||
import type { PublicSettings } from "../../api/api";
|
||||
import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api";
|
||||
import type {
|
||||
CompletedOrder,
|
||||
ClientStats,
|
||||
@@ -45,18 +47,26 @@ export default function OrderHistoryScreen() {
|
||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||
const [stats, setStats] = useState<ClientStats | 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 [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 [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [histRes, penRes, settings, refRes] = await Promise.all([
|
||||
const [histRes, penRes, settings, refRes, rewardsRes] = await Promise.all([
|
||||
getMyCompletedOrders(),
|
||||
getMyPenalties(),
|
||||
getPublicSettings(),
|
||||
getReferralBalance(),
|
||||
getMyPointsRewards(),
|
||||
]);
|
||||
if (histRes.success) {
|
||||
setOrders(histRes.commands || []);
|
||||
@@ -69,6 +79,9 @@ export default function OrderHistoryScreen() {
|
||||
setReferralBalance(refRes.balance);
|
||||
}
|
||||
setAppSettings(settings);
|
||||
if (rewardsRes.success && rewardsRes.enabled) {
|
||||
setPointsRewards(rewardsRes);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} 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(
|
||||
useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -132,6 +161,107 @@ export default function OrderHistoryScreen() {
|
||||
letterSpacing: 1,
|
||||
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: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -342,6 +472,86 @@ export default function OrderHistoryScreen() {
|
||||
)}
|
||||
</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 */}
|
||||
{appSettings.referral_enabled && <TouchableOpacity
|
||||
style={styles.referralBtn}
|
||||
@@ -432,7 +642,7 @@ export default function OrderHistoryScreen() {
|
||||
)}
|
||||
<View style={styles.orderFooter}>
|
||||
<Text style={styles.orderTotal}>
|
||||
{formatPrice(order.total_prix || 0)}
|
||||
{formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
|
||||
@@ -385,7 +385,8 @@ export default function OrderTrackingScreen() {
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item: order }) => {
|
||||
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 track = tracking[order.id];
|
||||
const eta = etas[order.id];
|
||||
@@ -444,9 +445,16 @@ export default function OrderTrackingScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardFooter}>
|
||||
<View>
|
||||
<Text style={styles.cardTotal}>
|
||||
{formatPrice(total)}
|
||||
</Text>
|
||||
{(order.referral_used ?? 0) > 0 && (
|
||||
<Text style={{ fontSize: 11, color: colors.success, marginTop: 2 }}>
|
||||
dont -{(order.referral_used as number).toFixed(2)} € parrainage
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons
|
||||
name={
|
||||
expanded
|
||||
|
||||
@@ -5,27 +5,36 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Linking,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||
import { getReferralBalance } from "../../api/api";
|
||||
import { getReferralBalance, getPublicSettings } from "../../api/api";
|
||||
|
||||
export default function ParrainageScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [balance, setBalance] = useState<number>(0);
|
||||
const [contactTelegram, setContactTelegram] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
getReferralBalance().then((res) => {
|
||||
if (res.success) setBalance(res.balance);
|
||||
});
|
||||
getPublicSettings().then((settings) => {
|
||||
if (settings.contact_telegram) {
|
||||
setContactTelegram(settings.contact_telegram);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: "chatbubble-ellipses-outline" as const,
|
||||
title: "1. Contacte-nous sur Telegram",
|
||||
desc: "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,
|
||||
@@ -152,6 +161,12 @@ export default function ParrainageScreen() {
|
||||
[colors],
|
||||
);
|
||||
|
||||
const handleTelegramPress = () => {
|
||||
if (contactTelegram) {
|
||||
Linking.openURL(`https://t.me/${contactTelegram}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Solde actuel */}
|
||||
@@ -190,18 +205,14 @@ export default function ParrainageScreen() {
|
||||
</View>
|
||||
|
||||
{/* Bouton Telegram */}
|
||||
<View
|
||||
style={styles.telegramBtn}
|
||||
// Utiliser TouchableOpacity si besoin d'interaction
|
||||
>
|
||||
{contactTelegram ? (
|
||||
<TouchableOpacity style={styles.telegramBtn} onPress={handleTelegramPress}>
|
||||
<Ionicons name="paper-plane-outline" size={20} color="#fff" />
|
||||
<Text
|
||||
style={styles.telegramText}
|
||||
onPress={() => Linking.openURL("https://t.me/milieu_nantais")}
|
||||
>
|
||||
Contacter @milieu_nantais
|
||||
<Text style={styles.telegramText}>
|
||||
Contacter @{contactTelegram}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +56,12 @@ export default function ProductDetailScreen() {
|
||||
const fixedProduct = {
|
||||
...p,
|
||||
prices:
|
||||
p.prices?.map((pr: any) => ({
|
||||
p.prices
|
||||
?.filter((pr: any) => pr.active_price !== false)
|
||||
.map((pr: any) => ({
|
||||
quantity: parseFloat(String(pr.quantity)),
|
||||
price: parseFloat(String(pr.price)),
|
||||
active_price: pr.active_price,
|
||||
})) || [],
|
||||
};
|
||||
setProduct(fixedProduct);
|
||||
@@ -67,7 +70,9 @@ export default function ProductDetailScreen() {
|
||||
setSelectedPrice(fixedProduct.prices[0].price);
|
||||
}
|
||||
const matched = categories.find(
|
||||
(c) => c.name.toLowerCase() === (p.category || "").toLowerCase(),
|
||||
(c) =>
|
||||
c.name.toLowerCase() ===
|
||||
(p.category || "").toLowerCase(),
|
||||
);
|
||||
if (matched?.color) setCatColor(matched.color);
|
||||
} else {
|
||||
@@ -108,11 +113,19 @@ export default function ProductDetailScreen() {
|
||||
|
||||
const catTextColor = (() => {
|
||||
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 g = parseInt(full.slice(2, 4), 16);
|
||||
const b = parseInt(full.slice(4, 6), 16);
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||
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)";
|
||||
@@ -206,6 +219,32 @@ export default function ProductDetailScreen() {
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 12,
|
||||
},
|
||||
comingSoonBadge: {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: [
|
||||
{ translateX: -80 },
|
||||
{ translateY: -30 },
|
||||
{ rotate: "-15deg" },
|
||||
],
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
borderWidth: 4,
|
||||
borderColor: "rgba(34,197,94,0.95)",
|
||||
paddingHorizontal: 40,
|
||||
paddingVertical: 16,
|
||||
elevation: 10,
|
||||
},
|
||||
comingSoonText: {
|
||||
color: "rgba(34,197,94,0.95)",
|
||||
fontSize: 28,
|
||||
fontWeight: "900",
|
||||
letterSpacing: 4,
|
||||
textTransform: "uppercase",
|
||||
textShadowColor: "rgba(0,0,0,0.9)",
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 12,
|
||||
},
|
||||
videoBtn: {
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
@@ -467,6 +506,7 @@ export default function ProductDetailScreen() {
|
||||
}
|
||||
|
||||
const isOutOfStock = product.stock === 0;
|
||||
const isComingSoon = product.coming_soon === true;
|
||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||
@@ -514,6 +554,13 @@ export default function ProductDetailScreen() {
|
||||
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||
</View>
|
||||
)}
|
||||
{isComingSoon && (
|
||||
<View style={styles.comingSoonBadge}>
|
||||
<Text style={styles.comingSoonText}>
|
||||
COMMING SOON
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{videoUri && !isOutOfStock && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -538,7 +585,8 @@ export default function ProductDetailScreen() {
|
||||
<View style={styles.priceIndicator} />
|
||||
<Text style={styles.priceText}>
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -580,24 +628,33 @@ export default function ProductDetailScreen() {
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.addToCartBtn,
|
||||
(isOutOfStock || selectedGrams === null) &&
|
||||
(isOutOfStock ||
|
||||
isComingSoon ||
|
||||
selectedGrams === null) &&
|
||||
styles.addToCartBtnDisabled,
|
||||
]}
|
||||
onPress={handleAddToCart}
|
||||
disabled={
|
||||
isOutOfStock || selectedGrams === null || adding
|
||||
isOutOfStock ||
|
||||
isComingSoon ||
|
||||
selectedGrams === null ||
|
||||
adding
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.addToCartText,
|
||||
(isOutOfStock || selectedGrams === null) &&
|
||||
(isOutOfStock ||
|
||||
isComingSoon ||
|
||||
selectedGrams === null) &&
|
||||
styles.addToCartTextDisabled,
|
||||
]}
|
||||
>
|
||||
{isOutOfStock
|
||||
? "Rupture de stock"
|
||||
: isComingSoon
|
||||
? "Bientôt disponible"
|
||||
: "Ajouter au panier"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -654,7 +711,8 @@ export default function ProductDetailScreen() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
{p.quantity}{product.unit || "g"}
|
||||
{p.quantity}
|
||||
{product.unit || "g"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Switch,
|
||||
Alert,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
@@ -14,10 +15,10 @@ import {
|
||||
Linking,
|
||||
} from "react-native";
|
||||
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 { 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 Button from "../../components/ui/Button";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
@@ -29,6 +30,7 @@ const STORAGE_SIGNAL = "profile_signal_pseudo";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
|
||||
// Données compte (backend)
|
||||
const [nom, setNom] = useState("");
|
||||
@@ -49,11 +51,29 @@ export default function ProfileScreen() {
|
||||
const [telegramEnabled, setTelegramEnabled] = 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 [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
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
// Modals Telegram unlink
|
||||
const [showTelegramUnlinkModal, setShowTelegramUnlinkModal] = useState(false);
|
||||
const [showTelegramSuccessModal, setShowTelegramSuccessModal] = useState(false);
|
||||
const [currentPwd, setCurrentPwd] = useState("");
|
||||
const [newPwd, setNewPwd] = useState("");
|
||||
const [confirmPwd, setConfirmPwd] = useState("");
|
||||
@@ -64,15 +84,19 @@ export default function ProfileScreen() {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
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_PHONE),
|
||||
AsyncStorage.getItem(STORAGE_SIGNAL),
|
||||
getMyProfile(),
|
||||
getTelegramStatus(),
|
||||
get2FAStatus(),
|
||||
getPublicSettings(),
|
||||
]);
|
||||
setTelegramLinked(tgStatus.linked);
|
||||
setTelegramEnabled(tgStatus.enabled);
|
||||
setTwoFAEnabled(twoFAStatus.two_fa_enabled);
|
||||
setTwoFAAdminEnabled(pubSettings.two_fa_enabled);
|
||||
|
||||
if (savedAddress !== null) setDefaultAddress(savedAddress);
|
||||
if (savedPhone !== null) setDefaultPhone(savedPhone);
|
||||
@@ -103,7 +127,17 @@ export default function ProfileScreen() {
|
||||
AsyncStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim()),
|
||||
]);
|
||||
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 () => {
|
||||
@@ -124,35 +158,32 @@ export default function ProfileScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnlinkTelegram = async () => {
|
||||
Alert.alert(
|
||||
"Délier Telegram",
|
||||
"Vous ne recevrez plus de notifications Telegram. Continuer ?",
|
||||
[
|
||||
{ text: "Annuler", style: "cancel" },
|
||||
{
|
||||
text: "Délier",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
const handleUnlinkTelegram = () => {
|
||||
setShowTelegramUnlinkModal(true);
|
||||
};
|
||||
|
||||
const confirmUnlinkTelegram = async () => {
|
||||
setShowTelegramUnlinkModal(false);
|
||||
await unlinkTelegram();
|
||||
setTelegramLinked(false);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
setTwoFAEnabled(false);
|
||||
setShowTelegramSuccessModal(true);
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!currentPwd || !newPwd || !confirmPwd) {
|
||||
Alert.alert("Erreur", "Veuillez remplir tous les champs");
|
||||
setErrorMsg("Veuillez remplir tous les champs.");
|
||||
setShowErrorModal(true);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
setSavingPassword(true);
|
||||
@@ -161,23 +192,41 @@ export default function ProfileScreen() {
|
||||
if (result.success) {
|
||||
setShowPasswordModal(false);
|
||||
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 {
|
||||
Alert.alert("Erreur", result.message || "Erreur inattendue");
|
||||
setErrorMsg(result.message || "Erreur inattendue lors du changement de mot de passe.");
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
} finally {
|
||||
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 () => {
|
||||
setShowConfirmContactModal(false);
|
||||
setSavingContact(true);
|
||||
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
|
||||
setSavingContact(false);
|
||||
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 {
|
||||
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 },
|
||||
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]);
|
||||
|
||||
if (loadingProfile) {
|
||||
@@ -338,7 +398,7 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<KeyboardAvoidingView style={styles.container} behavior="padding">
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
|
||||
{/* Header */}
|
||||
@@ -387,7 +447,7 @@ export default function ProfileScreen() {
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.saveBtn}
|
||||
onPress={saveContact}
|
||||
onPress={() => setShowConfirmContactModal(true)}
|
||||
disabled={savingContact}
|
||||
>
|
||||
<Ionicons name="save-outline" size={16} color="#fff" />
|
||||
@@ -414,6 +474,15 @@ export default function ProfileScreen() {
|
||||
multiline
|
||||
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>
|
||||
|
||||
{/* Carte contact livraison */}
|
||||
@@ -466,6 +535,21 @@ export default function ProfileScreen() {
|
||||
</TouchableOpacity>
|
||||
</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 */}
|
||||
{telegramEnabled && (
|
||||
<View style={styles.card}>
|
||||
@@ -505,6 +589,42 @@ export default function ProfileScreen() {
|
||||
</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>
|
||||
|
||||
<Modal
|
||||
@@ -643,6 +763,224 @@ export default function ProfileScreen() {
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user