chore: fix save
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user