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 {
|
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");
|
||||||
|
|||||||
Reference in New Issue
Block a user