2064 lines
106 KiB
TypeScript
2064 lines
106 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
Switch,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
TextInput,
|
|
Keyboard,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
import { useNavigation } from "@react-navigation/native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
|
import { useTheme } from "../../context/ThemeContext";
|
|
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
|
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
|
import type { Product } from "../../api/types";
|
|
import AlertModal from "../../components/ui/AlertModal";
|
|
import { useAlert } from "../../hooks/useAlert";
|
|
|
|
const POOL_COLORS = ["#10b981", "#9333ea", "#f97316", "#3b82f6", "#ef4444"];
|
|
|
|
const CRYPTO_CURRENCIES = [
|
|
{ id: "btc", label: "BTC", icon: "₿", color: "#f7931a" },
|
|
{ id: "eth", label: "ETH", icon: "Ξ", color: "#627eea" },
|
|
{ id: "ltc", label: "LTC", icon: "Ł", color: "#bebebe" },
|
|
{ id: "usdt", label: "USDT", icon: "₮", color: "#26a17b" },
|
|
{ id: "usdttrc20",label: "USDT TRC20",icon: "₮", color: "#c23631" },
|
|
{ id: "bnb", label: "BNB", icon: "B", color: "#f3ba2f" },
|
|
{ id: "sol", label: "SOL", icon: "◎", color: "#9945ff" },
|
|
{ id: "doge", label: "DOGE", icon: "Ð", color: "#c2a633" },
|
|
{ id: "trx", label: "TRX", icon: "T", color: "#c23631" },
|
|
{ id: "xmr", label: "XMR", icon: "ɱ", color: "#ff6600" },
|
|
];
|
|
|
|
const DAYS: { key: keyof DeliverySchedule; label: string }[] = [
|
|
{ key: "monday", label: "Lundi" },
|
|
{ key: "tuesday", label: "Mardi" },
|
|
{ key: "wednesday", label: "Mercredi" },
|
|
{ key: "thursday", label: "Jeudi" },
|
|
{ key: "friday", label: "Vendredi" },
|
|
{ key: "saturday", label: "Samedi" },
|
|
{ key: "sunday", label: "Dimanche" },
|
|
];
|
|
|
|
function DeliveryScheduleSection({
|
|
schedule,
|
|
onChange,
|
|
colors,
|
|
s,
|
|
}: {
|
|
schedule: DeliverySchedule;
|
|
onChange: (sched: DeliverySchedule) => void;
|
|
colors: any;
|
|
s: any;
|
|
}) {
|
|
const { width: screenWidth } = useWindowDimensions();
|
|
const updateDay = (key: keyof DeliverySchedule, patch: Partial<DaySchedule>) => {
|
|
onChange({ ...schedule, [key]: { ...schedule[key], ...patch } });
|
|
};
|
|
|
|
return (
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Horaires de livraison</Text>
|
|
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
|
Définissez les jours et horaires d'ouverture pour la livraison.
|
|
</Text>
|
|
{DAYS.map(({ key, label }, index) => {
|
|
const day = schedule[key];
|
|
return (
|
|
<View
|
|
key={key}
|
|
style={[
|
|
{
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
index === 0 && { borderTopWidth: 0 },
|
|
]}
|
|
>
|
|
{/* Ligne : jour + toggle */}
|
|
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: day.enabled ? spacing.s : 0 }}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<View style={{
|
|
width: 8, height: 8, borderRadius: 4,
|
|
backgroundColor: day.enabled ? "#10b981" : colors.border,
|
|
}} />
|
|
<Text style={[s.rowLabel]}>{label}</Text>
|
|
</View>
|
|
<Switch
|
|
value={day.enabled}
|
|
onValueChange={(v) => updateDay(key, { enabled: v })}
|
|
trackColor={{ false: colors.border, true: "#10b981" }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
{/* Horaires */}
|
|
{day.enabled && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.xs }}>
|
|
<Text style={s.thresholdSep}>Ouverture</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: screenWidth < 380 ? 58 : 72 }]}
|
|
value={day.open_time}
|
|
onChangeText={(v) => updateDay(key, { open_time: v })}
|
|
placeholder="09:00"
|
|
placeholderTextColor={colors.textMuted}
|
|
keyboardType="numbers-and-punctuation"
|
|
maxLength={5}
|
|
/>
|
|
<Text style={s.thresholdSep}>→</Text>
|
|
<Text style={s.thresholdSep}>Fermeture</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: screenWidth < 380 ? 58 : 72 }]}
|
|
value={day.close_time}
|
|
onChangeText={(v) => updateDay(key, { close_time: v })}
|
|
placeholder="20:00"
|
|
placeholderTextColor={colors.textMuted}
|
|
keyboardType="numbers-and-punctuation"
|
|
maxLength={5}
|
|
/>
|
|
</View>
|
|
)}
|
|
{!day.enabled && (
|
|
<Text style={[s.hint, { paddingHorizontal: 0, paddingBottom: 0, marginTop: spacing.xs }]}>
|
|
Fermé ce jour
|
|
</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Composant éditeur de zones postales
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
function parsePostalCodes(raw: string): string[] {
|
|
return raw
|
|
.split(/[\s,;]+/)
|
|
.map((c) => c.trim())
|
|
.filter((c) => /^\d{5}$/.test(c));
|
|
}
|
|
|
|
type NewZoneForm = { name: string; min_amount: string; codesRaw: string };
|
|
const EMPTY_FORM: NewZoneForm = { name: "", min_amount: "", codesRaw: "" };
|
|
|
|
function PostalZonesSection({
|
|
zones,
|
|
onChange,
|
|
colors,
|
|
s,
|
|
}: {
|
|
zones: PostalZone[];
|
|
onChange: (zones: PostalZone[]) => void;
|
|
colors: any;
|
|
s: any;
|
|
}) {
|
|
const { width: screenWidth } = useWindowDimensions();
|
|
const inputMd = screenWidth < 380 ? 64 : 80;
|
|
const inputLg = screenWidth < 380 ? 80 : 100;
|
|
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(null);
|
|
const [creating, setCreating] = React.useState(false);
|
|
const [form, setForm] = React.useState<NewZoneForm>(EMPTY_FORM);
|
|
const [formError, setFormError] = React.useState("");
|
|
|
|
const removeZone = (i: number) => {
|
|
onChange(zones.filter((_, idx) => idx !== i));
|
|
if (expandedIndex === i) setExpandedIndex(null);
|
|
};
|
|
|
|
const updateZone = (i: number, patch: Partial<PostalZone>) => {
|
|
onChange(zones.map((z, idx) => (idx === i ? { ...z, ...patch } : z)));
|
|
};
|
|
|
|
const removeCode = (zoneIdx: number, codeIdx: number) => {
|
|
updateZone(zoneIdx, {
|
|
codes: zones[zoneIdx].codes.filter((_, idx) => idx !== codeIdx),
|
|
});
|
|
};
|
|
|
|
const confirmCreate = () => {
|
|
const name = form.name.trim();
|
|
if (!name) { setFormError("Le nom de la zone est obligatoire."); return; }
|
|
const min = parseFloat(form.min_amount);
|
|
if (isNaN(min) || min < 0) { setFormError("Montant minimum invalide."); return; }
|
|
const codes = parsePostalCodes(form.codesRaw);
|
|
if (codes.length === 0) { setFormError("Ajoutez au moins un code postal valide (5 chiffres)."); return; }
|
|
onChange([...zones, { name, min_amount: min, codes }]);
|
|
setCreating(false);
|
|
setForm(EMPTY_FORM);
|
|
setFormError("");
|
|
setExpandedIndex(zones.length); // expand newly created
|
|
};
|
|
|
|
return (
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Zones de livraison</Text>
|
|
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
|
Définissez les zones de livraison et leur montant minimum de commande.
|
|
</Text>
|
|
|
|
{zones.map((zone, i) => {
|
|
const expanded = expandedIndex === i;
|
|
return (
|
|
<View key={i} style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
{/* En-tête zone */}
|
|
<TouchableOpacity
|
|
onPress={() => setExpandedIndex(expanded ? null : i)}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
gap: spacing.s,
|
|
}}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons
|
|
name={expanded ? "chevron-down" : "chevron-forward"}
|
|
size={16}
|
|
color={colors.textMuted}
|
|
/>
|
|
<Text style={[s.rowLabel, { flex: 1 }]} numberOfLines={1}>
|
|
{zone.name || "Zone sans nom"}
|
|
</Text>
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "700" }}>
|
|
{zone.min_amount > 0 ? `${zone.min_amount}€ min` : "Sans min"}
|
|
</Text>
|
|
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, marginLeft: spacing.xs }}>
|
|
{zone.codes.length} codes
|
|
</Text>
|
|
<TouchableOpacity onPress={() => removeZone(i)} style={{ marginLeft: spacing.s }}>
|
|
<Ionicons name="trash-outline" size={18} color={colors.danger} />
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
|
|
{/* Détail zone (édition) */}
|
|
{expanded && (
|
|
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.m }}>
|
|
{/* Nom */}
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<Text style={[s.thresholdSep, { width: screenWidth < 380 ? 38 : 48 }]}>Nom</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { flex: 1, width: undefined, textAlign: "left", paddingHorizontal: spacing.m }]}
|
|
value={zone.name}
|
|
onChangeText={(v) => updateZone(i, { name: v })}
|
|
placeholder="Ex: Zone centre-ville"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
</View>
|
|
{/* Minimum */}
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<Text style={[s.thresholdSep, { width: screenWidth < 380 ? 38 : 48 }]}>Min €</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: inputMd }]}
|
|
value={String(zone.min_amount)}
|
|
onChangeText={(v) => {
|
|
const n = parseFloat(v);
|
|
if (!isNaN(n)) updateZone(i, { min_amount: n });
|
|
}}
|
|
keyboardType="decimal-pad"
|
|
placeholder="0"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
</View>
|
|
{/* Codes postaux — chips supprimables */}
|
|
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>
|
|
Codes postaux ({zone.codes.length}) — appuyer pour supprimer :
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
|
{zone.codes.map((code, ci) => (
|
|
<TouchableOpacity
|
|
key={ci}
|
|
onPress={() => removeCode(i, ci)}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
backgroundColor: colors.accent + "20",
|
|
borderRadius: borderRadius.sm,
|
|
paddingHorizontal: spacing.s,
|
|
paddingVertical: 4,
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
|
|
{code}
|
|
</Text>
|
|
<Ionicons name="close" size={12} color={colors.accent} />
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
{/* Ajout en masse */}
|
|
<BulkCodesInput
|
|
onAdd={(newCodes) => {
|
|
const existing = new Set(zone.codes);
|
|
const toAdd = newCodes.filter((c) => !existing.has(c));
|
|
if (toAdd.length > 0) updateZone(i, { codes: [...zone.codes, ...toAdd] });
|
|
}}
|
|
colors={colors}
|
|
s={s}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
|
|
{zones.length === 0 && !creating && (
|
|
<Text style={[s.hint, { paddingTop: 0 }]}>Aucune zone définie — toutes les commandes seront acceptées sans minimum.</Text>
|
|
)}
|
|
|
|
{/* Formulaire de création */}
|
|
{creating && (
|
|
<View style={{ paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, gap: spacing.m }}>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Nouvelle zone</Text>
|
|
|
|
{/* Nom */}
|
|
<View style={{ gap: 4 }}>
|
|
<Text style={s.thresholdSep}>Nom de la zone *</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m }]}
|
|
value={form.name}
|
|
onChangeText={(v) => { setForm((f) => ({ ...f, name: v })); setFormError(""); }}
|
|
placeholder="Ex: Zone centre-ville"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoFocus
|
|
/>
|
|
</View>
|
|
|
|
{/* Minimum */}
|
|
<View style={{ gap: 4 }}>
|
|
<Text style={s.thresholdSep}>Montant minimum de commande (€) *</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: inputLg, textAlign: "left", paddingHorizontal: spacing.m }]}
|
|
value={form.min_amount}
|
|
onChangeText={(v) => { setForm((f) => ({ ...f, min_amount: v })); setFormError(""); }}
|
|
keyboardType="decimal-pad"
|
|
placeholder="Ex: 30"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
</View>
|
|
|
|
{/* Codes postaux */}
|
|
<View style={{ gap: 4 }}>
|
|
<Text style={s.thresholdSep}>Codes postaux * (séparés par virgules ou espaces)</Text>
|
|
<TextInput
|
|
style={[
|
|
s.thresholdInput,
|
|
{
|
|
width: "100%",
|
|
textAlign: "left",
|
|
paddingHorizontal: spacing.m,
|
|
paddingVertical: spacing.s,
|
|
height: 80,
|
|
textAlignVertical: "top",
|
|
},
|
|
]}
|
|
value={form.codesRaw}
|
|
onChangeText={(v) => { setForm((f) => ({ ...f, codesRaw: v })); setFormError(""); }}
|
|
placeholder={"44000, 44100, 44200\n44300 44400"}
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
keyboardType="numbers-and-punctuation"
|
|
/>
|
|
{form.codesRaw.length > 0 && (
|
|
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, fontStyle: "italic" }}>
|
|
{parsePostalCodes(form.codesRaw).length} code(s) valide(s) détecté(s)
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{formError !== "" && (
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.danger, fontStyle: "italic" }}>
|
|
{formError}
|
|
</Text>
|
|
)}
|
|
|
|
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
|
<TouchableOpacity
|
|
onPress={confirmCreate}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: colors.accent,
|
|
borderRadius: borderRadius.sm,
|
|
paddingVertical: spacing.s,
|
|
alignItems: "center",
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
gap: spacing.xs,
|
|
}}
|
|
>
|
|
<Ionicons name="checkmark" size={16} color="#fff" />
|
|
<Text style={{ color: "#fff", fontWeight: "700", fontSize: fontSize.sm }}>Créer la zone</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => { setCreating(false); setForm(EMPTY_FORM); setFormError(""); }}
|
|
style={{
|
|
paddingHorizontal: spacing.l,
|
|
borderRadius: borderRadius.sm,
|
|
paddingVertical: spacing.s,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
}}
|
|
>
|
|
<Text style={{ color: colors.textMuted, fontWeight: "600", fontSize: fontSize.sm }}>Annuler</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{!creating && (
|
|
<TouchableOpacity
|
|
onPress={() => { setCreating(true); setForm(EMPTY_FORM); setFormError(""); }}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
}}
|
|
>
|
|
<Ionicons name="add-circle-outline" size={18} color={colors.accent} />
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
|
|
Ajouter une zone
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function BulkCodesInput({
|
|
onAdd,
|
|
colors,
|
|
s,
|
|
}: {
|
|
onAdd: (codes: string[]) => void;
|
|
colors: any;
|
|
s: any;
|
|
}) {
|
|
const [value, setValue] = React.useState("");
|
|
const parsed = parsePostalCodes(value);
|
|
|
|
const handleAdd = () => {
|
|
if (parsed.length === 0) return;
|
|
onAdd(parsed);
|
|
setValue("");
|
|
};
|
|
|
|
return (
|
|
<View style={{ gap: spacing.xs }}>
|
|
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>Ajouter des codes (virgules ou espaces) :</Text>
|
|
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: spacing.s }}>
|
|
<TextInput
|
|
style={[
|
|
s.thresholdInput,
|
|
{
|
|
flex: 1,
|
|
width: undefined,
|
|
textAlign: "left",
|
|
paddingHorizontal: spacing.m,
|
|
paddingVertical: spacing.s,
|
|
height: 60,
|
|
textAlignVertical: "top",
|
|
},
|
|
]}
|
|
value={value}
|
|
onChangeText={setValue}
|
|
placeholder={"44500, 44600 44700"}
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
keyboardType="numbers-and-punctuation"
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={handleAdd}
|
|
style={{
|
|
backgroundColor: parsed.length > 0 ? colors.accent : colors.border,
|
|
borderRadius: borderRadius.sm,
|
|
padding: spacing.s,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minWidth: 48,
|
|
height: 60,
|
|
}}
|
|
disabled={parsed.length === 0}
|
|
>
|
|
<Ionicons name="add" size={18} color="#fff" />
|
|
{parsed.length > 0 && (
|
|
<Text style={{ color: "#fff", fontSize: 10, fontWeight: "700" }}>{parsed.length}</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
{value.length > 0 && parsed.length > 0 && (
|
|
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, fontStyle: "italic" }}>
|
|
{parsed.length} code(s) valide(s) : {parsed.join(", ")}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Composant éditeur de paliers
|
|
// ──────────────────────────────────────────────────────────────
|
|
function TiersSection({
|
|
title,
|
|
tiers,
|
|
onChange,
|
|
colors,
|
|
s,
|
|
accentColor,
|
|
active,
|
|
}: {
|
|
title: string;
|
|
tiers: PointsTier[];
|
|
onChange: (tiers: PointsTier[]) => void;
|
|
colors: any;
|
|
s: any;
|
|
accentColor: string;
|
|
active: boolean;
|
|
}) {
|
|
const { width: screenWidth } = useWindowDimensions();
|
|
const inputSm = screenWidth < 380 ? 46 : 56;
|
|
const updateTier = (i: number, field: keyof PointsTier, raw: string) => {
|
|
const val = field === "points" ? parseInt(raw, 10) : parseFloat(raw);
|
|
if (isNaN(val)) return;
|
|
const next = tiers.map((t, idx) => (idx === i ? { ...t, [field]: val } : t));
|
|
onChange(next);
|
|
};
|
|
|
|
const addTier = () => {
|
|
onChange([...tiers, { min: 0, max: 0, points: 1 }]);
|
|
};
|
|
|
|
const removeTier = (i: number) => {
|
|
onChange(tiers.filter((_, idx) => idx !== i));
|
|
};
|
|
|
|
return (
|
|
<View style={s.section}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
|
|
<Text style={s.sectionTitle}>{title}</Text>
|
|
<View style={{
|
|
paddingHorizontal: spacing.s,
|
|
paddingVertical: 2,
|
|
borderRadius: 10,
|
|
backgroundColor: active ? accentColor + "25" : colors.border + "40",
|
|
borderWidth: 1,
|
|
borderColor: active ? accentColor : colors.border,
|
|
marginBottom: spacing.s,
|
|
}}>
|
|
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
|
|
{active ? "Actif" : "Ignoré"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={s.hint}>
|
|
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
|
|
Max = 0 signifie illimité (pas de borne supérieure).
|
|
</Text>
|
|
|
|
{/* En-tête colonnes */}
|
|
<View style={{ flexDirection: "row", paddingHorizontal: spacing.l, paddingBottom: spacing.xs, gap: spacing.xs }}>
|
|
<Text style={[s.thresholdSep, { width: inputSm, textAlign: "center" }]}>Min €</Text>
|
|
<Text style={[s.thresholdSep, { width: inputSm, textAlign: "center" }]}>Max €</Text>
|
|
<Text style={[s.thresholdSep, { width: screenWidth < 380 ? 36 : 44, textAlign: "center" }]}>Pts</Text>
|
|
</View>
|
|
|
|
{tiers.map((tier, i) => (
|
|
<View
|
|
key={i}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.s,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
gap: spacing.xs,
|
|
}}
|
|
>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: inputSm }]}
|
|
keyboardType="decimal-pad"
|
|
value={String(tier.min)}
|
|
onChangeText={(v) => updateTier(i, "min", v)}
|
|
/>
|
|
<Text style={s.thresholdSep}>→</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: inputSm }]}
|
|
keyboardType="decimal-pad"
|
|
value={String(tier.max)}
|
|
onChangeText={(v) => updateTier(i, "max", v)}
|
|
placeholder="0=∞"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<Text style={s.thresholdSep}>=</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: screenWidth < 380 ? 36 : 44 }]}
|
|
keyboardType="number-pad"
|
|
value={String(tier.points)}
|
|
onChangeText={(v) => updateTier(i, "points", v)}
|
|
/>
|
|
<Text style={[s.thresholdSep, { marginRight: spacing.xs }]}>pts</Text>
|
|
<TouchableOpacity onPress={() => removeTier(i)}>
|
|
<Ionicons name="trash-outline" size={18} color={colors.danger} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
))}
|
|
|
|
{tiers.length === 0 && (
|
|
<Text style={[s.hint, { paddingTop: 0 }]}>Aucun palier défini</Text>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
onPress={addTier}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
}}
|
|
>
|
|
<Ionicons name="add-circle-outline" size={18} color={accentColor} />
|
|
<Text style={{ fontSize: fontSize.sm, color: accentColor, fontWeight: "600" }}>
|
|
Ajouter un palier
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const REWARD_ACCENT = "#f59e0b";
|
|
|
|
const REWARD_TYPES: { value: PointsReward["type"]; label: string; icon: string }[] = [
|
|
{ value: "free_product", label: "Produit offert", icon: "gift-outline" },
|
|
{ value: "half_price_product", label: "Produit à -50%", icon: "pricetag-outline" },
|
|
{ value: "custom", label: "Personnalisé", icon: "star-outline" },
|
|
];
|
|
|
|
const EMPTY_REWARD: PointsReward = {
|
|
threshold: 20,
|
|
type: "free_product",
|
|
description: "",
|
|
category_configs: [],
|
|
};
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Sélecteur de produits pour une catégorie dans la récompense
|
|
// ──────────────────────────────────────────────────────────────
|
|
function CategoryProductPicker({
|
|
catConfig,
|
|
products,
|
|
onChange,
|
|
colors,
|
|
s,
|
|
}: {
|
|
catConfig: RewardCategoryConfig;
|
|
products: Product[];
|
|
onChange: (cfg: RewardCategoryConfig) => void;
|
|
colors: any;
|
|
s: any;
|
|
}) {
|
|
const catProducts = products.filter((p) => p.category === catConfig.category);
|
|
|
|
const toggleProduct = (id: number) => {
|
|
const ids = catConfig.product_ids.includes(id)
|
|
? catConfig.product_ids.filter((x) => x !== id)
|
|
: [...catConfig.product_ids, id];
|
|
onChange({ ...catConfig, product_ids: ids, all_products: false });
|
|
};
|
|
|
|
return (
|
|
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
|
{/* Montant pour cette catégorie */}
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<Text style={{ fontSize: 12, color: s.thresholdSep.color ?? "#888", flex: 1 }}>
|
|
Valeur du produit offert
|
|
</Text>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
|
|
keyboardType="decimal-pad"
|
|
value={catConfig.amount > 0 ? String(catConfig.amount) : ""}
|
|
onChangeText={(v) => {
|
|
const n = parseFloat(v);
|
|
onChange({ ...catConfig, amount: isNaN(n) ? 0 : n });
|
|
}}
|
|
placeholder="0"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<Text style={{ fontSize: 12, color: colors.textMuted }}>€</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Toggle tous / sélection */}
|
|
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
|
<TouchableOpacity
|
|
onPress={() => onChange({ ...catConfig, all_products: true, product_ids: [] })}
|
|
style={{
|
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
|
borderRadius: borderRadius.full, borderWidth: 1.5,
|
|
borderColor: catConfig.all_products ? REWARD_ACCENT : colors.border,
|
|
backgroundColor: catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
|
|
}}
|
|
>
|
|
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
|
|
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
|
|
Tous ({catProducts.length})
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => onChange({ ...catConfig, all_products: false })}
|
|
style={{
|
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
|
borderRadius: borderRadius.full, borderWidth: 1.5,
|
|
borderColor: !catConfig.all_products ? REWARD_ACCENT : colors.border,
|
|
backgroundColor: !catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
|
|
}}
|
|
>
|
|
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
|
|
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
|
|
Sélection
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Liste des produits si mode sélection */}
|
|
{!catConfig.all_products && (
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
|
{catProducts.length === 0 ? (
|
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
|
|
Aucun produit dans cette catégorie
|
|
</Text>
|
|
) : catProducts.map((p) => {
|
|
const sel = catConfig.product_ids.includes(p.id);
|
|
return (
|
|
<TouchableOpacity
|
|
key={p.id}
|
|
onPress={() => toggleProduct(p.id)}
|
|
style={{
|
|
paddingHorizontal: spacing.s, paddingVertical: 4,
|
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
|
borderColor: sel ? REWARD_ACCENT : colors.border,
|
|
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
|
|
flexDirection: "row", alignItems: "center", gap: 4,
|
|
}}
|
|
>
|
|
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
|
|
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
|
{p.name}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Section centralisée récompenses par palier
|
|
// ──────────────────────────────────────────────────────────────
|
|
function CentralRewardSection({
|
|
reward,
|
|
allCategories,
|
|
productsByCategory,
|
|
onChange,
|
|
colors,
|
|
s,
|
|
}: {
|
|
reward: PointsReward | null | undefined;
|
|
allCategories: Category[];
|
|
productsByCategory: Record<string, Product[]>;
|
|
onChange: (reward: PointsReward | null) => void;
|
|
colors: any;
|
|
s: any;
|
|
}) {
|
|
const { width: screenWidth } = useWindowDimensions();
|
|
const enabled = !!reward;
|
|
const rawR = reward ?? EMPTY_REWARD;
|
|
const r: PointsReward = { ...rawR, category_configs: rawR.category_configs ?? [] };
|
|
|
|
const update = (patch: Partial<PointsReward>) =>
|
|
onChange({ ...r, ...patch });
|
|
|
|
const getCatConfig = (catName: string): RewardCategoryConfig =>
|
|
r.category_configs.find((c) => c.category === catName) ??
|
|
{ category: catName, all_products: true, product_ids: [], amount: 0 };
|
|
|
|
const isCatSelected = (catName: string) =>
|
|
r.category_configs.some((c) => c.category === catName);
|
|
|
|
const toggleCategory = (catName: string) => {
|
|
if (isCatSelected(catName)) {
|
|
update({ category_configs: r.category_configs.filter((c) => c.category !== catName) });
|
|
} else {
|
|
update({ category_configs: [...r.category_configs, { category: catName, all_products: true, product_ids: [], amount: 0 }] });
|
|
}
|
|
};
|
|
|
|
const updateCatConfig = (cfg: RewardCategoryConfig) => {
|
|
update({
|
|
category_configs: r.category_configs.map((c) =>
|
|
c.category === cfg.category ? cfg : c
|
|
),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<View style={s.section}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
|
|
<Text style={s.sectionTitle}>Récompenses par palier</Text>
|
|
<View style={{
|
|
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
|
backgroundColor: enabled ? REWARD_ACCENT + "25" : colors.border + "40",
|
|
borderWidth: 1, borderColor: enabled ? REWARD_ACCENT : colors.border,
|
|
marginBottom: spacing.s,
|
|
}}>
|
|
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
|
|
{enabled ? "Activée" : "Désactivée"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={[s.row, s.rowFirst]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Récompense activée</Text>
|
|
<Text style={s.rowDesc}>
|
|
Le seuil s'applique indépendamment à chaque type de points.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={enabled}
|
|
onValueChange={(v) => onChange(v ? EMPTY_REWARD : null)}
|
|
trackColor={{ false: colors.border, true: REWARD_ACCENT }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
|
|
{enabled && (
|
|
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
|
|
|
|
{/* Seuil */}
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={s.rowLabel}>Seuil de points</Text>
|
|
<Text style={s.rowDesc}>
|
|
Dès X points cumulés dans un type, la récompense est débloquée pour ce type.
|
|
</Text>
|
|
</View>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: screenWidth < 380 ? 52 : 64 }]}
|
|
keyboardType="number-pad"
|
|
value={String(r.threshold)}
|
|
onChangeText={(v) => { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) update({ threshold: n }); }}
|
|
/>
|
|
<Text style={s.thresholdSep}>pts</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Type */}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Type de récompense</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
|
|
{REWARD_TYPES.map((rt) => {
|
|
const sel = r.type === rt.value;
|
|
return (
|
|
<TouchableOpacity
|
|
key={rt.value}
|
|
onPress={() => update({ type: rt.value })}
|
|
style={{
|
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
|
borderColor: sel ? REWARD_ACCENT : colors.border,
|
|
backgroundColor: sel ? REWARD_ACCENT + "20" : "transparent",
|
|
}}
|
|
>
|
|
<Ionicons name={rt.icon as any} size={14} color={sel ? REWARD_ACCENT : colors.textMuted} />
|
|
<Text style={{ fontSize: 13, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
|
{rt.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Description */}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Description affichée au client</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m, paddingVertical: spacing.s, height: 72, textAlignVertical: "top" }]}
|
|
value={r.description}
|
|
onChangeText={(v) => update({ description: v })}
|
|
placeholder="Ex : Un produit 30€ de ton choix parmi les produits conditionnés en 30€"
|
|
placeholderTextColor={colors.textMuted}
|
|
multiline
|
|
/>
|
|
</View>
|
|
|
|
{/* Catégories éligibles */}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</Text>
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
|
Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection.
|
|
</Text>
|
|
{allCategories.length === 0 ? (
|
|
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
|
|
) : (
|
|
<View style={{ gap: spacing.m }}>
|
|
{allCategories.map((cat) => {
|
|
const selected = isCatSelected(cat.name);
|
|
const catColor = cat.color || REWARD_ACCENT;
|
|
return (
|
|
<View key={cat.name}>
|
|
{/* Chip catégorie */}
|
|
<TouchableOpacity
|
|
onPress={() => toggleCategory(cat.name)}
|
|
style={{
|
|
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
alignSelf: "flex-start",
|
|
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
|
borderRadius: borderRadius.full, borderWidth: 1.5,
|
|
borderColor: selected ? catColor : colors.border,
|
|
backgroundColor: selected ? catColor + "22" : "transparent",
|
|
}}
|
|
>
|
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
|
|
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
|
|
{cat.name}
|
|
</Text>
|
|
<Ionicons
|
|
name={selected ? "chevron-down" : "chevron-forward"}
|
|
size={12}
|
|
color={selected ? catColor : colors.textMuted}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{/* Sélecteur produits (visible si catégorie sélectionnée) */}
|
|
{selected && (
|
|
<CategoryProductPicker
|
|
catConfig={getCatConfig(cat.name)}
|
|
products={productsByCategory[cat.name] ?? []}
|
|
onChange={updateCatConfig}
|
|
colors={colors}
|
|
s={s}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Récapitulatif */}
|
|
{r.category_configs.length > 0 && (
|
|
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
|
|
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
|
|
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
|
|
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type →{" "}
|
|
{REWARD_TYPES.find((x) => x.value === r.type)?.label}
|
|
</Text>
|
|
{r.description !== "" && (
|
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
|
|
)}
|
|
{r.category_configs.map((cfg) => (
|
|
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
|
|
• {cfg.category}{cfg.amount > 0 ? ` (${cfg.amount}€)` : ""} : {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s) sélectionné(s)`}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function SettingsScreen() {
|
|
const { colors } = useTheme();
|
|
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
|
const navigation = useNavigation();
|
|
const { width: screenWidth } = useWindowDimensions();
|
|
const inputMd = screenWidth < 380 ? 64 : 80;
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [settings, setSettings] = useState<AppSettings>({
|
|
penalties_enabled: true,
|
|
show_amende_score: true,
|
|
penalty_tiers: [
|
|
{ min_cancel: 0, amount: 20 },
|
|
{ min_cancel: 1, amount: 50 },
|
|
{ min_cancel: 2, amount: 100 },
|
|
{ min_cancel: 3, amount: 150 },
|
|
],
|
|
points_enabled: true,
|
|
points_pools: [
|
|
{ key: "pool_0", name: "Pool 1", categories: [], tiers: [] },
|
|
{ key: "pool_1", name: "Pool 2", categories: [], tiers: [] },
|
|
],
|
|
referral_enabled: true,
|
|
delivery_schedule: DEFAULT_DELIVERY_SCHEDULE,
|
|
postal_zones: DEFAULT_POSTAL_ZONES,
|
|
crypto_payment_enabled: false,
|
|
crypto_only: false,
|
|
nowpayments_api_key: "",
|
|
nowpayments_ipn_secret: "",
|
|
nowpayments_currencies: [],
|
|
telegram_bot_token: "",
|
|
telegram_bot_username: "",
|
|
telegram_notifications_enabled: false,
|
|
telegram_2fa_enabled: false,
|
|
delivery_mode: { mode: "single" as const, category_routes: [] },
|
|
shop_name: "Milieu-Nantais",
|
|
contact_telegram: "",
|
|
points_reward: null,
|
|
});
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [productsByCategory, setProductsByCategory] = useState<Record<string, Product[]>>({});
|
|
const [livreurs, setLivreurs] = useState<string[]>([]);
|
|
|
|
// Refs pour l'auto-sauvegarde au départ de la page
|
|
const settingsRef = useRef(settings);
|
|
const isDirty = useRef(false);
|
|
const isLoaded = useRef(false);
|
|
|
|
useEffect(() => {
|
|
settingsRef.current = settings;
|
|
if (isLoaded.current) {
|
|
isDirty.current = true;
|
|
}
|
|
}, [settings]);
|
|
|
|
// Auto-sauvegarde silencieuse quand l'utilisateur quitte la page
|
|
useEffect(() => {
|
|
const unsubscribe = navigation.addListener("beforeRemove", () => {
|
|
if (isDirty.current) {
|
|
Keyboard.dismiss();
|
|
updateSettings(settingsRef.current).catch(() => {});
|
|
isDirty.current = false;
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
}, [navigation]);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
isLoaded.current = false;
|
|
try {
|
|
const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([
|
|
getSettings(),
|
|
getCategories(),
|
|
getAvailableDeliveryPersons(),
|
|
getAllProductsAdmin(),
|
|
]);
|
|
if (settingsRes.success && settingsRes.settings) {
|
|
const s = settingsRes.settings;
|
|
setSettings({
|
|
...s,
|
|
penalty_tiers: s.penalty_tiers ?? [],
|
|
points_pools: (s.points_pools ?? []).map((p: any) => ({
|
|
...p,
|
|
categories: p.categories ?? [],
|
|
tiers: p.tiers ?? [],
|
|
})),
|
|
nowpayments_currencies: s.nowpayments_currencies ?? [],
|
|
delivery_schedule: s.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
|
postal_zones: s.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
|
delivery_mode: {
|
|
...(s.delivery_mode ?? { mode: "single" as const }),
|
|
category_routes: s.delivery_mode?.category_routes ?? [],
|
|
},
|
|
points_reward: s.points_reward
|
|
? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [] }
|
|
: null,
|
|
});
|
|
}
|
|
if (categoriesRes) {
|
|
setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
|
|
}
|
|
if (livreursRes.success) {
|
|
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
|
|
}
|
|
if (productsRes.success) {
|
|
const byCategory: Record<string, Product[]> = {};
|
|
for (const p of productsRes.data) {
|
|
const cat = p.category || "";
|
|
if (!byCategory[cat]) byCategory[cat] = [];
|
|
byCategory[cat].push(p);
|
|
}
|
|
setProductsByCategory(byCategory);
|
|
}
|
|
} catch (e) {
|
|
console.error("[SettingsScreen] loadData error:", e);
|
|
} finally {
|
|
setLoading(false);
|
|
setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// Retourne l'index du pool auquel la catégorie est assignée, ou -1 si aucun
|
|
const getPoolIndexFor = (catName: string): number => {
|
|
const pools = settings.points_pools ?? [];
|
|
for (let i = 0; i < pools.length; i++) {
|
|
if ((pools[i].categories ?? []).includes(catName)) return i;
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
// Assigne une catégorie à un pool (ou la retire si poolIdx === -1)
|
|
const setCategoryPool = (catName: string, poolIdx: number) => {
|
|
setSettings((prev) => {
|
|
const pools = (prev.points_pools ?? []).map((pool, i) => ({
|
|
...pool,
|
|
categories: (pool.categories ?? []).filter((c) => c !== catName),
|
|
}));
|
|
if (poolIdx >= 0 && poolIdx < pools.length) {
|
|
pools[poolIdx] = {
|
|
...pools[poolIdx],
|
|
categories: [...(pools[poolIdx].categories ?? []), catName],
|
|
};
|
|
}
|
|
return { ...prev, points_pools: pools };
|
|
});
|
|
};
|
|
|
|
const addPool = () => {
|
|
setSettings((prev) => {
|
|
const pools = prev.points_pools ?? [];
|
|
const newKey = `pool_${Date.now()}`;
|
|
return {
|
|
...prev,
|
|
points_pools: [...pools, { key: newKey, name: `Type ${pools.length + 1}`, categories: [], tiers: [] }],
|
|
};
|
|
});
|
|
};
|
|
|
|
const removePool = (poolIdx: number) => {
|
|
setSettings((prev) => {
|
|
const pools = (prev.points_pools ?? []).filter((_, i) => i !== poolIdx);
|
|
return { ...prev, points_pools: pools };
|
|
});
|
|
};
|
|
|
|
const renamePool = (poolIdx: number, name: string) => {
|
|
setSettings((prev) => {
|
|
const pools = (prev.points_pools ?? []).map((p, i) =>
|
|
i === poolIdx ? { ...p, name } : p
|
|
);
|
|
return { ...prev, points_pools: pools };
|
|
});
|
|
};
|
|
|
|
const updatePoolTiers = (poolIdx: number, tiers: PointsTier[]) => {
|
|
setSettings((prev) => {
|
|
const pools = (prev.points_pools ?? []).map((p, i) =>
|
|
i === poolIdx ? { ...p, tiers } : p
|
|
);
|
|
return { ...prev, points_pools: pools };
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
// Fermer le clavier pour s'assurer que les TextInputs commitent leur valeur
|
|
Keyboard.dismiss();
|
|
setSaving(true);
|
|
// Utiliser le ref pour avoir la valeur la plus à jour (post-blur)
|
|
const current = settingsRef.current;
|
|
const res = await updateSettings(current);
|
|
setSaving(false);
|
|
if (res.success) {
|
|
isDirty.current = false;
|
|
showSuccess("Succès", "Paramètres sauvegardés");
|
|
} else {
|
|
showError("Erreur", res.error || "Erreur lors de la sauvegarde");
|
|
}
|
|
};
|
|
|
|
const s = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
|
content: { padding: screenWidth < 380 ? spacing.m : spacing.l, paddingBottom: spacing.xl * 2 },
|
|
section: {
|
|
backgroundColor: colors.bgSecondary,
|
|
borderRadius: borderRadius.lg,
|
|
marginBottom: spacing.l,
|
|
overflow: "hidden",
|
|
},
|
|
sectionTitle: {
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
color: colors.textMuted,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.8,
|
|
paddingHorizontal: spacing.l,
|
|
paddingTop: spacing.l,
|
|
paddingBottom: spacing.s,
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
rowFirst: { borderTopWidth: 0 },
|
|
rowLeft: { flex: 1, marginRight: spacing.m },
|
|
rowLabel: { fontSize: fontSize.md, color: colors.textPrimary, fontWeight: "600" },
|
|
rowDesc: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 },
|
|
catRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
gap: spacing.s,
|
|
},
|
|
catRowFirst: { borderTopWidth: 0 },
|
|
colorDot: { width: 10, height: 10, borderRadius: 5 },
|
|
catName: { flex: 1, fontSize: fontSize.md, color: colors.textPrimary },
|
|
chips: { flexDirection: "row", gap: spacing.xs },
|
|
chip: {
|
|
paddingHorizontal: spacing.s,
|
|
paddingVertical: 4,
|
|
borderRadius: borderRadius.sm,
|
|
borderWidth: 1,
|
|
},
|
|
chipText: { fontSize: fontSize.sm, fontWeight: "600" },
|
|
hint: {
|
|
fontSize: fontSize.sm,
|
|
color: colors.textMuted,
|
|
fontStyle: "italic",
|
|
paddingHorizontal: spacing.l,
|
|
paddingBottom: spacing.m,
|
|
},
|
|
thresholdRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.l,
|
|
paddingVertical: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
gap: spacing.m,
|
|
},
|
|
thresholdLabel: { flex: 1, fontSize: fontSize.md, color: colors.textPrimary, fontWeight: "600" },
|
|
thresholdDesc: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 },
|
|
thresholdInputs: { flexDirection: "row", alignItems: "center", gap: spacing.s },
|
|
thresholdInput: {
|
|
backgroundColor: colors.bgPrimary,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: borderRadius.sm,
|
|
paddingHorizontal: spacing.s,
|
|
paddingVertical: 6,
|
|
color: colors.textPrimary,
|
|
fontSize: fontSize.md,
|
|
width: 64,
|
|
textAlign: "center",
|
|
},
|
|
thresholdSep: { fontSize: fontSize.md, color: colors.textMuted },
|
|
input: {
|
|
backgroundColor: colors.bgPrimary,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: borderRadius.sm,
|
|
paddingHorizontal: spacing.m,
|
|
paddingVertical: spacing.s,
|
|
color: colors.textPrimary,
|
|
fontSize: fontSize.sm,
|
|
},
|
|
saveButton: {
|
|
backgroundColor: colors.accent,
|
|
borderRadius: borderRadius.lg,
|
|
paddingVertical: spacing.m,
|
|
alignItems: "center",
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
gap: spacing.s,
|
|
marginTop: spacing.s,
|
|
},
|
|
saveButtonText: { color: "#fff", fontSize: fontSize.md, fontWeight: "700" },
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={[s.container, { alignItems: "center", justifyContent: "center" }]}>
|
|
<ActivityIndicator size="large" color={colors.accent} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const pools = settings.points_pools ?? [];
|
|
|
|
return (
|
|
<View style={s.container}>
|
|
<ScrollView contentContainerStyle={s.content}>
|
|
{/* Personnalisation */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Personnalisation</Text>
|
|
<View style={[s.row, s.rowFirst]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Nom du shop</Text>
|
|
<Text style={s.rowDesc}>Affiché dans la sidebar du site client</Text>
|
|
</View>
|
|
</View>
|
|
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
|
|
<TextInput
|
|
style={s.input}
|
|
value={settings.shop_name}
|
|
onChangeText={(v) => setSettings((p) => ({ ...p, shop_name: v }))}
|
|
placeholder="Ex: Milieu-Nantais"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCorrect={false}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Amendes */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Amendes</Text>
|
|
<View style={[s.row, s.rowFirst]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Amendes activées</Text>
|
|
<Text style={s.rowDesc}>
|
|
Bloquer les commandes si le client a une amende non payée
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.penalties_enabled}
|
|
onValueChange={(v) =>
|
|
setSettings((prev) => ({ ...prev, penalties_enabled: v }))
|
|
}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
<View style={s.row}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Afficher le score d'amendes</Text>
|
|
<Text style={s.rowDesc}>
|
|
Les clients et la cabine voient le nombre d'amendes
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.show_amende_score}
|
|
onValueChange={(v) =>
|
|
setSettings((prev) => ({ ...prev, show_amende_score: v }))
|
|
}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
<View style={[s.row, { flexDirection: "column", alignItems: "flex-start", gap: spacing.s }]}>
|
|
<View style={{ flexDirection: "row", justifyContent: "space-between", width: "100%", alignItems: "center" }}>
|
|
<Text style={s.rowLabel}>Barème des amendes</Text>
|
|
<TouchableOpacity
|
|
onPress={() =>
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
penalty_tiers: [
|
|
...prev.penalty_tiers,
|
|
{
|
|
min_cancel: prev.penalty_tiers.length > 0
|
|
? prev.penalty_tiers[prev.penalty_tiers.length - 1].min_cancel + 1
|
|
: 0,
|
|
amount: 0,
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
>
|
|
<Ionicons name="add-circle-outline" size={22} color={colors.accent} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
<View style={{ flexDirection: "row", width: "100%", marginBottom: 2 }}>
|
|
<Text style={[s.rowDesc, { flex: 1 }]}>À partir de (annul.)</Text>
|
|
<Text style={[s.rowDesc, { width: inputMd, textAlign: "center" }]}>Montant</Text>
|
|
<View style={{ width: 28 }} />
|
|
</View>
|
|
{(settings.penalty_tiers ?? []).map((tier, i) => (
|
|
<View key={i} style={{ flexDirection: "row", alignItems: "center", width: "100%", gap: spacing.s }}>
|
|
<TextInput
|
|
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
|
|
value={String(tier.min_cancel)}
|
|
onChangeText={(v) => {
|
|
const n = parseInt(v, 10);
|
|
setSettings((prev) => {
|
|
const tiers = [...prev.penalty_tiers];
|
|
tiers[i] = { ...tiers[i], min_cancel: isNaN(n) ? 0 : n };
|
|
return { ...prev, penalty_tiers: tiers };
|
|
});
|
|
}}
|
|
keyboardType="number-pad"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: inputMd, textAlign: "center" }]}
|
|
value={String(tier.amount)}
|
|
onChangeText={(v) => {
|
|
const n = parseInt(v, 10);
|
|
setSettings((prev) => {
|
|
const tiers = [...prev.penalty_tiers];
|
|
tiers[i] = { ...tiers[i], amount: isNaN(n) ? 0 : n };
|
|
return { ...prev, penalty_tiers: tiers };
|
|
});
|
|
}}
|
|
keyboardType="number-pad"
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={() =>
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
penalty_tiers: prev.penalty_tiers.filter((_, j) => j !== i),
|
|
}))
|
|
}
|
|
>
|
|
<Ionicons name="remove-circle-outline" size={22} color={colors.danger} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Parrainage */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Parrainage</Text>
|
|
<View style={[s.row, s.rowFirst]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Parrainage activé</Text>
|
|
<Text style={s.rowDesc}>
|
|
Les clients peuvent voir et utiliser leur solde parrainage au checkout.{"\n"}
|
|
Désactivé : le solde reste en base mais ne peut plus être utilisé.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.referral_enabled}
|
|
onValueChange={(v) =>
|
|
setSettings((prev) => ({ ...prev, referral_enabled: v }))
|
|
}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Système de points */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Système de points</Text>
|
|
<View style={[s.row, s.rowFirst]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Points activés</Text>
|
|
<Text style={s.rowDesc}>
|
|
Les clients voient leurs scores de points.{"\n"}
|
|
La cabine peut réinitialiser les points.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.points_enabled}
|
|
onValueChange={(v) =>
|
|
setSettings((prev) => ({ ...prev, points_enabled: v }))
|
|
}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
|
|
{/* Gestion des types de points */}
|
|
<View
|
|
pointerEvents={settings.points_enabled ? "auto" : "none"}
|
|
style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.m, opacity: settings.points_enabled ? 1 : 0.4 }}
|
|
>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Types de points</Text>
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
|
Créez vos propres types de points. Le 1er type → colonne principale, le 2e → colonne secondaire.
|
|
</Text>
|
|
{pools.map((pool, i) => {
|
|
const color = POOL_COLORS[i % POOL_COLORS.length];
|
|
return (
|
|
<View key={pool.key} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
|
|
<View style={[s.colorDot, { backgroundColor: color, width: 12, height: 12, borderRadius: 6 }]} />
|
|
<TextInput
|
|
style={[s.thresholdInput, { flex: 1 }]}
|
|
value={pool.name}
|
|
onChangeText={(v) => renamePool(i, v)}
|
|
placeholder={`Type ${i + 1}`}
|
|
placeholderTextColor={colors.textMuted}
|
|
/>
|
|
<TouchableOpacity onPress={() => removePool(i)}>
|
|
<Ionicons name="trash-outline" size={18} color={colors.danger} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
})}
|
|
<TouchableOpacity
|
|
onPress={addPool}
|
|
style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.xs }}
|
|
>
|
|
<Ionicons name="add-circle-outline" size={18} color={colors.accent} />
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
|
|
Ajouter un type
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Attribution des catégories */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
|
|
<Text style={s.hint}>
|
|
Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"}
|
|
Vous pouvez modifier les attributions à tout moment.
|
|
</Text>
|
|
|
|
{/* Légende */}
|
|
<View style={{ flexDirection: "row", gap: spacing.m, paddingHorizontal: spacing.l, paddingBottom: spacing.m, flexWrap: "wrap" }}>
|
|
{pools.map((pool, i) => (
|
|
<View key={pool.key} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
|
<View style={[s.colorDot, { backgroundColor: POOL_COLORS[i % POOL_COLORS.length] }]} />
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>{pool.name}</Text>
|
|
</View>
|
|
))}
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
|
<View style={[s.colorDot, { backgroundColor: colors.border }]} />
|
|
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Aucun</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{categories.map((cat, index) => {
|
|
const assignedPoolIdx = getPoolIndexFor(cat.name);
|
|
return (
|
|
<View
|
|
key={cat.id}
|
|
style={[s.catRow, index === 0 && s.catRowFirst]}
|
|
>
|
|
<View
|
|
style={[
|
|
s.colorDot,
|
|
{ backgroundColor: cat.color || colors.accent },
|
|
]}
|
|
/>
|
|
<Text style={s.catName}>{cat.name}</Text>
|
|
<View style={s.chips}>
|
|
{pools.map((pool, pi) => {
|
|
const active = assignedPoolIdx === pi;
|
|
const chipColor = POOL_COLORS[pi % POOL_COLORS.length];
|
|
const label = pool.name.slice(0, 2).toUpperCase();
|
|
return (
|
|
<TouchableOpacity
|
|
key={pool.key}
|
|
style={[
|
|
s.chip,
|
|
{
|
|
borderColor: chipColor,
|
|
backgroundColor: active ? chipColor : "transparent",
|
|
},
|
|
]}
|
|
onPress={() => setCategoryPool(cat.name, active ? -1 : pi)}
|
|
>
|
|
<Text style={[s.chipText, { color: active ? "#fff" : chipColor }]}>
|
|
{label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
{/* Chip "Aucun" */}
|
|
<TouchableOpacity
|
|
style={[
|
|
s.chip,
|
|
{
|
|
borderColor: colors.textMuted,
|
|
backgroundColor: assignedPoolIdx === -1 ? colors.textMuted : "transparent",
|
|
},
|
|
]}
|
|
onPress={() => setCategoryPool(cat.name, -1)}
|
|
>
|
|
<Text style={[s.chipText, { color: assignedPoolIdx === -1 ? "#fff" : colors.textMuted }]}>
|
|
—
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
|
|
{categories.length === 0 && (
|
|
<Text style={[s.hint, { paddingTop: 0 }]}>
|
|
Aucune catégorie disponible
|
|
</Text>
|
|
)}
|
|
{pools.length === 0 && categories.length > 0 && (
|
|
<Text style={[s.hint, { paddingTop: 0 }]}>
|
|
Ajoutez au moins un type de point pour assigner des catégories.
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Barèmes de points — un par pool */}
|
|
{pools.map((pool, i) => (
|
|
<TiersSection
|
|
key={pool.key}
|
|
title={`Barème — ${pool.name}`}
|
|
tiers={pool.tiers ?? []}
|
|
onChange={(tiers) => updatePoolTiers(i, tiers)}
|
|
colors={colors}
|
|
s={s}
|
|
accentColor={POOL_COLORS[i % POOL_COLORS.length]}
|
|
active={true}
|
|
/>
|
|
))}
|
|
|
|
{/* Récompense centralisée par palier */}
|
|
<CentralRewardSection
|
|
reward={settings.points_reward ?? null}
|
|
allCategories={categories}
|
|
productsByCategory={productsByCategory}
|
|
onChange={(reward) => setSettings((p) => ({ ...p, points_reward: reward }))}
|
|
colors={colors}
|
|
s={s}
|
|
/>
|
|
|
|
{/* Horaires de livraison */}
|
|
<DeliveryScheduleSection
|
|
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
|
onChange={(sched) => setSettings((p) => ({ ...p, delivery_schedule: sched }))}
|
|
colors={colors}
|
|
s={s}
|
|
/>
|
|
|
|
{/* Zones de livraison */}
|
|
<PostalZonesSection
|
|
zones={settings.postal_zones ?? DEFAULT_POSTAL_ZONES}
|
|
onChange={(zones) => setSettings((p) => ({ ...p, postal_zones: zones }))}
|
|
colors={colors}
|
|
s={s}
|
|
/>
|
|
|
|
{/* Paiement crypto */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Paiement crypto</Text>
|
|
|
|
{/* Toggle activation */}
|
|
<View style={[s.row, s.rowFirst]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Paiement crypto activé</Text>
|
|
<Text style={s.rowDesc}>
|
|
Les clients peuvent payer leurs commandes en cryptomonnaie via NowPayments.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.crypto_payment_enabled}
|
|
onValueChange={(v) =>
|
|
setSettings((prev) => ({ ...prev, crypto_payment_enabled: v }))
|
|
}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
|
|
{/* Toggle crypto uniquement */}
|
|
<View
|
|
pointerEvents={settings.crypto_payment_enabled ? "auto" : "none"}
|
|
style={{ opacity: settings.crypto_payment_enabled ? 1 : 0.4 }}
|
|
>
|
|
<View style={[s.row, { borderTopWidth: 1, borderTopColor: colors.border }]}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Crypto uniquement</Text>
|
|
<Text style={s.rowDesc}>
|
|
Désactive le paiement en espèces — seule la crypto est acceptée.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.crypto_only}
|
|
onValueChange={(v) =>
|
|
setSettings((prev) => ({ ...prev, crypto_only: v }))
|
|
}
|
|
trackColor={{ false: colors.border, true: "#f7931a" }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Config NowPayments */}
|
|
<View
|
|
pointerEvents={settings.crypto_payment_enabled ? "auto" : "none"}
|
|
style={{ opacity: settings.crypto_payment_enabled ? 1 : 0.4, paddingHorizontal: spacing.l, paddingBottom: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.m, gap: spacing.m }}
|
|
>
|
|
{/* Clé API */}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Clé API NowPayments</Text>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<TextInput
|
|
style={[s.input, { flex: 1, fontFamily: "monospace" }]}
|
|
value={settings.nowpayments_api_key}
|
|
onChangeText={(v) => setSettings((p) => ({ ...p, nowpayments_api_key: v }))}
|
|
placeholder="Votre clé API NowPayments"
|
|
placeholderTextColor={colors.textSecondary}
|
|
secureTextEntry={!showApiKey}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<TouchableOpacity onPress={() => setShowApiKey((x) => !x)} style={{ padding: spacing.xs }}>
|
|
<Ionicons name={showApiKey ? "eye-off-outline" : "eye-outline"} size={20} color={colors.textSecondary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Secret IPN */}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Secret IPN</Text>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<TextInput
|
|
style={[s.input, { flex: 1, fontFamily: "monospace" }]}
|
|
value={settings.nowpayments_ipn_secret}
|
|
onChangeText={(v) => setSettings((p) => ({ ...p, nowpayments_ipn_secret: v }))}
|
|
placeholder="Secret IPN pour la vérification des webhooks"
|
|
placeholderTextColor={colors.textSecondary}
|
|
secureTextEntry={!showIpnSecret}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<TouchableOpacity onPress={() => setShowIpnSecret((x) => !x)} style={{ padding: spacing.xs }}>
|
|
<Ionicons name={showIpnSecret ? "eye-off-outline" : "eye-outline"} size={20} color={colors.textSecondary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={[s.rowDesc, { marginTop: spacing.xs }]}>
|
|
URL IPN à configurer sur NowPayments :{"\n"}
|
|
<Text style={{ color: colors.accent, fontSize: fontSize.xs }}>
|
|
https://5.181.0.112.nip.io/api/v1/webhooks/nowpayments
|
|
</Text>
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Cryptos acceptées */}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Cryptomonnaies acceptées</Text>
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
|
|
Sélectionnez les cryptos que vos clients peuvent utiliser pour payer.
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
|
|
{CRYPTO_CURRENCIES.map((coin) => {
|
|
const selected = (settings.nowpayments_currencies ?? []).includes(coin.id);
|
|
return (
|
|
<TouchableOpacity
|
|
key={coin.id}
|
|
onPress={() => {
|
|
setSettings((prev) => {
|
|
const current = prev.nowpayments_currencies ?? [];
|
|
const updated = selected
|
|
? current.filter((c) => c !== coin.id)
|
|
: [...current, coin.id];
|
|
return { ...prev, nowpayments_currencies: updated };
|
|
});
|
|
}}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.m,
|
|
paddingVertical: spacing.s,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 1.5,
|
|
borderColor: selected ? coin.color : colors.border,
|
|
backgroundColor: selected ? coin.color + "22" : colors.bgSecondary,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 16 }}>{coin.icon}</Text>
|
|
<Text style={{ fontSize: fontSize.sm, fontWeight: "600", color: selected ? coin.color : colors.textSecondary }}>
|
|
{coin.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
{(settings.nowpayments_currencies ?? []).length === 0 && (
|
|
<Text style={[s.rowDesc, { color: "#ef4444", marginTop: spacing.s }]}>
|
|
⚠ Sélectionnez au moins une cryptomonnaie.
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* ============================================ */}
|
|
{/* 🤖 TELEGRAM */}
|
|
{/* ============================================ */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Notifications Telegram</Text>
|
|
<View style={s.row}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Notifications activées</Text>
|
|
<Text style={s.rowDesc}>Envoyer les notifications via Telegram aux utilisateurs qui ont lié leur compte</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.telegram_notifications_enabled}
|
|
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_notifications_enabled: v }))}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
<View style={s.row}>
|
|
<View style={s.rowLeft}>
|
|
<Text style={s.rowLabel}>Authentification 2FA</Text>
|
|
<Text style={s.rowDesc}>Permettre aux clients d'activer la double authentification via Telegram lors de la connexion</Text>
|
|
</View>
|
|
<Switch
|
|
value={settings.telegram_2fa_enabled}
|
|
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))}
|
|
trackColor={{ false: colors.border, true: colors.accent }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
|
|
<Text style={s.rowDesc}>
|
|
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
|
|
</Text>
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Token du bot</Text>
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
|
|
Obtenu via @BotFather avec la commande /newbot
|
|
</Text>
|
|
<TextInput
|
|
style={[s.input, { fontFamily: "monospace" }]}
|
|
value={settings.telegram_bot_token}
|
|
onChangeText={(v) => setSettings((p) => ({ ...p, telegram_bot_token: v }))}
|
|
placeholder="123456:ABCdefGHI..."
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
secureTextEntry={true}
|
|
/>
|
|
</View>
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Username du bot</Text>
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
|
|
Sans le @, ex : MonBotNotifications
|
|
</Text>
|
|
<TextInput
|
|
style={s.input}
|
|
value={settings.telegram_bot_username}
|
|
onChangeText={(v) => setSettings((p) => ({ ...p, telegram_bot_username: v }))}
|
|
placeholder="MonBotNotifications"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
/>
|
|
</View>
|
|
{settings.telegram_bot_token !== "" && settings.telegram_bot_username !== "" && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.m, backgroundColor: "#10b98122", borderRadius: borderRadius.sm }}>
|
|
<Ionicons name="checkmark-circle" size={16} color="#10b981" />
|
|
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré — @{settings.telegram_bot_username}</Text>
|
|
</View>
|
|
)}
|
|
<View>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Contact SAV Telegram</Text>
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
|
|
Username du compte Telegram SAV (sans le @). Utilisé pour les boutons de contact client.
|
|
</Text>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
|
<Text style={{ fontSize: fontSize.md, color: colors.textMuted }}>@</Text>
|
|
<TextInput
|
|
style={[s.input, { flex: 1 }]}
|
|
value={settings.contact_telegram}
|
|
onChangeText={(v) => setSettings((p) => ({ ...p, contact_telegram: v }))}
|
|
placeholder="MonSAV"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
</View>
|
|
{settings.contact_telegram !== "" && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.s, padding: spacing.m, backgroundColor: "#0088cc22", borderRadius: borderRadius.sm }}>
|
|
<Ionicons name="paper-plane-outline" size={16} color="#0088cc" />
|
|
<Text style={{ color: "#0088cc", fontSize: fontSize.sm }}>@{settings.contact_telegram}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
</View>
|
|
</View>
|
|
|
|
{/* ============================================ */}
|
|
{/* 🚚 MODE DE LIVRAISON */}
|
|
{/* ============================================ */}
|
|
<View style={s.section}>
|
|
<Text style={s.sectionTitle}>Mode de livraison</Text>
|
|
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
|
|
Choisissez comment les commandes sont assignées aux livreurs.
|
|
</Text>
|
|
|
|
{/* Sélection du mode */}
|
|
<View style={{ flexDirection: "row", gap: spacing.m, paddingHorizontal: spacing.l, paddingTop: spacing.m, paddingBottom: spacing.s }}>
|
|
{(["single", "category_based"] as const).map((mode) => {
|
|
const selected = settings.delivery_mode.mode === mode;
|
|
const label = mode === "single" ? "Livreur unique" : "Par catégorie";
|
|
const desc = mode === "single" ? "Toutes les commandes → même livreur" : "Chaque livreur gère ses catégories";
|
|
return (
|
|
<TouchableOpacity
|
|
key={mode}
|
|
onPress={() => setSettings((p) => ({
|
|
...p,
|
|
delivery_mode: { ...p.delivery_mode, mode },
|
|
}))}
|
|
style={{
|
|
flex: 1,
|
|
padding: spacing.m,
|
|
borderRadius: borderRadius.sm,
|
|
borderWidth: 2,
|
|
borderColor: selected ? "#10b981" : colors.border,
|
|
backgroundColor: selected ? "#10b98118" : colors.bgSecondary,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: fontSize.sm, fontWeight: "700", color: selected ? "#10b981" : colors.textPrimary }}>{label}</Text>
|
|
<Text style={{ fontSize: fontSize.xs, color: colors.textSecondary, marginTop: 2 }}>{desc}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Configuration des routes par catégorie */}
|
|
{settings.delivery_mode.mode === "category_based" && (
|
|
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
|
|
<Text style={[s.rowLabel, { marginBottom: spacing.s, marginTop: spacing.m }]}>
|
|
Assignation livreur → catégories
|
|
</Text>
|
|
|
|
{settings.delivery_mode.category_routes.map((route, idx) => (
|
|
<View key={idx} style={{ backgroundColor: colors.bgSecondary, borderRadius: borderRadius.sm, padding: spacing.m, marginBottom: spacing.m, borderWidth: 1, borderColor: colors.border }}>
|
|
{/* Sélection livreur */}
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>Livreur</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs, marginBottom: spacing.m }}>
|
|
{livreurs.length === 0 ? (
|
|
<Text style={[s.rowDesc, { fontStyle: "italic" }]}>Aucun livreur actif</Text>
|
|
) : livreurs.map((username) => {
|
|
const sel = route.deliveryman_username === username;
|
|
return (
|
|
<TouchableOpacity
|
|
key={username}
|
|
onPress={() => setSettings((p) => {
|
|
const routes = [...p.delivery_mode.category_routes];
|
|
routes[idx] = { ...routes[idx], deliveryman_username: username };
|
|
return { ...p, delivery_mode: { ...p.delivery_mode, category_routes: routes } };
|
|
})}
|
|
style={{ paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.full, borderWidth: 1.5, borderColor: sel ? "#10b981" : colors.border, backgroundColor: sel ? "#10b98122" : "transparent" }}
|
|
>
|
|
<Text style={{ fontSize: fontSize.sm, color: sel ? "#10b981" : colors.textPrimary, fontWeight: sel ? "600" : "400" }}>{username}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Sélection catégories */}
|
|
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>Catégories assignées</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
|
{categories.map((cat) => {
|
|
const sel = route.categories.includes(cat.name);
|
|
return (
|
|
<TouchableOpacity
|
|
key={cat.name}
|
|
onPress={() => setSettings((p) => {
|
|
const routes = [...p.delivery_mode.category_routes];
|
|
const cats = routes[idx].categories.includes(cat.name)
|
|
? routes[idx].categories.filter((c) => c !== cat.name)
|
|
: [...routes[idx].categories, cat.name];
|
|
routes[idx] = { ...routes[idx], categories: cats };
|
|
return { ...p, delivery_mode: { ...p.delivery_mode, category_routes: routes } };
|
|
})}
|
|
style={{ paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.full, borderWidth: 1.5, borderColor: sel ? "#3b82f6" : colors.border, backgroundColor: sel ? "#3b82f622" : "transparent" }}
|
|
>
|
|
<Text style={{ fontSize: fontSize.sm, color: sel ? "#3b82f6" : colors.textPrimary, fontWeight: sel ? "600" : "400" }}>{cat.name}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Supprimer route */}
|
|
<TouchableOpacity
|
|
onPress={() => setSettings((p) => {
|
|
const routes = p.delivery_mode.category_routes.filter((_, i) => i !== idx);
|
|
return { ...p, delivery_mode: { ...p.delivery_mode, category_routes: routes } };
|
|
})}
|
|
style={{ marginTop: spacing.m, flexDirection: "row", alignItems: "center", gap: spacing.xs }}
|
|
>
|
|
<Ionicons name="trash-outline" size={14} color="#ef4444" />
|
|
<Text style={{ fontSize: fontSize.sm, color: "#ef4444" }}>Supprimer cette règle</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
))}
|
|
|
|
<TouchableOpacity
|
|
onPress={() => setSettings((p) => ({
|
|
...p,
|
|
delivery_mode: {
|
|
...p.delivery_mode,
|
|
category_routes: [...p.delivery_mode.category_routes, { deliveryman_username: "", categories: [] }],
|
|
},
|
|
}))}
|
|
style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, paddingVertical: spacing.m }}
|
|
>
|
|
<Ionicons name="add-circle-outline" size={18} color="#10b981" />
|
|
<Text style={{ fontSize: fontSize.sm, color: "#10b981", fontWeight: "600" }}>Ajouter une règle</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={s.saveButton}
|
|
onPress={handleSave}
|
|
disabled={saving}
|
|
>
|
|
{saving ? (
|
|
<ActivityIndicator color="#fff" size="small" />
|
|
) : (
|
|
<>
|
|
<Ionicons name="save-outline" size={18} color="#fff" />
|
|
<Text style={s.saveButtonText}>Sauvegarder</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
|
|
<AlertModal
|
|
visible={alert.visible}
|
|
type={alert.type}
|
|
title={alert.title}
|
|
message={alert.message}
|
|
onClose={hideAlert}
|
|
onConfirm={alert.onConfirm}
|
|
confirmText={alert.confirmText}
|
|
cancelText={alert.cancelText}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|