2375 lines
126 KiB
TypeScript
2375 lines
126 KiB
TypeScript
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>
|
||
);
|
||
}
|