import React, { useState, useEffect, useCallback, useRef } from "react"; import { useFocusEffect } from "@react-navigation/native"; import { View, Text, StyleSheet, ScrollView, Switch, TouchableOpacity, ActivityIndicator, TextInput, Keyboard, useWindowDimensions, } from "react-native"; 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, 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"; const POOL_COLORS = ["#10b981", "#9333ea", "#f97316", "#3b82f6", "#ef4444"]; const CRYPTO_CURRENCIES = [ { id: "btc", label: "BTC", icon: "₿", color: "#f7931a" }, { id: "eth", label: "ETH", icon: "Ξ", color: "#627eea" }, { id: "ltc", label: "LTC", icon: "Ł", color: "#bebebe" }, { id: "usdt", label: "USDT", icon: "₮", color: "#26a17b" }, { id: "usdttrc20",label: "USDT TRC20",icon: "₮", color: "#c23631" }, { id: "bnb", label: "BNB", icon: "B", color: "#f3ba2f" }, { id: "sol", label: "SOL", icon: "◎", color: "#9945ff" }, { id: "doge", label: "DOGE", icon: "Ð", color: "#c2a633" }, { id: "trx", label: "TRX", icon: "T", color: "#c23631" }, { id: "xmr", label: "XMR", icon: "ɱ", color: "#ff6600" }, ]; const DAYS: { key: keyof DeliverySchedule; label: string }[] = [ { key: "monday", label: "Lundi" }, { key: "tuesday", label: "Mardi" }, { key: "wednesday", label: "Mercredi" }, { key: "thursday", label: "Jeudi" }, { key: "friday", label: "Vendredi" }, { key: "saturday", label: "Samedi" }, { 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 ( setOpen((o) => !o)} activeOpacity={0.7} style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: spacing.l, paddingVertical: spacing.m, }} > {title} {badge} {open && ( {children} )} ); } function DeliveryScheduleSection({ schedule, onChange, colors, s, }: { schedule: DeliverySchedule; onChange: (sched: DeliverySchedule) => void; colors: any; s: any; }) { const { width: screenWidth } = useWindowDimensions(); const updateDay = (key: keyof DeliverySchedule, patch: Partial) => { onChange({ ...schedule, [key]: { ...schedule[key], ...patch } }); }; return ( Définissez les jours et horaires d'ouverture pour la livraison. {DAYS.map(({ key, label }, index) => { const day = schedule[key]; return ( {/* Ligne : jour + toggle */} {label} updateDay(key, { enabled: v })} trackColor={{ false: colors.border, true: "#10b981" }} thumbColor="#fff" /> {/* Horaires */} {day.enabled && ( Ouverture updateDay(key, { open_time: v })} placeholder="09:00" placeholderTextColor={colors.textMuted} keyboardType="numbers-and-punctuation" maxLength={5} /> Fermeture updateDay(key, { close_time: v })} placeholder="20:00" placeholderTextColor={colors.textMuted} keyboardType="numbers-and-punctuation" maxLength={5} /> )} {!day.enabled && ( Fermé ce jour )} ); })} ); } // ────────────────────────────────────────────────────────────── // Composant éditeur de zones postales // ────────────────────────────────────────────────────────────── function parsePostalCodes(raw: string): string[] { return raw .split(/[\s,;]+/) .map((c) => c.trim()) .filter((c) => /^\d{5}$/.test(c)); } type NewZoneForm = { name: string; min_amount: string; codesRaw: string }; const EMPTY_FORM: NewZoneForm = { name: "", min_amount: "", codesRaw: "" }; function PostalZonesSection({ zones, onChange, colors, s, }: { zones: PostalZone[]; onChange: (zones: PostalZone[]) => void; colors: any; s: any; }) { const { width: screenWidth } = useWindowDimensions(); const inputMd = screenWidth < 380 ? 64 : 80; const inputLg = screenWidth < 380 ? 80 : 100; const [expandedIndex, setExpandedIndex] = React.useState(null); const [creating, setCreating] = React.useState(false); const [form, setForm] = React.useState(EMPTY_FORM); const [formError, setFormError] = React.useState(""); const removeZone = (i: number) => { onChange(zones.filter((_, idx) => idx !== i)); if (expandedIndex === i) setExpandedIndex(null); }; const updateZone = (i: number, patch: Partial) => { onChange(zones.map((z, idx) => (idx === i ? { ...z, ...patch } : z))); }; const removeCode = (zoneIdx: number, codeIdx: number) => { updateZone(zoneIdx, { codes: zones[zoneIdx].codes.filter((_, idx) => idx !== codeIdx), }); }; const confirmCreate = () => { const name = form.name.trim(); if (!name) { setFormError("Le nom de la zone est obligatoire."); return; } const min = parseFloat(form.min_amount); if (isNaN(min) || min < 0) { setFormError("Montant minimum invalide."); return; } const codes = parsePostalCodes(form.codesRaw); if (codes.length === 0) { setFormError("Ajoutez au moins un code postal valide (5 chiffres)."); return; } onChange([...zones, { name, min_amount: min, codes }]); setCreating(false); setForm(EMPTY_FORM); setFormError(""); setExpandedIndex(zones.length); // expand newly created }; return ( Définissez les zones de livraison et leur montant minimum de commande. {zones.map((zone, i) => { const expanded = expandedIndex === i; return ( {/* En-tête zone */} setExpandedIndex(expanded ? null : i)} style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: spacing.l, paddingVertical: spacing.m, gap: spacing.s, }} activeOpacity={0.7} > {zone.name || "Zone sans nom"} {zone.min_amount > 0 ? `${zone.min_amount}€ min` : "Sans min"} {zone.codes.length} codes removeZone(i)} style={{ marginLeft: spacing.s }}> {/* Détail zone (édition) */} {expanded && ( {/* Nom */} Nom updateZone(i, { name: v })} placeholder="Ex: Zone centre-ville" placeholderTextColor={colors.textMuted} /> {/* Minimum */} Min € { const n = parseFloat(v); if (!isNaN(n)) updateZone(i, { min_amount: n }); }} keyboardType="decimal-pad" placeholder="0" placeholderTextColor={colors.textMuted} /> {/* Codes postaux — chips supprimables */} Codes postaux ({zone.codes.length}) — appuyer pour supprimer : {zone.codes.map((code, ci) => ( removeCode(i, ci)} style={{ flexDirection: "row", alignItems: "center", backgroundColor: colors.accent + "20", borderRadius: borderRadius.sm, paddingHorizontal: spacing.s, paddingVertical: 4, gap: 4, }} > {code} ))} {/* Ajout en masse */} { const existing = new Set(zone.codes); const toAdd = newCodes.filter((c) => !existing.has(c)); if (toAdd.length > 0) updateZone(i, { codes: [...zone.codes, ...toAdd] }); }} colors={colors} s={s} /> )} ); })} {zones.length === 0 && !creating && ( Aucune zone définie — toutes les commandes seront acceptées sans minimum. )} {/* Formulaire de création */} {creating && ( Nouvelle zone {/* Nom */} Nom de la zone * { setForm((f) => ({ ...f, name: v })); setFormError(""); }} placeholder="Ex: Zone centre-ville" placeholderTextColor={colors.textMuted} autoFocus /> {/* Minimum */} Montant minimum de commande (€) * { setForm((f) => ({ ...f, min_amount: v })); setFormError(""); }} keyboardType="decimal-pad" placeholder="Ex: 30" placeholderTextColor={colors.textMuted} /> {/* Codes postaux */} Codes postaux * (séparés par virgules ou espaces) { setForm((f) => ({ ...f, codesRaw: v })); setFormError(""); }} placeholder={"44000, 44100, 44200\n44300 44400"} placeholderTextColor={colors.textMuted} multiline keyboardType="numbers-and-punctuation" /> {form.codesRaw.length > 0 && ( {parsePostalCodes(form.codesRaw).length} code(s) valide(s) détecté(s) )} {formError !== "" && ( {formError} )} Créer la zone { setCreating(false); setForm(EMPTY_FORM); setFormError(""); }} style={{ paddingHorizontal: spacing.l, borderRadius: borderRadius.sm, paddingVertical: spacing.s, alignItems: "center", justifyContent: "center", borderWidth: 1, borderColor: colors.border, }} > Annuler )} {!creating && ( { setCreating(true); setForm(EMPTY_FORM); setFormError(""); }} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, }} > Ajouter une zone )} ); } function BulkCodesInput({ onAdd, colors, s, }: { onAdd: (codes: string[]) => void; colors: any; s: any; }) { const [value, setValue] = React.useState(""); const parsed = parsePostalCodes(value); const handleAdd = () => { if (parsed.length === 0) return; onAdd(parsed); setValue(""); }; return ( Ajouter des codes (virgules ou espaces) : 0 ? colors.accent : colors.border, borderRadius: borderRadius.sm, padding: spacing.s, alignItems: "center", justifyContent: "center", minWidth: 48, height: 60, }} disabled={parsed.length === 0} > {parsed.length > 0 && ( {parsed.length} )} {value.length > 0 && parsed.length > 0 && ( {parsed.length} code(s) valide(s) : {parsed.join(", ")} )} ); } // ────────────────────────────────────────────────────────────── // Composant éditeur de paliers // ────────────────────────────────────────────────────────────── function TiersSection({ title, tiers, onChange, colors, s, accentColor, active, }: { title: string; tiers: PointsTier[]; onChange: (tiers: PointsTier[]) => void; colors: any; s: any; accentColor: string; active: boolean; }) { const { width: screenWidth } = useWindowDimensions(); const inputSm = screenWidth < 380 ? 46 : 56; const updateTier = (i: number, field: keyof PointsTier, raw: string) => { const val = field === "points" ? parseInt(raw, 10) : parseFloat(raw); if (isNaN(val)) return; const next = tiers.map((t, idx) => (idx === i ? { ...t, [field]: val } : t)); onChange(next); }; const addTier = () => { onChange([...tiers, { min: 0, max: 0, points: 1 }]); }; const removeTier = (i: number) => { onChange(tiers.filter((_, idx) => idx !== i)); }; const badge = ( {active ? "Actif" : "Ignoré"} ); return ( Définissez chaque palier : de X€ à Y€ = N points.{"\n"} Max = 0 signifie illimité (pas de borne supérieure). {/* En-tête colonnes */} Min € Max € Pts {tiers.map((tier, i) => ( updateTier(i, "min", v)} /> updateTier(i, "max", v)} placeholder="0=∞" placeholderTextColor={colors.textMuted} /> = updateTier(i, "points", v)} /> pts removeTier(i)}> ))} {tiers.length === 0 && ( Aucun palier défini )} Ajouter un palier ); } const REWARD_ACCENT = "#f59e0b"; const REWARD_TYPES: { value: RewardCategoryConfig["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, 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 exists = catConfig.products.some((pq) => pq.product_id === id); if (exists) { onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false }); return; } // Présélectionne le premier palier de prix actif du produit, plutôt // qu'une valeur arbitraire qui pourrait ne correspondre à aucun palier réel. const prod = catProducts.find((p) => p.id === id); const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false); onChange({ ...catConfig, products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }], all_products: false, }); }; const updateProductQuantity = (id: number, quantity: number) => { onChange({ ...catConfig, products: catConfig.products.map((pq) => pq.product_id === id ? { ...pq, quantity } : pq ), }); }; return ( {/* Toggle tous / sélection */} onChange({ ...catConfig, all_products: true, products: [] })} 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", }} > Tous ({catProducts.length}) 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", }} > Sélection {/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */} {catConfig.all_products && ( Quantité : 0 ? String(catConfig.quantity) : ""} onChangeText={(v) => { const n = parseFloat(v); onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n }); }} placeholder="1" placeholderTextColor={colors.textMuted} /> doit correspondre à un palier de prix existant )} {/* Mode "Sélection" : chaque produit choisi a sa propre quantité (ex: produit A à 2g offerts, produit B à 1g offert) */} {!catConfig.all_products && ( {catProducts.length === 0 ? ( Aucun produit dans cette catégorie ) : ( {catProducts.map((p) => { const sel = catConfig.products.some((pq) => pq.product_id === p.id); return ( 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 && } {p.name} ); })} )} {catConfig.products.length > 0 && ( {catConfig.products.map((pq) => { const prod = catProducts.find((p) => p.id === pq.product_id); const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false); return ( {prod?.name ?? `Produit #${pq.product_id}`} {tiers.length === 0 ? ( Aucun palier de prix actif pour ce produit ) : tiers.map((tier) => { const isSel = pq.quantity === tier.quantity; return ( updateProductQuantity(pq.product_id, tier.quantity)} style={{ paddingHorizontal: spacing.s, paddingVertical: 3, borderRadius: borderRadius.sm, borderWidth: 1.5, borderColor: isSel ? REWARD_ACCENT : colors.border, backgroundColor: isSel ? REWARD_ACCENT + "22" : "transparent", flexDirection: "row", alignItems: "center", gap: 4, }} > {isSel && } {tier.quantity}{prod?.unit ?? ""} · {tier.price}€ ); })} ); })} )} )} ); } // ────────────────────────────────────────────────────────────── // Section centralisée récompenses par palier // ────────────────────────────────────────────────────────────── function CentralRewardSection({ reward, allCategories, productsByCategory, onChange, colors, s, }: { reward: PointsReward | null | undefined; allCategories: Category[]; productsByCategory: Record; 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) => onChange({ ...r, ...patch }); // Une catégorie peut avoir jusqu'à deux configs actives en parallèle // (une "free_product" et une "half_price_product"), chacune avec sa // propre sélection de produits — d'où la recherche par (catégorie, type). const getCatConfig = (catName: string, type: RewardCategoryConfig["type"]): RewardCategoryConfig => r.category_configs.find((c) => c.category === catName && c.type === type) ?? { category: catName, type, all_products: true, products: [], quantity: 1 }; const isTypeEnabled = (catName: string, type: RewardCategoryConfig["type"]) => r.category_configs.some((c) => c.category === catName && c.type === type); const isCatSelected = (catName: string) => r.category_configs.some((c) => c.category === catName); const toggleCategoryType = (catName: string, type: RewardCategoryConfig["type"]) => { if (isTypeEnabled(catName, type)) { update({ category_configs: r.category_configs.filter((c) => !(c.category === catName && c.type === type)) }); } else { update({ category_configs: [...r.category_configs, { category: catName, type, all_products: true, products: [], quantity: 1 }] }); } }; const updateCatConfig = (cfg: RewardCategoryConfig) => { update({ category_configs: r.category_configs.map((c) => c.category === cfg.category && c.type === cfg.type ? cfg : c ), }); }; const [expandedCats, setExpandedCats] = useState>(new Set()); const toggleExpanded = (catName: string) => { setExpandedCats((prev) => { const next = new Set(prev); if (next.has(catName)) next.delete(catName); else next.add(catName); return next; }); }; const rewardBadge = ( {enabled ? "Activée" : "Désactivée"} ); return ( Récompense activée Le seuil s'applique indépendamment à chaque type de points. onChange(v ? EMPTY_REWARD : null)} trackColor={{ false: colors.border, true: REWARD_ACCENT }} thumbColor="#fff" /> {enabled && ( {/* Seuil */} Seuil de points Dès X points cumulés dans un type, la récompense est débloquée pour ce type. { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) update({ threshold: n }); }} /> pts {/* Description */} Description affichée au client update({ description: v })} placeholder="Ex : Un produit 30€ de ton choix parmi les produits conditionnés en 30€" placeholderTextColor={colors.textMuted} multiline /> {/* Catégories éligibles — chaque catégorie choisit son propre type (produit offert ou -50%), qui s'applique aux produits récompense de cette catégorie configurés ci-dessus */} Catégories éligibles Sélectionnez une catégorie pour activer, indépendamment, un lot de produits offerts et/ou un lot de produits à -50%, chacun avec sa propre sélection de produits. {allCategories.length === 0 ? ( Aucune catégorie disponible ) : ( {allCategories.map((cat) => { const selected = isCatSelected(cat.name); const expanded = expandedCats.has(cat.name); const catColor = cat.color || REWARD_ACCENT; return ( {/* Chip catégorie (couleur = au moins un type actif, clic = déplier/replier) */} toggleExpanded(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", }} > {cat.name} {/* Les deux types de récompense, activables indépendamment */} {expanded && ( {REWARD_TYPES.map((rt) => { const typeEnabled = isTypeEnabled(cat.name, rt.value); return ( toggleCategoryType(cat.name, rt.value)} style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start", paddingHorizontal: spacing.s, paddingVertical: 4, borderRadius: borderRadius.sm, borderWidth: 1.5, borderColor: typeEnabled ? REWARD_ACCENT : colors.border, backgroundColor: typeEnabled ? REWARD_ACCENT + "22" : "transparent", }} > {rt.label} {typeEnabled && ( )} ); })} )} ); })} )} {/* Récapitulatif */} {r.category_configs.length > 0 && ( Récapitulatif Dès {r.threshold} pts par type de points → récompense débloquée {r.description !== "" && ( "{r.description}" )} {r.category_configs.map((cfg, idx) => ( • {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products ? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}` : `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`} ))} )} )} ); } // Palette violette d'origine de l'application (thème par défaut historique) const ORIGINAL_THEME_COLORS = { admin_color_primary: "#7c3aed", admin_color_secondary: "#000000", admin_color_success: "#4ade80", admin_color_danger: "#ef4444", admin_color_warning: "#f59e0b", client_color_primary: "#7c3aed", client_color_secondary: "#000000", client_color_success: "#4ade80", client_color_danger: "#ef4444", client_color_warning: "#f59e0b", client_title_gradient_from: "#a78bfa", client_title_gradient_to: "#22d3ee", } as const; export default function SettingsScreen() { const { colors, refreshColors } = useTheme(); const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert(); const navigation = useNavigation(); const { width: screenWidth } = useWindowDimensions(); const inputMd = screenWidth < 380 ? 64 : 80; const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({ penalties_enabled: true, show_amende_score: true, penalty_tiers: [ { min_cancel: 0, amount: 20 }, { min_cancel: 1, amount: 50 }, { min_cancel: 2, amount: 100 }, { min_cancel: 3, amount: 150 }, ], points_enabled: true, points_pools: [ { key: "pool_0", name: "Pool 1", categories: [], tiers: [] }, { key: "pool_1", name: "Pool 2", categories: [], tiers: [] }, ], referral_enabled: true, delivery_schedule: DEFAULT_DELIVERY_SCHEDULE, postal_zones: DEFAULT_POSTAL_ZONES, crypto_payment_enabled: false, crypto_only: false, nowpayments_api_key: "", nowpayments_ipn_secret: "", nowpayments_currencies: [], 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, admin_color_primary: "#7c3aed", admin_color_secondary: "#22d3ee", admin_color_success: "#4ade80", admin_color_danger: "#ef4444", admin_color_warning: "#f59e0b", client_color_primary: "#7c3aed", client_color_secondary: "#22d3ee", client_color_success: "#4ade80", client_color_danger: "#ef4444", client_color_warning: "#f59e0b", client_title_gradient_from: "#a78bfa", client_title_gradient_to: "#22d3ee", }); const [showApiKey, setShowApiKey] = useState(false); const [showIpnSecret, setShowIpnSecret] = useState(false); const [categories, setCategories] = useState([]); const [productsByCategory, setProductsByCategory] = useState>({}); const [livreurs, setLivreurs] = useState([]); // Refs pour l'auto-sauvegarde au départ de la page const settingsRef = useRef(settings); const isDirty = useRef(false); const isLoaded = useRef(false); useEffect(() => { settingsRef.current = settings; if (isLoaded.current) { isDirty.current = true; } }, [settings]); // Auto-sauvegarde silencieuse quand l'utilisateur quitte la page useEffect(() => { const unsubscribe = navigation.addListener("beforeRemove", () => { if (isDirty.current) { Keyboard.dismiss(); updateSettings(settingsRef.current).catch(() => {}); isDirty.current = false; } }); return unsubscribe; }, [navigation]); const loadData = useCallback(async () => { setLoading(true); isLoaded.current = false; try { const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([ getSettings(), getCategories(), getAvailableDeliveryPersons(), getAllProductsAdmin(), ]); if (settingsRes.success && settingsRes.settings) { const s = settingsRes.settings; setSettings({ ...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 ?? []).map((cfg) => ({ ...cfg, products: cfg.products ?? [], })), } : null, }); } if (categoriesRes) { setCategories(categoriesRes.filter((c) => !c.is_coming_soon)); } if (livreursRes.success) { setLivreurs(livreursRes.livreurs.map((l: any) => l.username)); } if (productsRes.success) { const byCategory: Record = {}; 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); setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0); } }, []); useFocusEffect( useCallback(() => { if (!isDirty.current) { loadData(); } }, [loadData]) ); // Retourne l'index du pool auquel la catégorie est assignée, ou -1 si aucun const getPoolIndexFor = (catName: string): number => { const pools = settings.points_pools ?? []; for (let i = 0; i < pools.length; i++) { if ((pools[i].categories ?? []).includes(catName)) return i; } return -1; }; // Assigne une catégorie à un pool (ou la retire si poolIdx === -1) const setCategoryPool = (catName: string, poolIdx: number) => { setSettings((prev) => { const pools = (prev.points_pools ?? []).map((pool, i) => ({ ...pool, categories: (pool.categories ?? []).filter((c) => c !== catName), })); if (poolIdx >= 0 && poolIdx < pools.length) { pools[poolIdx] = { ...pools[poolIdx], categories: [...(pools[poolIdx].categories ?? []), catName], }; } return { ...prev, points_pools: pools }; }); }; const addPool = () => { setSettings((prev) => { const pools = prev.points_pools ?? []; const newKey = `pool_${Date.now()}`; return { ...prev, points_pools: [...pools, { key: newKey, name: `Type ${pools.length + 1}`, categories: [], tiers: [] }], }; }); }; const removePool = (poolIdx: number) => { setSettings((prev) => { const pools = (prev.points_pools ?? []).filter((_, i) => i !== poolIdx); return { ...prev, points_pools: pools }; }); }; const renamePool = (poolIdx: number, name: string) => { setSettings((prev) => { const pools = (prev.points_pools ?? []).map((p, i) => i === poolIdx ? { ...p, name } : p ); return { ...prev, points_pools: pools }; }); }; const updatePoolTiers = (poolIdx: number, tiers: PointsTier[]) => { setSettings((prev) => { const pools = (prev.points_pools ?? []).map((p, i) => i === poolIdx ? { ...p, tiers } : p ); return { ...prev, points_pools: pools }; }); }; const handleSave = async () => { // Fermer le clavier pour s'assurer que les TextInputs commitent leur valeur Keyboard.dismiss(); setSaving(true); // Utiliser le ref pour avoir la valeur la plus à jour (post-blur) const current = settingsRef.current; const res = await updateSettings(current); setSaving(false); if (res.success) { isDirty.current = false; await loadData(); await refreshColors(); showSuccess("Succès", "Paramètres sauvegardés"); } else { showError("Erreur", res.error || "Erreur lors de la sauvegarde"); } }; const s = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgPrimary }, content: { padding: screenWidth < 380 ? spacing.m : spacing.l, paddingBottom: spacing.xl * 2 }, section: { backgroundColor: colors.bgSecondary, borderRadius: borderRadius.lg, marginBottom: spacing.l, overflow: "hidden", }, sectionTitle: { fontSize: fontSize.sm, fontWeight: "700", color: colors.textMuted, textTransform: "uppercase", letterSpacing: 0.8, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.s, }, row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, }, rowFirst: { borderTopWidth: 0 }, rowLeft: { flex: 1, marginRight: spacing.m }, rowLabel: { fontSize: fontSize.md, color: colors.textPrimary, fontWeight: "600" }, rowDesc: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 }, catRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, gap: spacing.s, }, catRowFirst: { borderTopWidth: 0 }, colorDot: { width: 10, height: 10, borderRadius: 5 }, catName: { flex: 1, fontSize: fontSize.md, color: colors.textPrimary }, chips: { flexDirection: "row", gap: spacing.xs }, chip: { paddingHorizontal: spacing.s, paddingVertical: 4, borderRadius: borderRadius.sm, borderWidth: 1, }, chipText: { fontSize: fontSize.sm, fontWeight: "600" }, hint: { fontSize: fontSize.sm, color: colors.textMuted, fontStyle: "italic", paddingHorizontal: spacing.l, paddingBottom: spacing.m, }, thresholdRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, gap: spacing.m, }, thresholdLabel: { flex: 1, fontSize: fontSize.md, color: colors.textPrimary, fontWeight: "600" }, thresholdDesc: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 }, thresholdInputs: { flexDirection: "row", alignItems: "center", gap: spacing.s }, thresholdInput: { backgroundColor: colors.bgPrimary, borderWidth: 1, borderColor: colors.border, borderRadius: borderRadius.sm, paddingHorizontal: spacing.s, paddingVertical: 6, color: colors.textPrimary, fontSize: fontSize.md, width: 64, textAlign: "center", }, thresholdSep: { fontSize: fontSize.md, color: colors.textMuted }, input: { backgroundColor: colors.bgPrimary, borderWidth: 1, borderColor: colors.border, borderRadius: borderRadius.sm, paddingHorizontal: spacing.m, paddingVertical: spacing.s, color: colors.textPrimary, fontSize: fontSize.sm, }, saveButton: { backgroundColor: colors.accent, borderRadius: borderRadius.lg, paddingVertical: spacing.m, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: spacing.s, marginTop: spacing.s, }, saveButtonText: { color: "#fff", fontSize: fontSize.md, fontWeight: "700" }, }); if (loading) { return ( ); } const pools = settings.points_pools ?? []; return ( {/* Personnalisation */} {/* Nom du shop */} Nom du shop Affiché dans la sidebar du site client setSettings((p) => ({ ...p, shop_name: v }))} placeholder="Ex: Milieu-Nantais" placeholderTextColor={colors.textMuted} autoCorrect={false} /> {/* Dégradé titre boutique */} Dégradé du titre boutique Couleurs du nom de la boutique dans le header du site client. {/* Aperçu du dégradé */} {/* Couleur de départ */} Couleur de départ setSettings((p) => ({ ...p, client_title_gradient_from: v }))} placeholder="#a78bfa" placeholderTextColor={colors.textMuted} autoCorrect={false} autoCapitalize="none" maxLength={7} /> {/* Couleur de fin */} Couleur de fin setSettings((p) => ({ ...p, client_title_gradient_to: v }))} placeholder="#22d3ee" placeholderTextColor={colors.textMuted} autoCorrect={false} autoCapitalize="none" maxLength={7} /> {/* Amendes */} Amendes activées Bloquer les commandes si le client a une amende non payée setSettings((prev) => ({ ...prev, penalties_enabled: v })) } trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> Afficher le score d'amendes Les clients et la cabine voient le nombre d'amendes setSettings((prev) => ({ ...prev, show_amende_score: v })) } trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> Barème des amendes setSettings((prev) => ({ ...prev, penalty_tiers: [ ...prev.penalty_tiers, { min_cancel: prev.penalty_tiers.length > 0 ? prev.penalty_tiers[prev.penalty_tiers.length - 1].min_cancel + 1 : 0, amount: 0, }, ], })) } > À partir de (annul.) Montant {(settings.penalty_tiers ?? []).map((tier, i) => ( { const n = parseInt(v, 10); setSettings((prev) => { const tiers = [...prev.penalty_tiers]; tiers[i] = { ...tiers[i], min_cancel: isNaN(n) ? 0 : n }; return { ...prev, penalty_tiers: tiers }; }); }} keyboardType="number-pad" placeholderTextColor={colors.textMuted} /> { const n = parseInt(v, 10); setSettings((prev) => { const tiers = [...prev.penalty_tiers]; tiers[i] = { ...tiers[i], amount: isNaN(n) ? 0 : n }; return { ...prev, penalty_tiers: tiers }; }); }} keyboardType="number-pad" placeholderTextColor={colors.textMuted} /> setSettings((prev) => ({ ...prev, penalty_tiers: prev.penalty_tiers.filter((_, j) => j !== i), })) } > ))} {/* Parrainage */} Parrainage activé Les clients peuvent voir et utiliser leur solde parrainage au checkout.{"\n"} Désactivé : le solde reste en base mais ne peut plus être utilisé. setSettings((prev) => ({ ...prev, referral_enabled: v })) } trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> {/* Système de points */} Points activés Les clients voient leurs scores de points.{"\n"} La cabine peut réinitialiser les points. setSettings((prev) => ({ ...prev, points_enabled: v })) } trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> {/* Gestion des types de points */} Types de points Créez vos propres types de points. Le 1er type → colonne principale, le 2e → colonne secondaire. {pools.map((pool, i) => { const color = POOL_COLORS[i % POOL_COLORS.length]; return ( renamePool(i, v)} placeholder={`Type ${i + 1}`} placeholderTextColor={colors.textMuted} /> removePool(i)}> ); })} Ajouter un type {/* Attribution des catégories */} Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"} Vous pouvez modifier les attributions à tout moment. {/* Légende */} {pools.map((pool, i) => ( {pool.name} ))} Aucun {categories.map((cat, index) => { const assignedPoolIdx = getPoolIndexFor(cat.name); return ( {cat.name} {pools.map((pool, pi) => { const active = assignedPoolIdx === pi; const chipColor = POOL_COLORS[pi % POOL_COLORS.length]; const label = pool.name.slice(0, 2).toUpperCase(); return ( setCategoryPool(cat.name, active ? -1 : pi)} > {label} ); })} {/* Chip "Aucun" */} setCategoryPool(cat.name, -1)} > ); })} {categories.length === 0 && ( Aucune catégorie disponible )} {pools.length === 0 && categories.length > 0 && ( Ajoutez au moins un type de point pour assigner des catégories. )} {/* Barèmes de points — un par pool */} {pools.map((pool, i) => ( updatePoolTiers(i, tiers)} colors={colors} s={s} accentColor={POOL_COLORS[i % POOL_COLORS.length]} active={true} /> ))} {/* Récompense centralisée par palier */} setSettings((p) => ({ ...p, points_reward: reward }))} colors={colors} s={s} /> {/* Horaires de livraison */} setSettings((p) => ({ ...p, delivery_schedule: sched }))} colors={colors} s={s} /> {/* Zones de livraison */} setSettings((p) => ({ ...p, postal_zones: zones }))} colors={colors} s={s} /> {/* Paiement crypto */} {/* Toggle activation */} Paiement crypto activé Les clients peuvent payer leurs commandes en cryptomonnaie via NowPayments. setSettings((prev) => ({ ...prev, crypto_payment_enabled: v })) } trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> {/* Toggle crypto uniquement */} Crypto uniquement Désactive le paiement en espèces — seule la crypto est acceptée. setSettings((prev) => ({ ...prev, crypto_only: v })) } trackColor={{ false: colors.border, true: "#f7931a" }} thumbColor="#fff" /> {/* Config NowPayments */} {/* Clé API */} Clé API NowPayments setSettings((p) => ({ ...p, nowpayments_api_key: v }))} placeholder="Votre clé API NowPayments" placeholderTextColor={colors.textSecondary} secureTextEntry={!showApiKey} autoCapitalize="none" autoCorrect={false} /> setShowApiKey((x) => !x)} style={{ padding: spacing.xs }}> {/* Secret IPN */} Secret IPN setSettings((p) => ({ ...p, nowpayments_ipn_secret: v }))} placeholder="Secret IPN pour la vérification des webhooks" placeholderTextColor={colors.textSecondary} secureTextEntry={!showIpnSecret} autoCapitalize="none" autoCorrect={false} /> setShowIpnSecret((x) => !x)} style={{ padding: spacing.xs }}> URL IPN à configurer sur NowPayments :{"\n"} https://5.181.0.112.nip.io/api/v1/webhooks/nowpayments {/* Cryptos acceptées */} Cryptomonnaies acceptées Sélectionnez les cryptos que vos clients peuvent utiliser pour payer. {CRYPTO_CURRENCIES.map((coin) => { const selected = (settings.nowpayments_currencies ?? []).includes(coin.id); return ( { setSettings((prev) => { const current = prev.nowpayments_currencies ?? []; const updated = selected ? current.filter((c) => c !== coin.id) : [...current, coin.id]; return { ...prev, nowpayments_currencies: updated }; }); }} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, paddingHorizontal: spacing.m, paddingVertical: spacing.s, borderRadius: borderRadius.full, borderWidth: 1.5, borderColor: selected ? coin.color : colors.border, backgroundColor: selected ? coin.color + "22" : colors.bgSecondary, }} > {coin.icon} {coin.label} ); })} {(settings.nowpayments_currencies ?? []).length === 0 && ( ⚠ Sélectionnez au moins une cryptomonnaie. )} {/* ============================================ */} {/* 🤖 TELEGRAM */} {/* ============================================ */} Notifications activées Envoyer les notifications via Telegram aux utilisateurs qui ont lié leur compte setSettings((prev) => ({ ...prev, telegram_notifications_enabled: v }))} trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> Authentification 2FA Permettre aux clients d'activer la double authentification via Telegram lors de la connexion setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))} trackColor={{ false: colors.border, true: colors.accent }} thumbColor="#fff" /> Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte. Token du bot Obtenu via @BotFather avec la commande /newbot setSettings((p) => ({ ...p, telegram_bot_token: v }))} placeholder="123456:ABCdefGHI..." placeholderTextColor={colors.textMuted} autoCapitalize="none" secureTextEntry={true} /> Username du bot Sans le @, ex : MonBotNotifications setSettings((p) => ({ ...p, telegram_bot_username: v }))} placeholder="MonBotNotifications" placeholderTextColor={colors.textMuted} autoCapitalize="none" /> {settings.telegram_bot_token !== "" && settings.telegram_bot_username !== "" && ( Bot configuré — @{settings.telegram_bot_username} )} Contact SAV Telegram Username du compte Telegram SAV (sans le @). Utilisé pour les boutons de contact client. @ setSettings((p) => ({ ...p, contact_telegram: v }))} placeholder="MonSAV" placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} /> {settings.contact_telegram !== "" && ( @{settings.contact_telegram} )} {/* ============================================ */} {/* 🚚 MODE DE LIVRAISON */} {/* ============================================ */} Choisissez comment les commandes sont assignées aux livreurs. {/* Sélection du mode */} {(["single", "category_based"] as const).map((mode) => { const selected = settings.delivery_mode.mode === mode; const label = mode === "single" ? "Livreur unique" : "Par catégorie"; const desc = mode === "single" ? "Toutes les commandes → même livreur" : "Chaque livreur gère ses catégories"; return ( setSettings((p) => ({ ...p, delivery_mode: { ...p.delivery_mode, mode }, }))} style={{ flex: 1, padding: spacing.m, borderRadius: borderRadius.sm, borderWidth: 2, borderColor: selected ? "#10b981" : colors.border, backgroundColor: selected ? "#10b98118" : colors.bgSecondary, }} > {label} {desc} ); })} {/* Configuration des routes par catégorie */} {settings.delivery_mode.mode === "category_based" && ( Assignation livreur → catégories {settings.delivery_mode.category_routes.map((route, idx) => ( {/* Sélection livreur */} Livreur {livreurs.length === 0 ? ( Aucun livreur actif ) : livreurs.map((username) => { const sel = route.deliveryman_username === username; return ( setSettings((p) => { const routes = [...p.delivery_mode.category_routes]; routes[idx] = { ...routes[idx], deliveryman_username: username }; return { ...p, delivery_mode: { ...p.delivery_mode, category_routes: routes } }; })} style={{ paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.full, borderWidth: 1.5, borderColor: sel ? "#10b981" : colors.border, backgroundColor: sel ? "#10b98122" : "transparent" }} > {username} ); })} {/* Sélection catégories */} Catégories assignées {categories.map((cat) => { const sel = route.categories.includes(cat.name); return ( setSettings((p) => { const routes = [...p.delivery_mode.category_routes]; const cats = routes[idx].categories.includes(cat.name) ? routes[idx].categories.filter((c) => c !== cat.name) : [...routes[idx].categories, cat.name]; routes[idx] = { ...routes[idx], categories: cats }; return { ...p, delivery_mode: { ...p.delivery_mode, category_routes: routes } }; })} style={{ paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.full, borderWidth: 1.5, borderColor: sel ? "#3b82f6" : colors.border, backgroundColor: sel ? "#3b82f622" : "transparent" }} > {cat.name} ); })} {/* Supprimer route */} setSettings((p) => { const routes = p.delivery_mode.category_routes.filter((_, i) => i !== idx); return { ...p, delivery_mode: { ...p.delivery_mode, category_routes: routes } }; })} style={{ marginTop: spacing.m, flexDirection: "row", alignItems: "center", gap: spacing.xs }} > Supprimer cette règle ))} setSettings((p) => ({ ...p, delivery_mode: { ...p.delivery_mode, category_routes: [...p.delivery_mode.category_routes, { deliveryman_username: "", categories: [] }], }, }))} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, paddingVertical: spacing.m }} > Ajouter une règle )} {/* ============================================ */} {/* 🎨 COULEURS DE L'INTERFACE */} {/* ============================================ */} Personnalisez les couleurs de l'espace admin et de l'app client / site web. showConfirm( "Restaurer le thème d'origine", "Toutes les couleurs personnalisées seront remplacées par le violet de base des premières versions de l'application. Continuer ?", () => { Keyboard.dismiss(); setSettings((p) => ({ ...p, ...ORIGINAL_THEME_COLORS })); }, "Restaurer", "Annuler", )} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginHorizontal: spacing.l, marginTop: spacing.m, paddingVertical: spacing.s, paddingHorizontal: spacing.m, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: "#7c3aed", alignSelf: "flex-start", }} > Restaurer les couleurs de base {(["admin", "client"] as const).map((scope) => ( {scope === "admin" ? "Espace admin" : "App client & site web"} {([ { label: "Principale", key: `${scope}_color_primary` as const }, { label: "Secondaire", key: `${scope}_color_secondary` as const }, { label: "Succès", key: `${scope}_color_success` as const }, { label: "Danger", key: `${scope}_color_danger` as const }, { label: "Avertissement", key: `${scope}_color_warning` as const }, ] as { label: string; key: keyof typeof settings }[]).map(({ label, key }) => ( {label} setSettings((p) => ({ ...p, [key]: v }))} placeholder="#7c3aed" placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} /> {["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#f59e0b", "#4ade80", "#ef4444", "#22d3ee", "#0f172a"].map((color) => ( setSettings((p) => ({ ...p, [key]: color }))} style={{ width: 26, height: 26, borderRadius: 13, backgroundColor: color, borderWidth: (settings[key] as string) === color ? 2.5 : 1, borderColor: (settings[key] as string) === color ? colors.textPrimary : colors.border, }} /> ))} ))} ))} {saving ? ( ) : ( <> Sauvegarder )} ); }