chore: build
This commit is contained in:
@@ -15,8 +15,9 @@ 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, getAvailableDeliveryPersons, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
||||
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
||||
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, RewardItem, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||
import type { Product } from "../../api/types";
|
||||
import AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
@@ -45,6 +46,51 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [
|
||||
{ key: "sunday", label: "Dimanche" },
|
||||
];
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Composant section accordéon générique
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function AccordionSection({
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
title: string;
|
||||
badge?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setOpen((o) => !o)}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingVertical: spacing.m,
|
||||
}}
|
||||
>
|
||||
<Text style={[s.sectionTitle, { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 }]}>{title}</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||
{badge}
|
||||
<Ionicons name={open ? "chevron-up" : "chevron-down"} size={18} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{open && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||
{children}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryScheduleSection({
|
||||
schedule,
|
||||
onChange,
|
||||
@@ -62,8 +108,7 @@ function DeliveryScheduleSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Horaires de livraison</Text>
|
||||
<AccordionSection title="Horaires de livraison" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||
Définissez les jours et horaires d'ouverture pour la livraison.
|
||||
</Text>
|
||||
@@ -132,7 +177,7 @@ function DeliveryScheduleSection({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -199,8 +244,7 @@ function PostalZonesSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Zones de livraison</Text>
|
||||
<AccordionSection title="Zones de livraison" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||
Définissez les zones de livraison et leur montant minimum de commande.
|
||||
</Text>
|
||||
@@ -435,7 +479,7 @@ function PostalZonesSection({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -546,24 +590,20 @@ function TiersSection({
|
||||
onChange(tiers.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
const badge = (
|
||||
<View style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||
backgroundColor: active ? accentColor + "25" : colors.border + "40",
|
||||
borderWidth: 1, borderColor: active ? accentColor : colors.border,
|
||||
}}>
|
||||
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
|
||||
{active ? "Actif" : "Ignoré"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
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>
|
||||
<AccordionSection title={title} badge={badge} colors={colors} s={s}>
|
||||
<Text style={s.hint}>
|
||||
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
|
||||
Max = 0 signifie illimité (pas de borne supérieure).
|
||||
@@ -639,10 +679,482 @@ function TiersSection({
|
||||
Ajouter un palier
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
const REWARD_ACCENT = "#f59e0b";
|
||||
|
||||
const REWARD_TYPES: { value: PointsReward["type"]; label: string; icon: string }[] = [
|
||||
{ value: "free_product", label: "Produit offert", icon: "gift-outline" },
|
||||
{ value: "half_price_product", label: "Produit à -50%", icon: "pricetag-outline" },
|
||||
];
|
||||
|
||||
const EMPTY_REWARD: PointsReward = {
|
||||
threshold: 20,
|
||||
type: "free_product",
|
||||
description: "",
|
||||
category_configs: [],
|
||||
reward_items: [],
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Sélecteur de produits pour une catégorie dans la récompense
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function CategoryProductPicker({
|
||||
catConfig,
|
||||
products,
|
||||
onChange,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
catConfig: RewardCategoryConfig;
|
||||
products: Product[];
|
||||
onChange: (cfg: RewardCategoryConfig) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const catProducts = products.filter((p) => p.category === catConfig.category);
|
||||
|
||||
const toggleProduct = (id: number) => {
|
||||
const ids = catConfig.product_ids.includes(id)
|
||||
? catConfig.product_ids.filter((x) => x !== id)
|
||||
: [...catConfig.product_ids, id];
|
||||
onChange({ ...catConfig, product_ids: ids, all_products: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
||||
{/* Toggle tous / sélection */}
|
||||
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange({ ...catConfig, all_products: true, product_ids: [] })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||
borderColor: catConfig.all_products ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
|
||||
Tous ({catProducts.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange({ ...catConfig, all_products: false })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||
borderColor: !catConfig.all_products ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: !catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
|
||||
Sélection
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Liste des produits si mode sélection */}
|
||||
{!catConfig.all_products && (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||
{catProducts.length === 0 ? (
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
|
||||
Aucun produit dans cette catégorie
|
||||
</Text>
|
||||
) : catProducts.map((p) => {
|
||||
const sel = catConfig.product_ids.includes(p.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={p.id}
|
||||
onPress={() => toggleProduct(p.id)}
|
||||
style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
|
||||
flexDirection: "row", alignItems: "center", gap: 4,
|
||||
}}
|
||||
>
|
||||
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
|
||||
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{p.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Section centralisée récompenses par palier
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function CentralRewardSection({
|
||||
reward,
|
||||
allCategories,
|
||||
productsByCategory,
|
||||
onChange,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
reward: PointsReward | null | undefined;
|
||||
allCategories: Category[];
|
||||
productsByCategory: Record<string, Product[]>;
|
||||
onChange: (reward: PointsReward | null) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const enabled = !!reward;
|
||||
const rawR = reward ?? EMPTY_REWARD;
|
||||
const r: PointsReward = { ...rawR, category_configs: rawR.category_configs ?? [] };
|
||||
|
||||
const update = (patch: Partial<PointsReward>) =>
|
||||
onChange({ ...r, ...patch });
|
||||
|
||||
const getCatConfig = (catName: string): RewardCategoryConfig =>
|
||||
r.category_configs.find((c) => c.category === catName) ??
|
||||
{ category: catName, all_products: true, product_ids: [] };
|
||||
|
||||
const isCatSelected = (catName: string) =>
|
||||
r.category_configs.some((c) => c.category === catName);
|
||||
|
||||
const toggleCategory = (catName: string) => {
|
||||
if (isCatSelected(catName)) {
|
||||
update({ category_configs: r.category_configs.filter((c) => c.category !== catName) });
|
||||
} else {
|
||||
update({ category_configs: [...r.category_configs, { category: catName, all_products: true, product_ids: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
const updateCatConfig = (cfg: RewardCategoryConfig) => {
|
||||
update({
|
||||
category_configs: r.category_configs.map((c) =>
|
||||
c.category === cfg.category ? cfg : c
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const rewardBadge = (
|
||||
<View style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||
backgroundColor: enabled ? REWARD_ACCENT + "25" : colors.border + "40",
|
||||
borderWidth: 1, borderColor: enabled ? REWARD_ACCENT : colors.border,
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{enabled ? "Activée" : "Désactivée"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionSection title="Récompenses par palier" badge={rewardBadge} colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Récompense activée</Text>
|
||||
<Text style={s.rowDesc}>
|
||||
Le seuil s'applique indépendamment à chaque type de points.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={(v) => onChange(v ? EMPTY_REWARD : null)}
|
||||
trackColor={{ false: colors.border, true: REWARD_ACCENT }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{enabled && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
|
||||
|
||||
{/* Seuil */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.rowLabel}>Seuil de points</Text>
|
||||
<Text style={s.rowDesc}>
|
||||
Dès X points cumulés dans un type, la récompense est débloquée pour ce type.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: screenWidth < 380 ? 52 : 64 }]}
|
||||
keyboardType="number-pad"
|
||||
value={String(r.threshold)}
|
||||
onChangeText={(v) => { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) update({ threshold: n }); }}
|
||||
/>
|
||||
<Text style={s.thresholdSep}>pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Type */}
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Type de récompense</Text>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
|
||||
{REWARD_TYPES.map((rt) => {
|
||||
const sel = r.type === rt.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={rt.value}
|
||||
onPress={() => update({ type: rt.value })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: sel ? REWARD_ACCENT + "20" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons name={rt.icon as any} size={14} color={sel ? REWARD_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 13, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{rt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Description affichée au client</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m, paddingVertical: spacing.s, height: 72, textAlignVertical: "top" }]}
|
||||
value={r.description}
|
||||
onChangeText={(v) => update({ description: v })}
|
||||
placeholder="Ex : Un produit 30€ de ton choix parmi les produits conditionnés en 30€"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Produits récompense — uniquement pour le type "Produit offert" */}
|
||||
{r.type === "free_product" && (
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Produits ajoutés au panier</Text>
|
||||
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
||||
Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier (gratuits). Il doit commander au moins un produit normal.
|
||||
</Text>
|
||||
<View style={{ gap: spacing.s }}>
|
||||
{r.reward_items.map((item, idx) => {
|
||||
const allProds = Object.values(productsByCategory).flat();
|
||||
const prod = allProds.find((p) => p.id === item.product_id);
|
||||
return (
|
||||
<View
|
||||
key={idx}
|
||||
style={{
|
||||
borderWidth: 1, borderColor: REWARD_ACCENT + "44",
|
||||
borderRadius: borderRadius.sm, padding: spacing.m,
|
||||
backgroundColor: REWARD_ACCENT + "08", gap: spacing.s,
|
||||
}}
|
||||
>
|
||||
{/* Sélecteur produit */}
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||
{allProds.map((p) => {
|
||||
const sel = item.product_id === p.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={p.id}
|
||||
onPress={() => {
|
||||
const updated = r.reward_items.map((it, i) =>
|
||||
i === idx ? { ...it, product_id: p.id } : it
|
||||
);
|
||||
update({ reward_items: updated });
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: 4,
|
||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
|
||||
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{p.name} ({p.category})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Quantité + Prix + Supprimer */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted }}>Qté :</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
|
||||
keyboardType="decimal-pad"
|
||||
value={item.quantity > 0 ? String(item.quantity) : ""}
|
||||
onChangeText={(v) => {
|
||||
const n = parseFloat(v);
|
||||
const updated = r.reward_items.map((it, i) =>
|
||||
i === idx ? { ...it, quantity: isNaN(n) ? 0 : n } : it
|
||||
);
|
||||
update({ reward_items: updated });
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted }}>Prix :</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
|
||||
keyboardType="decimal-pad"
|
||||
value={item.price > 0 ? String(item.price) : ""}
|
||||
onChangeText={(v) => {
|
||||
const n = parseFloat(v);
|
||||
const updated = r.reward_items.map((it, i) =>
|
||||
i === idx ? { ...it, price: isNaN(n) ? 0 : n } : it
|
||||
);
|
||||
update({ reward_items: updated });
|
||||
}}
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted }}>€</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => update({ reward_items: r.reward_items.filter((_, i) => i !== idx) })}
|
||||
style={{ padding: spacing.xs }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{prod && (
|
||||
<Text style={{ fontSize: 11, color: REWARD_ACCENT, fontStyle: "italic" }}>
|
||||
{prod.name}{item.quantity > 0 ? ` · x${item.quantity}` : ""}{item.price > 0 ? ` · ${item.price}€` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{!prod && (
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
|
||||
Sélectionnez un produit ci-dessus
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{Object.values(productsByCategory).flat().length === 0 ? (
|
||||
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucun produit disponible</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => update({ reward_items: [...r.reward_items, { product_id: 0, quantity: 1, price: 0 }] })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: REWARD_ACCENT + "66",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={16} color={REWARD_ACCENT} />
|
||||
<Text style={{ fontSize: 13, color: REWARD_ACCENT, fontWeight: "600" }}>Ajouter un produit récompense</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Catégories éligibles — uniquement pour le type "Produit à -50%" */}
|
||||
{r.type === "half_price_product" && (
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles à -50%</Text>
|
||||
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
||||
Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection.
|
||||
</Text>
|
||||
{allCategories.length === 0 ? (
|
||||
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
|
||||
) : (
|
||||
<View style={{ gap: spacing.m }}>
|
||||
{allCategories.map((cat) => {
|
||||
const selected = isCatSelected(cat.name);
|
||||
const catColor = cat.color || REWARD_ACCENT;
|
||||
return (
|
||||
<View key={cat.name}>
|
||||
{/* Chip catégorie */}
|
||||
<TouchableOpacity
|
||||
onPress={() => toggleCategory(cat.name)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||
borderColor: selected ? catColor : colors.border,
|
||||
backgroundColor: selected ? catColor + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
|
||||
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
|
||||
{cat.name}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={selected ? "chevron-down" : "chevron-forward"}
|
||||
size={12}
|
||||
color={selected ? catColor : colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Sélecteur produits (visible si catégorie sélectionnée) */}
|
||||
{selected && (
|
||||
<CategoryProductPicker
|
||||
catConfig={getCatConfig(cat.name)}
|
||||
products={productsByCategory[cat.name] ?? []}
|
||||
onChange={updateCatConfig}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Récapitulatif */}
|
||||
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
|
||||
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
|
||||
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
|
||||
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type →{" "}
|
||||
{REWARD_TYPES.find((x) => x.value === r.type)?.label}
|
||||
</Text>
|
||||
{r.description !== "" && (
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
|
||||
)}
|
||||
{r.type === "half_price_product" && r.category_configs.map((cfg) => (
|
||||
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
• Éligible à -50% : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`}
|
||||
</Text>
|
||||
))}
|
||||
{r.type === "free_product" && r.reward_items.filter((it) => it.product_id > 0).map((it, idx) => {
|
||||
const prod = Object.values(productsByCategory).flat().find((p) => p.id === it.product_id);
|
||||
return (
|
||||
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<Ionicons name="gift-outline" size={11} color={REWARD_ACCENT} />
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
{prod?.name ?? `Produit #${it.product_id}`}{it.quantity > 0 ? ` · x${it.quantity}` : ""}{it.price > 0 ? ` · ${it.price}€` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
||||
@@ -677,11 +1189,16 @@ export default function SettingsScreen() {
|
||||
telegram_bot_token: "",
|
||||
telegram_bot_username: "",
|
||||
telegram_notifications_enabled: false,
|
||||
telegram_2fa_enabled: false,
|
||||
delivery_mode: { mode: "single" as const, category_routes: [] },
|
||||
shop_name: "Milieu-Nantais",
|
||||
contact_telegram: "",
|
||||
points_reward: null,
|
||||
});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [productsByCategory, setProductsByCategory] = useState<Record<string, Product[]>>({});
|
||||
const [livreurs, setLivreurs] = useState<string[]>([]);
|
||||
|
||||
// Refs pour l'auto-sauvegarde au départ de la page
|
||||
@@ -711,30 +1228,56 @@ export default function SettingsScreen() {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
isLoaded.current = false;
|
||||
const [settingsRes, categoriesRes, livreursRes] = await Promise.all([
|
||||
getSettings(),
|
||||
getCategories(),
|
||||
getAvailableDeliveryPersons(),
|
||||
]);
|
||||
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,
|
||||
delivery_mode: settingsRes.settings.delivery_mode ?? { mode: "single", category_routes: [] },
|
||||
});
|
||||
try {
|
||||
const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([
|
||||
getSettings(),
|
||||
getCategories(),
|
||||
getAvailableDeliveryPersons(),
|
||||
getAllProductsAdmin(),
|
||||
]);
|
||||
if (settingsRes.success && settingsRes.settings) {
|
||||
const s = settingsRes.settings;
|
||||
setSettings({
|
||||
...s,
|
||||
penalty_tiers: s.penalty_tiers ?? [],
|
||||
points_pools: (s.points_pools ?? []).map((p: any) => ({
|
||||
...p,
|
||||
categories: p.categories ?? [],
|
||||
tiers: p.tiers ?? [],
|
||||
})),
|
||||
nowpayments_currencies: s.nowpayments_currencies ?? [],
|
||||
delivery_schedule: s.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
||||
postal_zones: s.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
||||
delivery_mode: {
|
||||
...(s.delivery_mode ?? { mode: "single" as const }),
|
||||
category_routes: s.delivery_mode?.category_routes ?? [],
|
||||
},
|
||||
points_reward: s.points_reward
|
||||
? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [], reward_items: s.points_reward.reward_items ?? [] }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (categoriesRes) {
|
||||
setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
|
||||
}
|
||||
if (livreursRes.success) {
|
||||
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
|
||||
}
|
||||
if (productsRes.success) {
|
||||
const byCategory: Record<string, Product[]> = {};
|
||||
for (const p of productsRes.data) {
|
||||
const cat = p.category || "";
|
||||
if (!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(p);
|
||||
}
|
||||
setProductsByCategory(byCategory);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SettingsScreen] loadData error:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0);
|
||||
}
|
||||
if (categoriesRes) {
|
||||
setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
|
||||
}
|
||||
if (livreursRes.success) {
|
||||
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
|
||||
}
|
||||
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(() => {
|
||||
@@ -939,9 +1482,28 @@ export default function SettingsScreen() {
|
||||
return (
|
||||
<View style={s.container}>
|
||||
<ScrollView contentContainerStyle={s.content}>
|
||||
{/* Personnalisation */}
|
||||
<AccordionSection title="Personnalisation" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Nom du shop</Text>
|
||||
<Text style={s.rowDesc}>Affiché dans la sidebar du site client</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={settings.shop_name}
|
||||
onChangeText={(v) => setSettings((p) => ({ ...p, shop_name: v }))}
|
||||
placeholder="Ex: Milieu-Nantais"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Amendes */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Amendes</Text>
|
||||
<AccordionSection title="Amendes" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Amendes activées</Text>
|
||||
@@ -1001,7 +1563,7 @@ export default function SettingsScreen() {
|
||||
<Text style={[s.rowDesc, { width: inputMd, textAlign: "center" }]}>Montant</Text>
|
||||
<View style={{ width: 28 }} />
|
||||
</View>
|
||||
{settings.penalty_tiers.map((tier, i) => (
|
||||
{(settings.penalty_tiers ?? []).map((tier, i) => (
|
||||
<View key={i} style={{ flexDirection: "row", alignItems: "center", width: "100%", gap: spacing.s }}>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
|
||||
@@ -1044,11 +1606,10 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Parrainage */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Parrainage</Text>
|
||||
<AccordionSection title="Parrainage" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Parrainage activé</Text>
|
||||
@@ -1066,11 +1627,10 @@ export default function SettingsScreen() {
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Système de points */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Système de points</Text>
|
||||
<AccordionSection title="Système de points" colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Points activés</Text>
|
||||
@@ -1126,11 +1686,10 @@ export default function SettingsScreen() {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Attribution des catégories */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
|
||||
<AccordionSection title="Attribution des catégories aux points" colors={colors} s={s}>
|
||||
<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.
|
||||
@@ -1217,7 +1776,7 @@ export default function SettingsScreen() {
|
||||
Ajoutez au moins un type de point pour assigner des catégories.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Barèmes de points — un par pool */}
|
||||
{pools.map((pool, i) => (
|
||||
@@ -1233,6 +1792,16 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Récompense centralisée par palier */}
|
||||
<CentralRewardSection
|
||||
reward={settings.points_reward ?? null}
|
||||
allCategories={categories}
|
||||
productsByCategory={productsByCategory}
|
||||
onChange={(reward) => setSettings((p) => ({ ...p, points_reward: reward }))}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
|
||||
{/* Horaires de livraison */}
|
||||
<DeliveryScheduleSection
|
||||
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
||||
@@ -1250,8 +1819,7 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
|
||||
{/* Paiement crypto */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Paiement crypto</Text>
|
||||
<AccordionSection title="Paiement crypto" colors={colors} s={s}>
|
||||
|
||||
{/* Toggle activation */}
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
@@ -1393,13 +1961,12 @@ export default function SettingsScreen() {
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* 🤖 TELEGRAM */}
|
||||
{/* ============================================ */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Notifications Telegram</Text>
|
||||
<AccordionSection title="Notifications Telegram" colors={colors} s={s}>
|
||||
<View style={s.row}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Notifications activées</Text>
|
||||
@@ -1412,6 +1979,18 @@ export default function SettingsScreen() {
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
<View style={s.row}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Authentification 2FA</Text>
|
||||
<Text style={s.rowDesc}>Permettre aux clients d'activer la double authentification via Telegram lors de la connexion</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.telegram_2fa_enabled}
|
||||
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))}
|
||||
trackColor={{ false: colors.border, true: colors.accent }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
|
||||
<Text style={s.rowDesc}>
|
||||
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
|
||||
@@ -1451,14 +2030,38 @@ export default function SettingsScreen() {
|
||||
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré — @{settings.telegram_bot_username}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Contact SAV Telegram</Text>
|
||||
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
|
||||
Username du compte Telegram SAV (sans le @). Utilisé pour les boutons de contact client.
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||
<Text style={{ fontSize: fontSize.md, color: colors.textMuted }}>@</Text>
|
||||
<TextInput
|
||||
style={[s.input, { flex: 1 }]}
|
||||
value={settings.contact_telegram}
|
||||
onChangeText={(v) => setSettings((p) => ({ ...p, contact_telegram: v }))}
|
||||
placeholder="MonSAV"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{settings.contact_telegram !== "" && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.s, padding: spacing.m, backgroundColor: "#0088cc22", borderRadius: borderRadius.sm }}>
|
||||
<Ionicons name="paper-plane-outline" size={16} color="#0088cc" />
|
||||
<Text style={{ color: "#0088cc", fontSize: fontSize.sm }}>@{settings.contact_telegram}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* 🚚 MODE DE LIVRAISON */}
|
||||
{/* ============================================ */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Mode de livraison</Text>
|
||||
<AccordionSection title="Mode de livraison" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
|
||||
Choisissez comment les commandes sont assignées aux livreurs.
|
||||
</Text>
|
||||
@@ -1577,7 +2180,7 @@ export default function SettingsScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
<TouchableOpacity
|
||||
style={s.saveButton}
|
||||
|
||||
Reference in New Issue
Block a user