import React, { useState, useEffect, useCallback, useRef } from "react"; import { View, Text, StyleSheet, ScrollView, Switch, TouchableOpacity, ActivityIndicator, TextInput, Keyboard, } 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, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin"; import type { AppSettings, Category, PointsTier, PointsPool, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin"; 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" }, ]; function DeliveryScheduleSection({ schedule, onChange, colors, s, }: { schedule: DeliverySchedule; onChange: (sched: DeliverySchedule) => void; colors: any; s: any; }) { const updateDay = (key: keyof DeliverySchedule, patch: Partial) => { onChange({ ...schedule, [key]: { ...schedule[key], ...patch } }); }; return ( Horaires de livraison 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 [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 ( Zones de livraison 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 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)); }; return ( {title} {active ? "Actif" : "Ignoré"} 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 ); } export default function SettingsScreen() { const { colors } = useTheme(); const { alert, showError, showSuccess, hideAlert } = useAlert(); const navigation = useNavigation(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({ penalties_enabled: true, show_amende_score: true, 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, nowpayments_api_key: "", nowpayments_ipn_secret: "", nowpayments_currencies: [], }); const [showApiKey, setShowApiKey] = useState(false); const [showIpnSecret, setShowIpnSecret] = useState(false); const [categories, setCategories] = 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; const [settingsRes, categoriesRes] = await Promise.all([ getSettings(), getCategories(), ]); if (settingsRes.success && settingsRes.settings) { setSettings({ ...settingsRes.settings, points_pools: settingsRes.settings.points_pools ?? [], delivery_schedule: settingsRes.settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE, postal_zones: settingsRes.settings.postal_zones ?? DEFAULT_POSTAL_ZONES, }); } if (categoriesRes) { setCategories(categoriesRes.filter((c) => !c.is_coming_soon)); } setLoading(false); // Marquer comme chargé après un tick pour que le useEffect de settings ne // considère pas le setSettings initial comme une modification utilisateur setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0); }, []); useEffect(() => { 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; 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: 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 ( {/* Amendes */} 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" /> {/* Parrainage */} 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 */} 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 */} Attribution des catégories aux points 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} /> ))} {/* 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 */} 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" /> {/* 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. )} {saving ? ( ) : ( <> Sauvegarder )} ); }