chore: build

This commit is contained in:
2026-06-09 20:04:39 +02:00
parent 15a454768d
commit b379508831
9 changed files with 427 additions and 50 deletions
+1
View File
@@ -18,6 +18,7 @@
},
"android": {
"package": "com.uberstup.adminpanel",
"abiFilters": ["arm64-v8a"],
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
"backgroundColor": "#000000"
+1 -1
View File
@@ -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"
+4 -18
View File
@@ -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"
},
+16 -5
View File
@@ -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 -8
View File
@@ -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 {
-8
View File
@@ -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[];
-2
View File
@@ -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) => {
-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,
@@ -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>