chore: build
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"android": {
|
||||
"package": "com.uberstup.adminpanel",
|
||||
"abiFilters": ["arm64-v8a"],
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/icon.png",
|
||||
"backgroundColor": "#000000"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"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"
|
||||
|
||||
Generated
+4
-18
@@ -80,7 +80,6 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1368,7 +1367,6 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -2561,7 +2559,6 @@
|
||||
"version": "7.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz",
|
||||
"integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.14.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
@@ -2761,7 +2758,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -3330,7 +3326,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3976,6 +3971,7 @@
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
@@ -3985,6 +3981,7 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -4116,7 +4113,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz",
|
||||
"integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.24",
|
||||
@@ -4235,7 +4231,6 @@
|
||||
"version": "14.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
|
||||
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fontfaceobserver": "^2.1.0"
|
||||
},
|
||||
@@ -7092,7 +7087,6 @@
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7110,7 +7104,6 @@
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -7138,7 +7131,6 @@
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
|
||||
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/create-cache-key-function": "^29.7.0",
|
||||
"@react-native/assets-registry": "0.81.5",
|
||||
@@ -7265,7 +7257,6 @@
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
@@ -7275,7 +7266,6 @@
|
||||
"version": "4.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
|
||||
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"react-native-is-edge-to-edge": "^1.2.1",
|
||||
@@ -7290,7 +7280,6 @@
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
@@ -7320,7 +7309,6 @@
|
||||
"version": "13.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
|
||||
"integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"invariant": "2.2.4"
|
||||
@@ -7334,7 +7322,6 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
|
||||
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
|
||||
"@babel/plugin-transform-class-properties": "^7.0.0-0",
|
||||
@@ -7458,7 +7445,6 @@
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7715,7 +7701,8 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.4",
|
||||
@@ -8321,7 +8308,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -151,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[] = [];
|
||||
@@ -276,8 +273,6 @@ export const confirmReceptionAdmin = async (commandId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// LIVREURS
|
||||
// ============================================
|
||||
|
||||
export const getAvailableDeliveryPersons = async () => {
|
||||
@@ -930,6 +925,20 @@ export interface PointsTier {
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface RewardCategoryConfig {
|
||||
category: string;
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface PointsReward {
|
||||
threshold: number;
|
||||
type: "free_product" | "half_price_product" | "custom";
|
||||
description: string;
|
||||
category_configs: RewardCategoryConfig[];
|
||||
}
|
||||
|
||||
export interface PointsPool {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -1028,6 +1037,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[];
|
||||
@@ -1042,6 +1052,7 @@ export interface AppSettings {
|
||||
telegram_2fa_enabled: boolean;
|
||||
delivery_mode: DeliveryModeConfig;
|
||||
shop_name: string;
|
||||
contact_telegram: string;
|
||||
}
|
||||
|
||||
export const getSettings = async (): Promise<{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from "./client";
|
||||
import { API_BASE_URL } from "./client";
|
||||
|
||||
//@ts
|
||||
import type {
|
||||
OrderItem,
|
||||
DeliveryPerson,
|
||||
@@ -41,9 +42,6 @@ export const confirmReceptionCabine = async (commandId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PENALITES
|
||||
|
||||
export const applyClientPenalty = async (
|
||||
clientUsername: string,
|
||||
reason: string,
|
||||
@@ -71,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,
|
||||
@@ -97,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 {
|
||||
|
||||
@@ -42,10 +42,6 @@ export const updateMyStatus = async (
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// QUEUE
|
||||
// ============================================
|
||||
|
||||
export const getMyQueue = async (): Promise<{
|
||||
success: boolean;
|
||||
queue_info?: QueueInfo;
|
||||
@@ -62,10 +58,6 @@ export const getMyQueue = async (): Promise<{
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// LIVRAISONS
|
||||
// ============================================
|
||||
|
||||
export const getMyDeliveries = async (): Promise<{
|
||||
success: boolean;
|
||||
deliveries?: DeliveryItem[];
|
||||
|
||||
@@ -12,7 +12,6 @@ const apiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// attach JWT token
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
const isAdminRoute =
|
||||
config.url?.includes("/api/v2/") ||
|
||||
@@ -27,7 +26,6 @@ apiClient.interceptors.request.use(async (config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor: handle common errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import axios from "axios";
|
||||
|
||||
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
|
||||
|
||||
// ---- Types ----
|
||||
export interface LatLng {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -24,7 +23,6 @@ export interface NavigationInstruction {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ---- Maneuver translations (FR) ----
|
||||
export const maneuverTranslations: Record<string, string> = {
|
||||
TURN_LEFT: "Tournez à gauche",
|
||||
TURN_RIGHT: "Tournez à droite",
|
||||
@@ -48,7 +46,6 @@ export const maneuverTranslations: Record<string, string> = {
|
||||
WAYPOINT_REACHED: "Point de passage atteint",
|
||||
};
|
||||
|
||||
// Ionicons name per maneuver
|
||||
export const maneuverIcons: Record<string, string> = {
|
||||
TURN_LEFT: "arrow-back",
|
||||
TURN_RIGHT: "arrow-forward",
|
||||
@@ -79,7 +76,6 @@ function formatDistance(meters: number): string {
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
|
||||
// ---- Geocode address → coords ----
|
||||
export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
||||
try {
|
||||
const url = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(address)}.json?key=${TOMTOM_API_KEY}&limit=1`;
|
||||
@@ -96,7 +92,6 @@ export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Calculate route ----
|
||||
export async function calculateRoute(
|
||||
origin: LatLng,
|
||||
destination: LatLng,
|
||||
|
||||
@@ -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, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||
import type { Product } from "../../api/types";
|
||||
import AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
@@ -643,6 +644,358 @@ function TiersSection({
|
||||
);
|
||||
}
|
||||
|
||||
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" },
|
||||
{ value: "custom", label: "Personnalisé", icon: "star-outline" },
|
||||
];
|
||||
|
||||
const EMPTY_REWARD: PointsReward = {
|
||||
threshold: 20,
|
||||
type: "free_product",
|
||||
description: "",
|
||||
category_configs: [],
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 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 }}>
|
||||
{/* Montant pour cette catégorie */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||
<Text style={{ fontSize: 12, color: s.thresholdSep.color ?? "#888", flex: 1 }}>
|
||||
Valeur du produit offert
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
|
||||
keyboardType="decimal-pad"
|
||||
value={catConfig.amount > 0 ? String(catConfig.amount) : ""}
|
||||
onChangeText={(v) => {
|
||||
const n = parseFloat(v);
|
||||
onChange({ ...catConfig, amount: isNaN(n) ? 0 : n });
|
||||
}}
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted }}>€</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 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 r = reward ?? EMPTY_REWARD;
|
||||
|
||||
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: [], amount: 0 };
|
||||
|
||||
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: [], amount: 0 }] });
|
||||
}
|
||||
};
|
||||
|
||||
const updateCatConfig = (cfg: RewardCategoryConfig) => {
|
||||
update({
|
||||
category_configs: r.category_configs.map((c) =>
|
||||
c.category === cfg.category ? cfg : c
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
|
||||
<Text style={s.sectionTitle}>Récompenses par palier</Text>
|
||||
<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,
|
||||
marginBottom: spacing.s,
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{enabled ? "Activée" : "Désactivée"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Catégories éligibles */}
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</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 && (
|
||||
<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.category_configs.map((cfg) => (
|
||||
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
• {cfg.category}{cfg.amount > 0 ? ` (${cfg.amount}€)` : ""} : {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s) sélectionné(s)`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
||||
@@ -680,10 +1033,13 @@ export default function SettingsScreen() {
|
||||
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
|
||||
@@ -713,10 +1069,11 @@ export default function SettingsScreen() {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
isLoaded.current = false;
|
||||
const [settingsRes, categoriesRes, livreursRes] = await Promise.all([
|
||||
const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([
|
||||
getSettings(),
|
||||
getCategories(),
|
||||
getAvailableDeliveryPersons(),
|
||||
getAllProductsAdmin(),
|
||||
]);
|
||||
if (settingsRes.success && settingsRes.settings) {
|
||||
setSettings({
|
||||
@@ -725,6 +1082,7 @@ export default function SettingsScreen() {
|
||||
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: [] },
|
||||
points_reward: settingsRes.settings.points_reward ?? null,
|
||||
});
|
||||
}
|
||||
if (categoriesRes) {
|
||||
@@ -733,6 +1091,15 @@ 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);
|
||||
}
|
||||
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
|
||||
@@ -1256,6 +1623,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}
|
||||
@@ -1486,6 +1863,30 @@ 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>
|
||||
</View>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user