Files
projet_gestion_commande/frontend-admin/src/screens/admin/SettingsScreen.tsx
T
Xor290 6fca16a58c
Frontend Admin - EAS Build / build (push) Successful in 1h21m50s
Frontend Client - EAS Build / build (push) Successful in 1h17m38s
chore: build
2026-08-30 15:23:59 +02:00

2375 lines
126 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<View style={s.section}>
<TouchableOpacity
onPress={() => setOpen((o) => !o)}
activeOpacity={0.7}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.l,
paddingVertical: spacing.m,
}}
>
<Text style={[s.sectionTitle, { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 }]}>{title}</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
{badge}
<Ionicons name={open ? "chevron-up" : "chevron-down"} size={18} color={colors.textMuted} />
</View>
</TouchableOpacity>
{open && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
{children}
</View>
)}
</View>
);
}
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 (
<AccordionSection title="Horaires de livraison" colors={colors} s={s}>
<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>
);
})}
</AccordionSection>
);
}
// ──────────────────────────────────────────────────────────────
// 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 (
<AccordionSection title="Zones de livraison" colors={colors} s={s}>
<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>
)}
</AccordionSection>
);
}
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));
};
const badge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: active ? accentColor + "25" : colors.border + "40",
borderWidth: 1, borderColor: active ? accentColor : colors.border,
}}>
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
{active ? "Actif" : "Ignoré"}
</Text>
</View>
);
return (
<AccordionSection title={title} badge={badge} colors={colors} s={s}>
<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>
</AccordionSection>
);
}
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 (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
{/* Toggle tous / sélection */}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={() => 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",
}}
>
<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>
{/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */}
{catConfig.all_products && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Quantité :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={catConfig.quantity > 0 ? String(catConfig.quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
doit correspondre à un palier de prix existant
</Text>
</View>
)}
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité
(ex: produit A à 2g offerts, produit B à 1g offert) */}
{!catConfig.all_products && (
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === 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>
)}
{catConfig.products.length > 0 && (
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
{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 (
<View key={pq.product_id} style={{ gap: 4 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
{prod?.name ?? `Produit #${pq.product_id}`}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
{tiers.length === 0 ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Aucun palier de prix actif pour ce produit
</Text>
) : tiers.map((tier) => {
const isSel = pq.quantity === tier.quantity;
return (
<TouchableOpacity
key={tier.quantity}
onPress={() => 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 && <Ionicons name="checkmark" size={10} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? REWARD_ACCENT : colors.textMuted }}>
{tier.quantity}{prod?.unit ?? ""} · {tier.price}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
</View>
)}
</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 });
// 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<Set<string>>(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 = (
<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,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
{enabled ? "Activée" : "Désactivée"}
</Text>
</View>
);
return (
<AccordionSection title="Récompenses par palier" badge={rewardBadge} colors={colors} s={s}>
<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>
{/* 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 — 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 */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
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.
</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 expanded = expandedCats.has(cat.name);
const catColor = cat.color || REWARD_ACCENT;
return (
<View key={cat.name}>
{/* Chip catégorie (couleur = au moins un type actif, clic = déplier/replier) */}
<TouchableOpacity
onPress={() => 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",
}}
>
<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={expanded ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{/* Les deux types de récompense, activables indépendamment */}
{expanded && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.m }}>
{REWARD_TYPES.map((rt) => {
const typeEnabled = isTypeEnabled(cat.name, rt.value);
return (
<View key={rt.value} style={{ gap: spacing.s }}>
<TouchableOpacity
onPress={() => 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",
}}
>
<Ionicons
name={typeEnabled ? "checkbox" : "square-outline"}
size={14}
color={typeEnabled ? REWARD_ACCENT : colors.textMuted}
/>
<Ionicons name={rt.icon as any} size={12} color={typeEnabled ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: typeEnabled ? "700" : "400", color: typeEnabled ? REWARD_ACCENT : colors.textMuted }}>
{rt.label}
</Text>
</TouchableOpacity>
{typeEnabled && (
<CategoryProductPicker
catConfig={getCatConfig(cat.name, rt.value)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
)}
</View>
);
})}
</View>
)}
</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 de points récompense débloquée
</Text>
{r.description !== "" && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
)}
{r.category_configs.map((cfg, idx) => (
<Text key={`${cfg.category}-${cfg.type}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{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(", ")}`}
</Text>
))}
</View>
)}
</View>
)}
</AccordionSection>
);
}
// 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<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,
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<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 ?? []).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<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);
}
}, []);
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 (
<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 */}
<AccordionSection title="Personnalisation" colors={colors} s={s}>
{/* Nom du shop */}
<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>
{/* Dégradé titre boutique */}
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingVertical: spacing.m, gap: spacing.m }}>
<Text style={s.rowLabel}>Dégradé du titre boutique</Text>
<Text style={s.rowDesc}>Couleurs du nom de la boutique dans le header du site client.</Text>
{/* Aperçu du dégradé */}
<View style={{ height: 36, borderRadius: borderRadius.sm, overflow: "hidden" }}>
<View style={{
flex: 1,
backgroundColor: settings.client_title_gradient_from,
// gradient simulé : deux moitiés de couleur
}}>
<View style={{
position: "absolute", right: 0, top: 0, bottom: 0,
width: "50%",
backgroundColor: settings.client_title_gradient_to,
}} />
<View style={{
position: "absolute", left: "25%", right: "25%", top: 0, bottom: 0,
backgroundColor: `${settings.client_title_gradient_from}00`,
}} />
</View>
</View>
{/* Couleur de départ */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{ width: 32, height: 32, borderRadius: borderRadius.sm, backgroundColor: settings.client_title_gradient_from, borderWidth: 1, borderColor: colors.border }} />
<View style={{ flex: 1 }}>
<Text style={[s.rowDesc, { marginBottom: 4 }]}>Couleur de départ</Text>
<TextInput
style={[s.input, { fontFamily: "monospace" }]}
value={settings.client_title_gradient_from}
onChangeText={(v) => setSettings((p) => ({ ...p, client_title_gradient_from: v }))}
placeholder="#a78bfa"
placeholderTextColor={colors.textMuted}
autoCorrect={false}
autoCapitalize="none"
maxLength={7}
/>
</View>
</View>
{/* Couleur de fin */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{ width: 32, height: 32, borderRadius: borderRadius.sm, backgroundColor: settings.client_title_gradient_to, borderWidth: 1, borderColor: colors.border }} />
<View style={{ flex: 1 }}>
<Text style={[s.rowDesc, { marginBottom: 4 }]}>Couleur de fin</Text>
<TextInput
style={[s.input, { fontFamily: "monospace" }]}
value={settings.client_title_gradient_to}
onChangeText={(v) => setSettings((p) => ({ ...p, client_title_gradient_to: v }))}
placeholder="#22d3ee"
placeholderTextColor={colors.textMuted}
autoCorrect={false}
autoCapitalize="none"
maxLength={7}
/>
</View>
</View>
</View>
</AccordionSection>
{/* Amendes */}
<AccordionSection title="Amendes" colors={colors} s={s}>
<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>
</AccordionSection>
{/* Parrainage */}
<AccordionSection title="Parrainage" colors={colors} s={s}>
<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>
</AccordionSection>
{/* Système de points */}
<AccordionSection title="Système de points" colors={colors} s={s}>
<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>
</AccordionSection>
{/* Attribution des catégories */}
<AccordionSection title="Attribution des catégories aux points" colors={colors} s={s}>
<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>
)}
</AccordionSection>
{/* 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 */}
<AccordionSection title="Paiement crypto" colors={colors} s={s}>
{/* 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>
</AccordionSection>
{/* ============================================ */}
{/* 🤖 TELEGRAM */}
{/* ============================================ */}
<AccordionSection title="Notifications Telegram" colors={colors} s={s}>
<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>
</AccordionSection>
{/* ============================================ */}
{/* 🚚 MODE DE LIVRAISON */}
{/* ============================================ */}
<AccordionSection title="Mode de livraison" colors={colors} s={s}>
<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>
)}
</AccordionSection>
{/* ============================================ */}
{/* 🎨 COULEURS DE L'INTERFACE */}
{/* ============================================ */}
<AccordionSection title="Couleurs de l'interface" colors={colors} s={s}>
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
Personnalisez les couleurs de l'espace admin et de l'app client / site web.
</Text>
<TouchableOpacity
onPress={() => 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",
}}
>
<Ionicons name="color-palette-outline" size={16} color="#7c3aed" />
<Text style={{ fontSize: fontSize.sm, color: "#7c3aed", fontWeight: "600" }}>
Restaurer les couleurs de base
</Text>
</TouchableOpacity>
{(["admin", "client"] as const).map((scope) => (
<View key={scope}>
<Text style={[s.rowLabel, { paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.xs }]}>
{scope === "admin" ? "Espace admin" : "App client & site web"}
</Text>
{([
{ 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 }) => (
<View key={key as string} style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m }}>
<Text style={[s.rowDesc, { marginBottom: spacing.xs }]}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{
width: 36, height: 36,
borderRadius: borderRadius.sm,
backgroundColor: (settings[key] as string) || "#7c3aed",
borderWidth: 1,
borderColor: colors.border,
}} />
<TextInput
style={[s.input, { flex: 1 }]}
value={settings[key] as string}
onChangeText={(v) => setSettings((p) => ({ ...p, [key]: v }))}
placeholder="#7c3aed"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={{ flexDirection: "row", gap: spacing.xs, marginTop: spacing.xs, flexWrap: "wrap" }}>
{["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#f59e0b", "#4ade80", "#ef4444", "#22d3ee", "#0f172a"].map((color) => (
<TouchableOpacity
key={color}
onPress={() => 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,
}}
/>
))}
</View>
</View>
))}
</View>
))}
</AccordionSection>
<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>
);
}