chore: fix save

This commit is contained in:
2026-03-09 19:40:28 +01:00
parent 0a144cd07b
commit 7c04e61eb2
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { import {
View, View,
Text, Text,
@@ -8,7 +8,9 @@ import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
TextInput, TextInput,
Keyboard,
} from "react-native"; } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme"; import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
@@ -122,6 +124,17 @@ function DeliveryScheduleSection({
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Composant éditeur de zones postales // 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({ function PostalZonesSection({
zones, zones,
onChange, onChange,
@@ -134,12 +147,9 @@ function PostalZonesSection({
s: any; s: any;
}) { }) {
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(null); const [expandedIndex, setExpandedIndex] = React.useState<number | null>(null);
const [creating, setCreating] = React.useState(false);
const addZone = () => { const [form, setForm] = React.useState<NewZoneForm>(EMPTY_FORM);
const next = [...zones, { name: "Nouvelle zone", min_amount: 0, codes: [] }]; const [formError, setFormError] = React.useState("");
onChange(next);
setExpandedIndex(next.length - 1);
};
const removeZone = (i: number) => { const removeZone = (i: number) => {
onChange(zones.filter((_, idx) => idx !== i)); onChange(zones.filter((_, idx) => idx !== i));
@@ -150,18 +160,26 @@ function PostalZonesSection({
onChange(zones.map((z, idx) => (idx === i ? { ...z, ...patch } : z))); onChange(zones.map((z, idx) => (idx === i ? { ...z, ...patch } : z)));
}; };
const addCode = (i: number, code: string) => {
const trimmed = code.trim();
if (!trimmed || zones[i].codes.includes(trimmed)) return;
updateZone(i, { codes: [...zones[i].codes, trimmed] });
};
const removeCode = (zoneIdx: number, codeIdx: number) => { const removeCode = (zoneIdx: number, codeIdx: number) => {
updateZone(zoneIdx, { updateZone(zoneIdx, {
codes: zones[zoneIdx].codes.filter((_, idx) => idx !== codeIdx), 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 ( return (
<View style={s.section}> <View style={s.section}>
<Text style={s.sectionTitle}>Zones de livraison</Text> <Text style={s.sectionTitle}>Zones de livraison</Text>
@@ -204,7 +222,7 @@ function PostalZonesSection({
</TouchableOpacity> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
{/* Détail zone */} {/* Détail zone (édition) */}
{expanded && ( {expanded && (
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.m }}> <View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.m }}>
{/* Nom */} {/* Nom */}
@@ -233,8 +251,10 @@ function PostalZonesSection({
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
/> />
</View> </View>
{/* Codes postaux */} {/* Codes postaux — chips supprimables */}
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>Codes postaux :</Text> <Text style={[s.thresholdSep, { marginBottom: 0 }]}>
Codes postaux ({zone.codes.length}) — appuyer pour supprimer :
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}> <View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{zone.codes.map((code, ci) => ( {zone.codes.map((code, ci) => (
<TouchableOpacity <TouchableOpacity
@@ -256,85 +276,217 @@ function PostalZonesSection({
<Ionicons name="close" size={12} color={colors.accent} /> <Ionicons name="close" size={12} color={colors.accent} />
</TouchableOpacity> </TouchableOpacity>
))} ))}
<PostalCodeInput
onAdd={(code) => addCode(i, code)}
colors={colors}
s={s}
/>
</View> </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>
)} )}
</View> </View>
); );
})} })}
{zones.length === 0 && ( {zones.length === 0 && !creating && (
<Text style={[s.hint, { paddingTop: 0 }]}>Aucune zone définie — toutes les commandes seront acceptées sans minimum.</Text> <Text style={[s.hint, { paddingTop: 0 }]}>Aucune zone définie — toutes les commandes seront acceptées sans minimum.</Text>
)} )}
<TouchableOpacity {/* Formulaire de création */}
onPress={addZone} {creating && (
style={{ <View style={{ paddingHorizontal: spacing.l, paddingVertical: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, gap: spacing.m }}>
flexDirection: "row", <Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Nouvelle zone</Text>
alignItems: "center",
gap: spacing.xs, {/* Nom */}
paddingHorizontal: spacing.l, <View style={{ gap: 4 }}>
paddingVertical: spacing.m, <Text style={s.thresholdSep}>Nom de la zone *</Text>
borderTopWidth: 1, <TextInput
borderTopColor: colors.border, style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m }]}
}} value={form.name}
> onChangeText={(v) => { setForm((f) => ({ ...f, name: v })); setFormError(""); }}
<Ionicons name="add-circle-outline" size={18} color={colors.accent} /> placeholder="Ex: Zone centre-ville"
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}> placeholderTextColor={colors.textMuted}
Ajouter une zone autoFocus
</Text> />
</TouchableOpacity> </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> </View>
); );
} }
function PostalCodeInput({ function BulkCodesInput({
onAdd, onAdd,
colors, colors,
s, s,
}: { }: {
onAdd: (code: string) => void; onAdd: (codes: string[]) => void;
colors: any; colors: any;
s: any; s: any;
}) { }) {
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
const parsed = parsePostalCodes(value);
const handleAdd = () => {
if (parsed.length === 0) return;
onAdd(parsed);
setValue("");
};
return ( return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}> <View style={{ gap: spacing.xs }}>
<TextInput <Text style={[s.thresholdSep, { marginBottom: 0 }]}>Ajouter des codes (virgules ou espaces) :</Text>
style={[s.thresholdInput, { width: 72, fontSize: fontSize.sm }]} <View style={{ flexDirection: "row", alignItems: "flex-end", gap: spacing.s }}>
value={value} <TextInput
onChangeText={setValue} style={[
placeholder="44XXX" s.thresholdInput,
placeholderTextColor={colors.textMuted} {
keyboardType="number-pad" flex: 1,
maxLength={5} width: undefined,
onSubmitEditing={() => { textAlign: "left",
if (value.length === 5) { paddingHorizontal: spacing.m,
onAdd(value); paddingVertical: spacing.s,
setValue(""); height: 60,
} textAlignVertical: "top",
}} },
/> ]}
<TouchableOpacity value={value}
onPress={() => { onChangeText={setValue}
if (value.length === 5) { placeholder={"44500, 44600 44700"}
onAdd(value); placeholderTextColor={colors.textMuted}
setValue(""); multiline
} keyboardType="numbers-and-punctuation"
}} />
style={{ <TouchableOpacity
backgroundColor: colors.accent, onPress={handleAdd}
borderRadius: borderRadius.sm, style={{
padding: 6, backgroundColor: parsed.length > 0 ? colors.accent : colors.border,
}} borderRadius: borderRadius.sm,
> padding: spacing.s,
<Ionicons name="add" size={14} color="#fff" /> alignItems: "center",
</TouchableOpacity> 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> </View>
); );
} }
@@ -474,6 +626,7 @@ function TiersSection({
export default function SettingsScreen() { export default function SettingsScreen() {
const { colors } = useTheme(); const { colors } = useTheme();
const { alert, showError, showSuccess, hideAlert } = useAlert(); const { alert, showError, showSuccess, hideAlert } = useAlert();
const navigation = useNavigation();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -494,8 +647,33 @@ export default function SettingsScreen() {
}); });
const [categories, setCategories] = useState<Category[]>([]); 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 () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
isLoaded.current = false;
const [settingsRes, categoriesRes] = await Promise.all([ const [settingsRes, categoriesRes] = await Promise.all([
getSettings(), getSettings(),
getCategories(), getCategories(),
@@ -515,6 +693,9 @@ export default function SettingsScreen() {
setCategories(categoriesRes.filter((c) => !c.is_coming_soon)); setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
} }
setLoading(false); 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(() => { useEffect(() => {
@@ -541,10 +722,15 @@ export default function SettingsScreen() {
}; };
const handleSave = async () => { const handleSave = async () => {
// Fermer le clavier pour s'assurer que les TextInputs commitent leur valeur
Keyboard.dismiss();
setSaving(true); setSaving(true);
const res = await updateSettings(settings); // Utiliser le ref pour avoir la valeur la plus à jour (post-blur)
const current = settingsRef.current;
const res = await updateSettings(current);
setSaving(false); setSaving(false);
if (res.success) { if (res.success) {
isDirty.current = false;
showSuccess("Succès", "Paramètres sauvegardés"); showSuccess("Succès", "Paramètres sauvegardés");
} else { } else {
showError("Erreur", res.error || "Erreur lors de la sauvegarde"); showError("Erreur", res.error || "Erreur lors de la sauvegarde");