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
>
)}
);
}