1308 lines
62 KiB
TypeScript
1308 lines
62 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
Switch,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
TextInput,
|
|
Keyboard,
|
|
} 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, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
|
import type { AppSettings, Category, PointsTier, PointsPool, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
|
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 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: 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: 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 [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: 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: 48 }]}>Min €</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: 80 }]}
|
|
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: 100, 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 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: 56, textAlign: "center" }]}>Min €</Text>
|
|
<Text style={[s.thresholdSep, { width: 56, textAlign: "center" }]}>Max €</Text>
|
|
<Text style={[s.thresholdSep, { width: 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: 56 }]}
|
|
keyboardType="decimal-pad"
|
|
value={String(tier.min)}
|
|
onChangeText={(v) => updateTier(i, "min", v)}
|
|
/>
|
|
<Text style={s.thresholdSep}>→</Text>
|
|
<TextInput
|
|
style={[s.thresholdInput, { width: 56 }]}
|
|
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: 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>
|
|
);
|
|
}
|
|
|
|
export default function SettingsScreen() {
|
|
const { colors } = useTheme();
|
|
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
|
const navigation = useNavigation();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [settings, setSettings] = useState<AppSettings>({
|
|
penalties_enabled: true,
|
|
show_amende_score: true,
|
|
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,
|
|
nowpayments_api_key: "",
|
|
nowpayments_ipn_secret: "",
|
|
nowpayments_currencies: [],
|
|
});
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
|
|
// 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;
|
|
const [settingsRes, categoriesRes] = await Promise.all([
|
|
getSettings(),
|
|
getCategories(),
|
|
]);
|
|
if (settingsRes.success && settingsRes.settings) {
|
|
setSettings({
|
|
...settingsRes.settings,
|
|
points_pools: settingsRes.settings.points_pools ?? [],
|
|
delivery_schedule: settingsRes.settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
|
postal_zones: settingsRes.settings.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
|
});
|
|
}
|
|
if (categoriesRes) {
|
|
setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
|
|
}
|
|
setLoading(false);
|
|
// Marquer comme chargé après un tick pour que le useEffect de settings ne
|
|
// considère pas le setSettings initial comme une modification utilisateur
|
|
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: 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}>
|
|
{/* 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>
|
|
|
|
{/* 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}
|
|
/>
|
|
))}
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
|
|
<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>
|
|
);
|
|
}
|