Files
projet_gestion_commande/frontend-admin/src/screens/admin/SettingsScreen.tsx
T
2026-06-12 13:33:07 +02:00

2064 lines
106 KiB
TypeScript

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<DaySchedule>) => {
onChange({ ...schedule, [key]: { ...schedule[key], ...patch } });
};
return (
<View style={s.section}>
<Text style={s.sectionTitle}>Horaires de livraison</Text>
<Text style={[s.hint, { paddingTop: spacing.s }]}>
Définissez les jours et horaires d'ouverture pour la livraison.
</Text>
{DAYS.map(({ key, label }, index) => {
const day = schedule[key];
return (
<View
key={key}
style={[
{
paddingHorizontal: spacing.l,
paddingVertical: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.border,
},
index === 0 && { borderTopWidth: 0 },
]}
>
{/* Ligne : jour + toggle */}
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: day.enabled ? spacing.s : 0 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<View style={{
width: 8, height: 8, borderRadius: 4,
backgroundColor: day.enabled ? "#10b981" : colors.border,
}} />
<Text style={[s.rowLabel]}>{label}</Text>
</View>
<Switch
value={day.enabled}
onValueChange={(v) => updateDay(key, { enabled: v })}
trackColor={{ false: colors.border, true: "#10b981" }}
thumbColor="#fff"
/>
</View>
{/* Horaires */}
{day.enabled && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.xs }}>
<Text style={s.thresholdSep}>Ouverture</Text>
<TextInput
style={[s.thresholdInput, { width: screenWidth < 380 ? 58 : 72 }]}
value={day.open_time}
onChangeText={(v) => updateDay(key, { open_time: v })}
placeholder="09:00"
placeholderTextColor={colors.textMuted}
keyboardType="numbers-and-punctuation"
maxLength={5}
/>
<Text style={s.thresholdSep}>→</Text>
<Text style={s.thresholdSep}>Fermeture</Text>
<TextInput
style={[s.thresholdInput, { width: screenWidth < 380 ? 58 : 72 }]}
value={day.close_time}
onChangeText={(v) => updateDay(key, { close_time: v })}
placeholder="20:00"
placeholderTextColor={colors.textMuted}
keyboardType="numbers-and-punctuation"
maxLength={5}
/>
</View>
)}
{!day.enabled && (
<Text style={[s.hint, { paddingHorizontal: 0, paddingBottom: 0, marginTop: spacing.xs }]}>
Fermé ce jour
</Text>
)}
</View>
);
})}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// 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<number | null>(null);
const [creating, setCreating] = React.useState(false);
const [form, setForm] = React.useState<NewZoneForm>(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<PostalZone>) => {
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 (
<View style={s.section}>
<Text style={s.sectionTitle}>Zones de livraison</Text>
<Text style={[s.hint, { paddingTop: spacing.s }]}>
Définissez les zones de livraison et leur montant minimum de commande.
</Text>
{zones.map((zone, i) => {
const expanded = expandedIndex === i;
return (
<View key={i} style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
{/* En-tête zone */}
<TouchableOpacity
onPress={() => setExpandedIndex(expanded ? null : i)}
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.l,
paddingVertical: spacing.m,
gap: spacing.s,
}}
activeOpacity={0.7}
>
<Ionicons
name={expanded ? "chevron-down" : "chevron-forward"}
size={16}
color={colors.textMuted}
/>
<Text style={[s.rowLabel, { flex: 1 }]} numberOfLines={1}>
{zone.name || "Zone sans nom"}
</Text>
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "700" }}>
{zone.min_amount > 0 ? `${zone.min_amount}€ min` : "Sans min"}
</Text>
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, marginLeft: spacing.xs }}>
{zone.codes.length} codes
</Text>
<TouchableOpacity onPress={() => removeZone(i)} style={{ marginLeft: spacing.s }}>
<Ionicons name="trash-outline" size={18} color={colors.danger} />
</TouchableOpacity>
</TouchableOpacity>
{/* Détail zone (édition) */}
{expanded && (
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.m }}>
{/* Nom */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<Text style={[s.thresholdSep, { width: screenWidth < 380 ? 38 : 48 }]}>Nom</Text>
<TextInput
style={[s.thresholdInput, { flex: 1, width: undefined, textAlign: "left", paddingHorizontal: spacing.m }]}
value={zone.name}
onChangeText={(v) => updateZone(i, { name: v })}
placeholder="Ex: Zone centre-ville"
placeholderTextColor={colors.textMuted}
/>
</View>
{/* Minimum */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<Text style={[s.thresholdSep, { width: screenWidth < 380 ? 38 : 48 }]}>Min €</Text>
<TextInput
style={[s.thresholdInput, { width: inputMd }]}
value={String(zone.min_amount)}
onChangeText={(v) => {
const n = parseFloat(v);
if (!isNaN(n)) updateZone(i, { min_amount: n });
}}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.textMuted}
/>
</View>
{/* Codes postaux — chips supprimables */}
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>
Codes postaux ({zone.codes.length}) — appuyer pour supprimer :
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{zone.codes.map((code, ci) => (
<TouchableOpacity
key={ci}
onPress={() => removeCode(i, ci)}
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.accent + "20",
borderRadius: borderRadius.sm,
paddingHorizontal: spacing.s,
paddingVertical: 4,
gap: 4,
}}
>
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
{code}
</Text>
<Ionicons name="close" size={12} color={colors.accent} />
</TouchableOpacity>
))}
</View>
{/* Ajout en masse */}
<BulkCodesInput
onAdd={(newCodes) => {
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}
/>
</View>
)}
</View>
);
})}
{zones.length === 0 && !creating && (
<Text style={[s.hint, { paddingTop: 0 }]}>Aucune zone définie — toutes les commandes seront acceptées sans minimum.</Text>
)}
{/* Formulaire de création */}
{creating && (
<View style={{ paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, gap: spacing.m }}>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Nouvelle zone</Text>
{/* Nom */}
<View style={{ gap: 4 }}>
<Text style={s.thresholdSep}>Nom de la zone *</Text>
<TextInput
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m }]}
value={form.name}
onChangeText={(v) => { setForm((f) => ({ ...f, name: v })); setFormError(""); }}
placeholder="Ex: Zone centre-ville"
placeholderTextColor={colors.textMuted}
autoFocus
/>
</View>
{/* Minimum */}
<View style={{ gap: 4 }}>
<Text style={s.thresholdSep}>Montant minimum de commande (€) *</Text>
<TextInput
style={[s.thresholdInput, { width: inputLg, textAlign: "left", paddingHorizontal: spacing.m }]}
value={form.min_amount}
onChangeText={(v) => { setForm((f) => ({ ...f, min_amount: v })); setFormError(""); }}
keyboardType="decimal-pad"
placeholder="Ex: 30"
placeholderTextColor={colors.textMuted}
/>
</View>
{/* Codes postaux */}
<View style={{ gap: 4 }}>
<Text style={s.thresholdSep}>Codes postaux * (séparés par virgules ou espaces)</Text>
<TextInput
style={[
s.thresholdInput,
{
width: "100%",
textAlign: "left",
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
height: 80,
textAlignVertical: "top",
},
]}
value={form.codesRaw}
onChangeText={(v) => { setForm((f) => ({ ...f, codesRaw: v })); setFormError(""); }}
placeholder={"44000, 44100, 44200\n44300 44400"}
placeholderTextColor={colors.textMuted}
multiline
keyboardType="numbers-and-punctuation"
/>
{form.codesRaw.length > 0 && (
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, fontStyle: "italic" }}>
{parsePostalCodes(form.codesRaw).length} code(s) valide(s) détecté(s)
</Text>
)}
</View>
{formError !== "" && (
<Text style={{ fontSize: fontSize.sm, color: colors.danger, fontStyle: "italic" }}>
{formError}
</Text>
)}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={confirmCreate}
style={{
flex: 1,
backgroundColor: colors.accent,
borderRadius: borderRadius.sm,
paddingVertical: spacing.s,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: spacing.xs,
}}
>
<Ionicons name="checkmark" size={16} color="#fff" />
<Text style={{ color: "#fff", fontWeight: "700", fontSize: fontSize.sm }}>Créer la zone</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => { 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,
}}
>
<Text style={{ color: colors.textMuted, fontWeight: "600", fontSize: fontSize.sm }}>Annuler</Text>
</TouchableOpacity>
</View>
</View>
)}
{!creating && (
<TouchableOpacity
onPress={() => { 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,
}}
>
<Ionicons name="add-circle-outline" size={18} color={colors.accent} />
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
Ajouter une zone
</Text>
</TouchableOpacity>
)}
</View>
);
}
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 (
<View style={{ gap: spacing.xs }}>
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>Ajouter des codes (virgules ou espaces) :</Text>
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: spacing.s }}>
<TextInput
style={[
s.thresholdInput,
{
flex: 1,
width: undefined,
textAlign: "left",
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
height: 60,
textAlignVertical: "top",
},
]}
value={value}
onChangeText={setValue}
placeholder={"44500, 44600 44700"}
placeholderTextColor={colors.textMuted}
multiline
keyboardType="numbers-and-punctuation"
/>
<TouchableOpacity
onPress={handleAdd}
style={{
backgroundColor: parsed.length > 0 ? colors.accent : colors.border,
borderRadius: borderRadius.sm,
padding: spacing.s,
alignItems: "center",
justifyContent: "center",
minWidth: 48,
height: 60,
}}
disabled={parsed.length === 0}
>
<Ionicons name="add" size={18} color="#fff" />
{parsed.length > 0 && (
<Text style={{ color: "#fff", fontSize: 10, fontWeight: "700" }}>{parsed.length}</Text>
)}
</TouchableOpacity>
</View>
{value.length > 0 && parsed.length > 0 && (
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, fontStyle: "italic" }}>
{parsed.length} code(s) valide(s) : {parsed.join(", ")}
</Text>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// 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 (
<View style={s.section}>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
<Text style={s.sectionTitle}>{title}</Text>
<View style={{
paddingHorizontal: spacing.s,
paddingVertical: 2,
borderRadius: 10,
backgroundColor: active ? accentColor + "25" : colors.border + "40",
borderWidth: 1,
borderColor: active ? accentColor : colors.border,
marginBottom: spacing.s,
}}>
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
{active ? "Actif" : "Ignoré"}
</Text>
</View>
</View>
<Text style={s.hint}>
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
Max = 0 signifie illimité (pas de borne supérieure).
</Text>
{/* En-tête colonnes */}
<View style={{ flexDirection: "row", paddingHorizontal: spacing.l, paddingBottom: spacing.xs, gap: spacing.xs }}>
<Text style={[s.thresholdSep, { width: inputSm, textAlign: "center" }]}>Min €</Text>
<Text style={[s.thresholdSep, { width: inputSm, textAlign: "center" }]}>Max €</Text>
<Text style={[s.thresholdSep, { width: screenWidth < 380 ? 36 : 44, textAlign: "center" }]}>Pts</Text>
</View>
{tiers.map((tier, i) => (
<View
key={i}
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.l,
paddingVertical: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: spacing.xs,
}}
>
<TextInput
style={[s.thresholdInput, { width: inputSm }]}
keyboardType="decimal-pad"
value={String(tier.min)}
onChangeText={(v) => updateTier(i, "min", v)}
/>
<Text style={s.thresholdSep}>→</Text>
<TextInput
style={[s.thresholdInput, { width: inputSm }]}
keyboardType="decimal-pad"
value={String(tier.max)}
onChangeText={(v) => updateTier(i, "max", v)}
placeholder="0=∞"
placeholderTextColor={colors.textMuted}
/>
<Text style={s.thresholdSep}>=</Text>
<TextInput
style={[s.thresholdInput, { width: screenWidth < 380 ? 36 : 44 }]}
keyboardType="number-pad"
value={String(tier.points)}
onChangeText={(v) => updateTier(i, "points", v)}
/>
<Text style={[s.thresholdSep, { marginRight: spacing.xs }]}>pts</Text>
<TouchableOpacity onPress={() => removeTier(i)}>
<Ionicons name="trash-outline" size={18} color={colors.danger} />
</TouchableOpacity>
</View>
))}
{tiers.length === 0 && (
<Text style={[s.hint, { paddingTop: 0 }]}>Aucun palier défini</Text>
)}
<TouchableOpacity
onPress={addTier}
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.l,
paddingVertical: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.border,
}}
>
<Ionicons name="add-circle-outline" size={18} color={accentColor} />
<Text style={{ fontSize: fontSize.sm, color: accentColor, fontWeight: "600" }}>
Ajouter un palier
</Text>
</TouchableOpacity>
</View>
);
}
const REWARD_ACCENT = "#f59e0b";
const REWARD_TYPES: { value: PointsReward["type"]; label: string; icon: string }[] = [
{ value: "free_product", label: "Produit offert", icon: "gift-outline" },
{ value: "half_price_product", label: "Produit à -50%", icon: "pricetag-outline" },
{ value: "custom", label: "Personnalisé", icon: "star-outline" },
];
const EMPTY_REWARD: PointsReward = {
threshold: 20,
type: "free_product",
description: "",
category_configs: [],
};
// ──────────────────────────────────────────────────────────────
// Sélecteur de produits pour une catégorie dans la récompense
// ──────────────────────────────────────────────────────────────
function CategoryProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: RewardCategoryConfig;
products: Product[];
onChange: (cfg: RewardCategoryConfig) => void;
colors: any;
s: any;
}) {
const catProducts = products.filter((p) => p.category === catConfig.category);
const toggleProduct = (id: number) => {
const ids = catConfig.product_ids.includes(id)
? catConfig.product_ids.filter((x) => x !== id)
: [...catConfig.product_ids, id];
onChange({ ...catConfig, product_ids: ids, all_products: false });
};
return (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
{/* Montant pour cette catégorie */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<Text style={{ fontSize: 12, color: s.thresholdSep.color ?? "#888", flex: 1 }}>
Valeur du produit offert
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={catConfig.amount > 0 ? String(catConfig.amount) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
onChange({ ...catConfig, amount: isNaN(n) ? 0 : n });
}}
placeholder="0"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}>€</Text>
</View>
</View>
{/* Toggle tous / sélection */}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: true, product_ids: [] })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: catConfig.all_products ? REWARD_ACCENT : colors.border,
backgroundColor: catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
Tous ({catProducts.length})
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: false })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: !catConfig.all_products ? REWARD_ACCENT : colors.border,
backgroundColor: !catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
Sélection
</Text>
</TouchableOpacity>
</View>
{/* Liste des produits si mode sélection */}
{!catConfig.all_products && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : catProducts.map((p) => {
const sel = catConfig.product_ids.includes(p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// Section centralisée récompenses par palier
// ──────────────────────────────────────────────────────────────
function CentralRewardSection({
reward,
allCategories,
productsByCategory,
onChange,
colors,
s,
}: {
reward: PointsReward | null | undefined;
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onChange: (reward: PointsReward | null) => void;
colors: any;
s: any;
}) {
const { width: screenWidth } = useWindowDimensions();
const enabled = !!reward;
const rawR = reward ?? EMPTY_REWARD;
const r: PointsReward = { ...rawR, category_configs: rawR.category_configs ?? [] };
const update = (patch: Partial<PointsReward>) =>
onChange({ ...r, ...patch });
const getCatConfig = (catName: string): RewardCategoryConfig =>
r.category_configs.find((c) => c.category === catName) ??
{ category: catName, all_products: true, product_ids: [], amount: 0 };
const isCatSelected = (catName: string) =>
r.category_configs.some((c) => c.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
update({ category_configs: r.category_configs.filter((c) => c.category !== catName) });
} else {
update({ category_configs: [...r.category_configs, { category: catName, all_products: true, product_ids: [], amount: 0 }] });
}
};
const updateCatConfig = (cfg: RewardCategoryConfig) => {
update({
category_configs: r.category_configs.map((c) =>
c.category === cfg.category ? cfg : c
),
});
};
return (
<View style={s.section}>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
<Text style={s.sectionTitle}>Récompenses par palier</Text>
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? REWARD_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? REWARD_ACCENT : colors.border,
marginBottom: spacing.s,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
{enabled ? "Activée" : "Désactivée"}
</Text>
</View>
</View>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Récompense activée</Text>
<Text style={s.rowDesc}>
Le seuil s'applique indépendamment à chaque type de points.
</Text>
</View>
<Switch
value={enabled}
onValueChange={(v) => onChange(v ? EMPTY_REWARD : null)}
trackColor={{ false: colors.border, true: REWARD_ACCENT }}
thumbColor="#fff"
/>
</View>
{enabled && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
{/* Seuil */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{ flex: 1 }}>
<Text style={s.rowLabel}>Seuil de points</Text>
<Text style={s.rowDesc}>
Dès X points cumulés dans un type, la récompense est débloquée pour ce type.
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<TextInput
style={[s.thresholdInput, { width: screenWidth < 380 ? 52 : 64 }]}
keyboardType="number-pad"
value={String(r.threshold)}
onChangeText={(v) => { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) update({ threshold: n }); }}
/>
<Text style={s.thresholdSep}>pts</Text>
</View>
</View>
{/* Type */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Type de récompense</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
{REWARD_TYPES.map((rt) => {
const sel = r.type === rt.value;
return (
<TouchableOpacity
key={rt.value}
onPress={() => update({ type: rt.value })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "20" : "transparent",
}}
>
<Ionicons name={rt.icon as any} size={14} color={sel ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 13, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{rt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* Description */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Description affichée au client</Text>
<TextInput
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m, paddingVertical: spacing.s, height: 72, textAlignVertical: "top" }]}
value={r.description}
onChangeText={(v) => update({ description: v })}
placeholder="Ex : Un produit 30€ de ton choix parmi les produits conditionnés en 30€"
placeholderTextColor={colors.textMuted}
multiline
/>
</View>
{/* Catégories éligibles */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
) : (
<View style={{ gap: spacing.m }}>
{allCategories.map((cat) => {
const selected = isCatSelected(cat.name);
const catColor = cat.color || REWARD_ACCENT;
return (
<View key={cat.name}>
{/* Chip catégorie */}
<TouchableOpacity
onPress={() => toggleCategory(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
alignSelf: "flex-start",
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: selected ? catColor : colors.border,
backgroundColor: selected ? catColor + "22" : "transparent",
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
{cat.name}
</Text>
<Ionicons
name={selected ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{/* Sélecteur produits (visible si catégorie sélectionnée) */}
{selected && (
<CategoryProductPicker
catConfig={getCatConfig(cat.name)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
)}
</View>
);
})}
</View>
)}
</View>
{/* Récapitulatif */}
{r.category_configs.length > 0 && (
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type {" "}
{REWARD_TYPES.find((x) => x.value === r.type)?.label}
</Text>
{r.description !== "" && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
)}
{r.category_configs.map((cfg) => (
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
{cfg.category}{cfg.amount > 0 ? ` (${cfg.amount}€)` : ""} : {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s) sélectionné(s)`}
</Text>
))}
</View>
)}
</View>
)}
</View>
);
}
export default function SettingsScreen() {
const { colors } = useTheme();
const { alert, showError, showSuccess, hideAlert } = useAlert();
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<AppSettings>({
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<Category[]>([]);
const [productsByCategory, setProductsByCategory] = useState<Record<string, Product[]>>({});
const [livreurs, setLivreurs] = useState<string[]>([]);
// 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<string, Product[]> = {};
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 (
<View style={[s.container, { alignItems: "center", justifyContent: "center" }]}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
const pools = settings.points_pools ?? [];
return (
<View style={s.container}>
<ScrollView contentContainerStyle={s.content}>
{/* Personnalisation */}
<View style={s.section}>
<Text style={s.sectionTitle}>Personnalisation</Text>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Nom du shop</Text>
<Text style={s.rowDesc}>Affiché dans la sidebar du site client</Text>
</View>
</View>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
<TextInput
style={s.input}
value={settings.shop_name}
onChangeText={(v) => setSettings((p) => ({ ...p, shop_name: v }))}
placeholder="Ex: Milieu-Nantais"
placeholderTextColor={colors.textMuted}
autoCorrect={false}
/>
</View>
</View>
{/* Amendes */}
<View style={s.section}>
<Text style={s.sectionTitle}>Amendes</Text>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Amendes activées</Text>
<Text style={s.rowDesc}>
Bloquer les commandes si le client a une amende non payée
</Text>
</View>
<Switch
value={settings.penalties_enabled}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, penalties_enabled: v }))
}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={s.row}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Afficher le score d'amendes</Text>
<Text style={s.rowDesc}>
Les clients et la cabine voient le nombre d'amendes
</Text>
</View>
<Switch
value={settings.show_amende_score}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, show_amende_score: v }))
}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={[s.row, { flexDirection: "column", alignItems: "flex-start", gap: spacing.s }]}>
<View style={{ flexDirection: "row", justifyContent: "space-between", width: "100%", alignItems: "center" }}>
<Text style={s.rowLabel}>Barème des amendes</Text>
<TouchableOpacity
onPress={() =>
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,
},
],
}))
}
>
<Ionicons name="add-circle-outline" size={22} color={colors.accent} />
</TouchableOpacity>
</View>
<View style={{ flexDirection: "row", width: "100%", marginBottom: 2 }}>
<Text style={[s.rowDesc, { flex: 1 }]}>À partir de (annul.)</Text>
<Text style={[s.rowDesc, { width: inputMd, textAlign: "center" }]}>Montant</Text>
<View style={{ width: 28 }} />
</View>
{(settings.penalty_tiers ?? []).map((tier, i) => (
<View key={i} style={{ flexDirection: "row", alignItems: "center", width: "100%", gap: spacing.s }}>
<TextInput
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
value={String(tier.min_cancel)}
onChangeText={(v) => {
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}
/>
<TextInput
style={[s.thresholdInput, { width: inputMd, textAlign: "center" }]}
value={String(tier.amount)}
onChangeText={(v) => {
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}
/>
<TouchableOpacity
onPress={() =>
setSettings((prev) => ({
...prev,
penalty_tiers: prev.penalty_tiers.filter((_, j) => j !== i),
}))
}
>
<Ionicons name="remove-circle-outline" size={22} color={colors.danger} />
</TouchableOpacity>
</View>
))}
</View>
</View>
{/* Parrainage */}
<View style={s.section}>
<Text style={s.sectionTitle}>Parrainage</Text>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Parrainage activé</Text>
<Text style={s.rowDesc}>
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é.
</Text>
</View>
<Switch
value={settings.referral_enabled}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, referral_enabled: v }))
}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
</View>
{/* Système de points */}
<View style={s.section}>
<Text style={s.sectionTitle}>Système de points</Text>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Points activés</Text>
<Text style={s.rowDesc}>
Les clients voient leurs scores de points.{"\n"}
La cabine peut réinitialiser les points.
</Text>
</View>
<Switch
value={settings.points_enabled}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, points_enabled: v }))
}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
{/* Gestion des types de points */}
<View
pointerEvents={settings.points_enabled ? "auto" : "none"}
style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.m, opacity: settings.points_enabled ? 1 : 0.4 }}
>
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Types de points</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Créez vos propres types de points. Le 1er type colonne principale, le 2e colonne secondaire.
</Text>
{pools.map((pool, i) => {
const color = POOL_COLORS[i % POOL_COLORS.length];
return (
<View key={pool.key} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<View style={[s.colorDot, { backgroundColor: color, width: 12, height: 12, borderRadius: 6 }]} />
<TextInput
style={[s.thresholdInput, { flex: 1 }]}
value={pool.name}
onChangeText={(v) => renamePool(i, v)}
placeholder={`Type ${i + 1}`}
placeholderTextColor={colors.textMuted}
/>
<TouchableOpacity onPress={() => removePool(i)}>
<Ionicons name="trash-outline" size={18} color={colors.danger} />
</TouchableOpacity>
</View>
);
})}
<TouchableOpacity
onPress={addPool}
style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.xs }}
>
<Ionicons name="add-circle-outline" size={18} color={colors.accent} />
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
Ajouter un type
</Text>
</TouchableOpacity>
</View>
</View>
{/* Attribution des catégories */}
<View style={s.section}>
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
<Text style={s.hint}>
Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"}
Vous pouvez modifier les attributions à tout moment.
</Text>
{/* Légende */}
<View style={{ flexDirection: "row", gap: spacing.m, paddingHorizontal: spacing.l, paddingBottom: spacing.m, flexWrap: "wrap" }}>
{pools.map((pool, i) => (
<View key={pool.key} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: POOL_COLORS[i % POOL_COLORS.length] }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>{pool.name}</Text>
</View>
))}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: colors.border }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Aucun</Text>
</View>
</View>
{categories.map((cat, index) => {
const assignedPoolIdx = getPoolIndexFor(cat.name);
return (
<View
key={cat.id}
style={[s.catRow, index === 0 && s.catRowFirst]}
>
<View
style={[
s.colorDot,
{ backgroundColor: cat.color || colors.accent },
]}
/>
<Text style={s.catName}>{cat.name}</Text>
<View style={s.chips}>
{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 (
<TouchableOpacity
key={pool.key}
style={[
s.chip,
{
borderColor: chipColor,
backgroundColor: active ? chipColor : "transparent",
},
]}
onPress={() => setCategoryPool(cat.name, active ? -1 : pi)}
>
<Text style={[s.chipText, { color: active ? "#fff" : chipColor }]}>
{label}
</Text>
</TouchableOpacity>
);
})}
{/* Chip "Aucun" */}
<TouchableOpacity
style={[
s.chip,
{
borderColor: colors.textMuted,
backgroundColor: assignedPoolIdx === -1 ? colors.textMuted : "transparent",
},
]}
onPress={() => setCategoryPool(cat.name, -1)}
>
<Text style={[s.chipText, { color: assignedPoolIdx === -1 ? "#fff" : colors.textMuted }]}>
</Text>
</TouchableOpacity>
</View>
</View>
);
})}
{categories.length === 0 && (
<Text style={[s.hint, { paddingTop: 0 }]}>
Aucune catégorie disponible
</Text>
)}
{pools.length === 0 && categories.length > 0 && (
<Text style={[s.hint, { paddingTop: 0 }]}>
Ajoutez au moins un type de point pour assigner des catégories.
</Text>
)}
</View>
{/* Barèmes de points — un par pool */}
{pools.map((pool, i) => (
<TiersSection
key={pool.key}
title={`Barème — ${pool.name}`}
tiers={pool.tiers ?? []}
onChange={(tiers) => updatePoolTiers(i, tiers)}
colors={colors}
s={s}
accentColor={POOL_COLORS[i % POOL_COLORS.length]}
active={true}
/>
))}
{/* Récompense centralisée par palier */}
<CentralRewardSection
reward={settings.points_reward ?? null}
allCategories={categories}
productsByCategory={productsByCategory}
onChange={(reward) => setSettings((p) => ({ ...p, points_reward: reward }))}
colors={colors}
s={s}
/>
{/* Horaires de livraison */}
<DeliveryScheduleSection
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
onChange={(sched) => setSettings((p) => ({ ...p, delivery_schedule: sched }))}
colors={colors}
s={s}
/>
{/* Zones de livraison */}
<PostalZonesSection
zones={settings.postal_zones ?? DEFAULT_POSTAL_ZONES}
onChange={(zones) => setSettings((p) => ({ ...p, postal_zones: zones }))}
colors={colors}
s={s}
/>
{/* Paiement crypto */}
<View style={s.section}>
<Text style={s.sectionTitle}>Paiement crypto</Text>
{/* Toggle activation */}
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Paiement crypto activé</Text>
<Text style={s.rowDesc}>
Les clients peuvent payer leurs commandes en cryptomonnaie via NowPayments.
</Text>
</View>
<Switch
value={settings.crypto_payment_enabled}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, crypto_payment_enabled: v }))
}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
{/* Toggle crypto uniquement */}
<View
pointerEvents={settings.crypto_payment_enabled ? "auto" : "none"}
style={{ opacity: settings.crypto_payment_enabled ? 1 : 0.4 }}
>
<View style={[s.row, { borderTopWidth: 1, borderTopColor: colors.border }]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Crypto uniquement</Text>
<Text style={s.rowDesc}>
Désactive le paiement en espèces seule la crypto est acceptée.
</Text>
</View>
<Switch
value={settings.crypto_only}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, crypto_only: v }))
}
trackColor={{ false: colors.border, true: "#f7931a" }}
thumbColor="#fff"
/>
</View>
</View>
{/* Config NowPayments */}
<View
pointerEvents={settings.crypto_payment_enabled ? "auto" : "none"}
style={{ opacity: settings.crypto_payment_enabled ? 1 : 0.4, paddingHorizontal: spacing.l, paddingBottom: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.m, gap: spacing.m }}
>
{/* Clé API */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Clé API NowPayments</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<TextInput
style={[s.input, { flex: 1, fontFamily: "monospace" }]}
value={settings.nowpayments_api_key}
onChangeText={(v) => setSettings((p) => ({ ...p, nowpayments_api_key: v }))}
placeholder="Votre clé API NowPayments"
placeholderTextColor={colors.textSecondary}
secureTextEntry={!showApiKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowApiKey((x) => !x)} style={{ padding: spacing.xs }}>
<Ionicons name={showApiKey ? "eye-off-outline" : "eye-outline"} size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
{/* Secret IPN */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Secret IPN</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<TextInput
style={[s.input, { flex: 1, fontFamily: "monospace" }]}
value={settings.nowpayments_ipn_secret}
onChangeText={(v) => 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}
/>
<TouchableOpacity onPress={() => setShowIpnSecret((x) => !x)} style={{ padding: spacing.xs }}>
<Ionicons name={showIpnSecret ? "eye-off-outline" : "eye-outline"} size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<Text style={[s.rowDesc, { marginTop: spacing.xs }]}>
URL IPN à configurer sur NowPayments :{"\n"}
<Text style={{ color: colors.accent, fontSize: fontSize.xs }}>
https://5.181.0.112.nip.io/api/v1/webhooks/nowpayments
</Text>
</Text>
</View>
{/* Cryptos acceptées */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Cryptomonnaies acceptées</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Sélectionnez les cryptos que vos clients peuvent utiliser pour payer.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
{CRYPTO_CURRENCIES.map((coin) => {
const selected = (settings.nowpayments_currencies ?? []).includes(coin.id);
return (
<TouchableOpacity
key={coin.id}
onPress={() => {
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,
}}
>
<Text style={{ fontSize: 16 }}>{coin.icon}</Text>
<Text style={{ fontSize: fontSize.sm, fontWeight: "600", color: selected ? coin.color : colors.textSecondary }}>
{coin.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{(settings.nowpayments_currencies ?? []).length === 0 && (
<Text style={[s.rowDesc, { color: "#ef4444", marginTop: spacing.s }]}>
Sélectionnez au moins une cryptomonnaie.
</Text>
)}
</View>
</View>
</View>
{/* ============================================ */}
{/* 🤖 TELEGRAM */}
{/* ============================================ */}
<View style={s.section}>
<Text style={s.sectionTitle}>Notifications Telegram</Text>
<View style={s.row}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Notifications activées</Text>
<Text style={s.rowDesc}>Envoyer les notifications via Telegram aux utilisateurs qui ont lié leur compte</Text>
</View>
<Switch
value={settings.telegram_notifications_enabled}
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_notifications_enabled: v }))}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={s.row}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Authentification 2FA</Text>
<Text style={s.rowDesc}>Permettre aux clients d'activer la double authentification via Telegram lors de la connexion</Text>
</View>
<Switch
value={settings.telegram_2fa_enabled}
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
<Text style={s.rowDesc}>
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
</Text>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Token du bot</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Obtenu via @BotFather avec la commande /newbot
</Text>
<TextInput
style={[s.input, { fontFamily: "monospace" }]}
value={settings.telegram_bot_token}
onChangeText={(v) => setSettings((p) => ({ ...p, telegram_bot_token: v }))}
placeholder="123456:ABCdefGHI..."
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
secureTextEntry={true}
/>
</View>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Username du bot</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Sans le @, ex : MonBotNotifications
</Text>
<TextInput
style={s.input}
value={settings.telegram_bot_username}
onChangeText={(v) => setSettings((p) => ({ ...p, telegram_bot_username: v }))}
placeholder="MonBotNotifications"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
</View>
{settings.telegram_bot_token !== "" && settings.telegram_bot_username !== "" && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.m, backgroundColor: "#10b98122", borderRadius: borderRadius.sm }}>
<Ionicons name="checkmark-circle" size={16} color="#10b981" />
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré @{settings.telegram_bot_username}</Text>
</View>
)}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Contact SAV Telegram</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Username du compte Telegram SAV (sans le @). Utilisé pour les boutons de contact client.
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<Text style={{ fontSize: fontSize.md, color: colors.textMuted }}>@</Text>
<TextInput
style={[s.input, { flex: 1 }]}
value={settings.contact_telegram}
onChangeText={(v) => setSettings((p) => ({ ...p, contact_telegram: v }))}
placeholder="MonSAV"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{settings.contact_telegram !== "" && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.s, padding: spacing.m, backgroundColor: "#0088cc22", borderRadius: borderRadius.sm }}>
<Ionicons name="paper-plane-outline" size={16} color="#0088cc" />
<Text style={{ color: "#0088cc", fontSize: fontSize.sm }}>@{settings.contact_telegram}</Text>
</View>
)}
</View>
</View>
</View>
{/* ============================================ */}
{/* 🚚 MODE DE LIVRAISON */}
{/* ============================================ */}
<View style={s.section}>
<Text style={s.sectionTitle}>Mode de livraison</Text>
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
Choisissez comment les commandes sont assignées aux livreurs.
</Text>
{/* Sélection du mode */}
<View style={{ flexDirection: "row", gap: spacing.m, paddingHorizontal: spacing.l, paddingTop: spacing.m, paddingBottom: spacing.s }}>
{(["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 (
<TouchableOpacity
key={mode}
onPress={() => 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,
}}
>
<Text style={{ fontSize: fontSize.sm, fontWeight: "700", color: selected ? "#10b981" : colors.textPrimary }}>{label}</Text>
<Text style={{ fontSize: fontSize.xs, color: colors.textSecondary, marginTop: 2 }}>{desc}</Text>
</TouchableOpacity>
);
})}
</View>
{/* Configuration des routes par catégorie */}
{settings.delivery_mode.mode === "category_based" && (
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
<Text style={[s.rowLabel, { marginBottom: spacing.s, marginTop: spacing.m }]}>
Assignation livreur catégories
</Text>
{settings.delivery_mode.category_routes.map((route, idx) => (
<View key={idx} style={{ backgroundColor: colors.bgSecondary, borderRadius: borderRadius.sm, padding: spacing.m, marginBottom: spacing.m, borderWidth: 1, borderColor: colors.border }}>
{/* Sélection livreur */}
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>Livreur</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs, marginBottom: spacing.m }}>
{livreurs.length === 0 ? (
<Text style={[s.rowDesc, { fontStyle: "italic" }]}>Aucun livreur actif</Text>
) : livreurs.map((username) => {
const sel = route.deliveryman_username === username;
return (
<TouchableOpacity
key={username}
onPress={() => 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" }}
>
<Text style={{ fontSize: fontSize.sm, color: sel ? "#10b981" : colors.textPrimary, fontWeight: sel ? "600" : "400" }}>{username}</Text>
</TouchableOpacity>
);
})}
</View>
{/* Sélection catégories */}
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>Catégories assignées</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{categories.map((cat) => {
const sel = route.categories.includes(cat.name);
return (
<TouchableOpacity
key={cat.name}
onPress={() => 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" }}
>
<Text style={{ fontSize: fontSize.sm, color: sel ? "#3b82f6" : colors.textPrimary, fontWeight: sel ? "600" : "400" }}>{cat.name}</Text>
</TouchableOpacity>
);
})}
</View>
{/* Supprimer route */}
<TouchableOpacity
onPress={() => 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 }}
>
<Ionicons name="trash-outline" size={14} color="#ef4444" />
<Text style={{ fontSize: fontSize.sm, color: "#ef4444" }}>Supprimer cette règle</Text>
</TouchableOpacity>
</View>
))}
<TouchableOpacity
onPress={() => 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 }}
>
<Ionicons name="add-circle-outline" size={18} color="#10b981" />
<Text style={{ fontSize: fontSize.sm, color: "#10b981", fontWeight: "600" }}>Ajouter une règle</Text>
</TouchableOpacity>
</View>
)}
</View>
<TouchableOpacity
style={s.saveButton}
onPress={handleSave}
disabled={saving}
>
{saving ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<Ionicons name="save-outline" size={18} color="#fff" />
<Text style={s.saveButtonText}>Sauvegarder</Text>
</>
)}
</TouchableOpacity>
</ScrollView>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}