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 {
View,
Text,
@@ -8,7 +8,9 @@ import {
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";
@@ -122,6 +124,17 @@ function DeliveryScheduleSection({
// ──────────────────────────────────────────────────────────────
// 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,
@@ -134,12 +147,9 @@ function PostalZonesSection({
s: any;
}) {
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(null);
const addZone = () => {
const next = [...zones, { name: "Nouvelle zone", min_amount: 0, codes: [] }];
onChange(next);
setExpandedIndex(next.length - 1);
};
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));
@@ -150,18 +160,26 @@ function PostalZonesSection({
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) => {
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>
@@ -204,7 +222,7 @@ function PostalZonesSection({
</TouchableOpacity>
</TouchableOpacity>
{/* Détail zone */}
{/* Détail zone (édition) */}
{expanded && (
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.m }}>
{/* Nom */}
@@ -233,8 +251,10 @@ function PostalZonesSection({
placeholderTextColor={colors.textMuted}
/>
</View>
{/* Codes postaux */}
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>Codes postaux :</Text>
{/* 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
@@ -256,85 +276,217 @@ function PostalZonesSection({
<Ionicons name="close" size={12} color={colors.accent} />
</TouchableOpacity>
))}
<PostalCodeInput
onAdd={(code) => addCode(i, code)}
colors={colors}
s={s}
/>
</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 && (
{zones.length === 0 && !creating && (
<Text style={[s.hint, { paddingTop: 0 }]}>Aucune zone définie — toutes les commandes seront acceptées sans minimum.</Text>
)}
<TouchableOpacity
onPress={addZone}
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>
{/* 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 PostalCodeInput({
function BulkCodesInput({
onAdd,
colors,
s,
}: {
onAdd: (code: string) => void;
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={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<TextInput
style={[s.thresholdInput, { width: 72, fontSize: fontSize.sm }]}
value={value}
onChangeText={setValue}
placeholder="44XXX"
placeholderTextColor={colors.textMuted}
keyboardType="number-pad"
maxLength={5}
onSubmitEditing={() => {
if (value.length === 5) {
onAdd(value);
setValue("");
}
}}
/>
<TouchableOpacity
onPress={() => {
if (value.length === 5) {
onAdd(value);
setValue("");
}
}}
style={{
backgroundColor: colors.accent,
borderRadius: borderRadius.sm,
padding: 6,
}}
>
<Ionicons name="add" size={14} color="#fff" />
</TouchableOpacity>
<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>
);
}
@@ -474,6 +626,7 @@ function TiersSection({
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);
@@ -494,8 +647,33 @@ export default function SettingsScreen() {
});
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(),
@@ -515,6 +693,9 @@ export default function SettingsScreen() {
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(() => {
@@ -541,10 +722,15 @@ export default function SettingsScreen() {
};
const handleSave = async () => {
// Fermer le clavier pour s'assurer que les TextInputs commitent leur valeur
Keyboard.dismiss();
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);
if (res.success) {
isDirty.current = false;
showSuccess("Succès", "Paramètres sauvegardés");
} else {
showError("Erreur", res.error || "Erreur lors de la sauvegarde");