chore: build

This commit is contained in:
2026-06-14 18:09:44 +02:00
parent 3a0f725159
commit 6d4e0862ff
45 changed files with 3745 additions and 873 deletions
+18 -1
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
module.exports = ({ config }) => {
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
return {
...config,
updates: {
...config.updates,
...(updateUrl ? { url: updateUrl } : {}),
},
};
};
+10 -2
View File
@@ -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
View File
@@ -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"
}
}
}
+77
View File
@@ -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",
+1
View File
@@ -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",
+115 -23
View File
@@ -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<{
+37 -13
View File
@@ -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;
+24 -13
View File
@@ -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,
-2
View File
@@ -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) => {
-5
View File
@@ -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,
+2 -1
View File
@@ -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}
+1
View File
@@ -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>
);
}
+11
View File
@@ -0,0 +1,11 @@
module.exports = ({ config }) => {
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
return {
...config,
updates: {
...config.updates,
...(updateUrl ? { url: updateUrl } : {}),
},
};
};
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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;
+2 -3
View File
@@ -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) => {
-2
View File
@@ -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);
+62 -8
View File
@@ -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,
+1 -1
View File
@@ -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}
+80 -93
View File
@@ -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" },
});
+39 -5
View File
@@ -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
+23 -12
View File
@@ -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={[
+366 -28
View File
@@ -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 é 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>
);
}