import React, { useState, useEffect, useCallback, useRef } from "react"; 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" }, ]; 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 ( 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 { 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 ( 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 { 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)); }; 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 ); } 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 ( {/* Montant pour cette catégorie */} Valeur du produit offert 0 ? String(catConfig.amount) : ""} onChangeText={(v) => { const n = parseFloat(v); onChange({ ...catConfig, amount: isNaN(n) ? 0 : n }); }} placeholder="0" placeholderTextColor={colors.textMuted} /> {/* Toggle tous / sélection */} 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", }} > 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 {/* Liste des produits si mode sélection */} {!catConfig.all_products && ( {catProducts.length === 0 ? ( Aucun produit dans cette catégorie ) : catProducts.map((p) => { const sel = catConfig.product_ids.includes(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} ); })} )} ); } // ────────────────────────────────────────────────────────────── // 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 }); 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 ( Récompenses par palier {enabled ? "Activée" : "Désactivée"} 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 {/* Type */} Type de récompense {REWARD_TYPES.map((rt) => { const sel = r.type === rt.value; return ( 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", }} > {rt.label} ); })} {/* 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 */} Catégories éligibles Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection. {allCategories.length === 0 ? ( Aucune catégorie disponible ) : ( {allCategories.map((cat) => { const selected = isCatSelected(cat.name); const catColor = cat.color || REWARD_ACCENT; return ( {/* Chip catégorie */} 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", }} > {cat.name} {/* Sélecteur produits (visible si catégorie sélectionnée) */} {selected && ( )} ); })} )} {/* Récapitulatif */} {r.category_configs.length > 0 && ( Récapitulatif Dès {r.threshold} pts par type →{" "} {REWARD_TYPES.find((x) => x.value === r.type)?.label} {r.description !== "" && ( "{r.description}" )} {r.category_configs.map((cfg) => ( • {cfg.category}{cfg.amount > 0 ? ` (${cfg.amount}€)` : ""} : {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s) sélectionné(s)`} ))} )} )} ); } export default function SettingsScreen() { const { colors } = useTheme(); const { alert, showError, showSuccess, 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, }); 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 ?? [] } : 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); } }, []); 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: 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 */} Personnalisation 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} /> {/* 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" /> 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 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} /> ))} {/* 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 */} 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 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 */} {/* ============================================ */} 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 )} {saving ? ( ) : ( <> Sauvegarder )} ); }