chore: build

This commit is contained in:
Xor290
2026-09-08 17:33:32 +02:00
parent 42ed11bc22
commit 624df79974
14 changed files with 1066 additions and 60 deletions
+15
View File
@@ -1126,6 +1126,19 @@ export interface PointsReward {
category_configs: RewardCategoryConfig[];
}
export interface PromotionProductQuantity {
product_id: number;
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue)
}
export interface CategoryPromotionConfig {
category: string;
discount_percent: number; // pourcentage de réduction libre (ex: 10, 20, 33.5)
all_products: boolean;
quantity: number; // quantité uniforme si all_products = true
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
}
export interface PointsPool {
key: string;
name: string;
@@ -1225,6 +1238,8 @@ export interface AppSettings {
points_enabled: boolean;
points_pools: PointsPool[];
points_reward?: PointsReward | null;
promotions_enabled: boolean;
promotions: CategoryPromotionConfig[];
referral_enabled: boolean;
delivery_schedule: DeliverySchedule;
postal_zones: PostalZone[];
@@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
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, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import type { Product } from "../../api/types";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
@@ -1123,6 +1123,390 @@ function CentralRewardSection({
);
}
// ──────────────────────────────────────────────────────────────
// Sélecteur de produits pour une catégorie dans une promotion —
// même logique que CategoryProductPicker (récompenses), sans notion
// de "type" : une seule réduction (%) par catégorie.
// ──────────────────────────────────────────────────────────────
const PROMO_ACCENT = "#22c55e";
function PromotionProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: CategoryPromotionConfig;
products: Product[];
onChange: (cfg: CategoryPromotionConfig) => void;
colors: any;
s: any;
}) {
const catProducts = products.filter((p) => p.category === catConfig.category);
const toggleProduct = (id: number) => {
const exists = catConfig.products.some((pq) => pq.product_id === id);
if (exists) {
onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false });
return;
}
// Présélectionne le premier palier de prix actif du produit.
const prod = catProducts.find((p) => p.id === id);
const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false);
onChange({
...catConfig,
products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }],
all_products: false,
});
};
const updateProductQuantity = (id: number, quantity: number) => {
onChange({
...catConfig,
products: catConfig.products.map((pq) =>
pq.product_id === id ? { ...pq, quantity } : pq
),
});
};
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, products: [] })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: catConfig.all_products ? PROMO_ACCENT : colors.border,
backgroundColor: catConfig.all_products ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? PROMO_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 ? PROMO_ACCENT : colors.border,
backgroundColor: !catConfig.all_products ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? PROMO_ACCENT : colors.textMuted }}>
Sélection
</Text>
</TouchableOpacity>
</View>
{/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */}
{catConfig.all_products && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Quantité :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={catConfig.quantity > 0 ? String(catConfig.quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
doit correspondre à un palier de prix existant
</Text>
</View>
)}
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité,
via les paliers de prix réels du produit (pas de saisie libre) */}
{!catConfig.all_products && (
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === 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 ? PROMO_ACCENT : colors.border,
backgroundColor: sel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? PROMO_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
{catConfig.products.length > 0 && (
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
{catConfig.products.map((pq) => {
const prod = catProducts.find((p) => p.id === pq.product_id);
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
return (
<View key={pq.product_id} style={{ gap: 4 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
{prod?.name ?? `Produit #${pq.product_id}`}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
{tiers.length === 0 ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Aucun palier de prix actif pour ce produit
</Text>
) : tiers.map((tier) => {
const isSel = pq.quantity === tier.quantity;
return (
<TouchableOpacity
key={tier.quantity}
onPress={() => updateProductQuantity(pq.product_id, tier.quantity)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 3,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: isSel ? PROMO_ACCENT : colors.border,
backgroundColor: isSel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{isSel && <Ionicons name="checkmark" size={10} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? PROMO_ACCENT : colors.textMuted }}>
{tier.quantity}{prod?.unit ?? ""} · {tier.price}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
</View>
)}
</View>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// Section centralisée promotions — réduction (%) automatique sur des
// produits/quantités d'une catégorie, appliquée à toute commande
// (indépendant des points de fidélité, contrairement aux récompenses).
// ──────────────────────────────────────────────────────────────
function PromotionsSection({
enabled,
promotions,
allCategories,
productsByCategory,
onToggle,
onChangePromotions,
colors,
s,
}: {
enabled: boolean;
promotions: CategoryPromotionConfig[];
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onToggle: (v: boolean) => void;
onChangePromotions: (promotions: CategoryPromotionConfig[]) => void;
colors: any;
s: any;
}) {
const getCatConfig = (catName: string): CategoryPromotionConfig =>
promotions.find((p) => p.category === catName) ??
{ category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] };
const isCatSelected = (catName: string) => promotions.some((p) => p.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
onChangePromotions(promotions.filter((p) => p.category !== catName));
} else {
onChangePromotions([...promotions, { category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] }]);
}
};
const updateCatConfig = (cfg: CategoryPromotionConfig) => {
onChangePromotions(promotions.map((p) => (p.category === cfg.category ? cfg : p)));
};
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set());
const toggleExpanded = (catName: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(catName)) next.delete(catName); else next.add(catName);
return next;
});
};
const promoBadge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? PROMO_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? PROMO_ACCENT : colors.border,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? PROMO_ACCENT : colors.textMuted }}>
{enabled ? "Activées" : "Désactivées"}
</Text>
</View>
);
return (
<AccordionSection title="Promotions" badge={promoBadge} colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Promotions activées</Text>
<Text style={s.rowDesc}>
Réduction automatique appliquée au prix affiché et facturé, pour tout client indépendant des points de fidélité.
</Text>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: colors.border, true: PROMO_ACCENT }}
thumbColor="#fff"
/>
</View>
{enabled && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories en promo</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez une catégorie, définissez le pourcentage de réduction, puis tous les produits ou une sélection avec leur quantité.
</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 expanded = expandedCats.has(cat.name);
const catColor = cat.color || PROMO_ACCENT;
const cfg = getCatConfig(cat.name);
return (
<View key={cat.name}>
<TouchableOpacity
onPress={() => toggleExpanded(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}{selected ? ` · -${cfg.discount_percent}%` : ""}
</Text>
<Ionicons
name={expanded ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{expanded && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
<TouchableOpacity
onPress={() => toggleCategory(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: 4,
alignSelf: "flex-start",
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: selected ? PROMO_ACCENT : colors.border,
backgroundColor: selected ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons
name={selected ? "checkbox" : "square-outline"}
size={14}
color={selected ? PROMO_ACCENT : colors.textMuted}
/>
<Ionicons name="pricetag-outline" size={12} color={selected ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? PROMO_ACCENT : colors.textMuted }}>
Promo active sur cette catégorie
</Text>
</TouchableOpacity>
{selected && (
<>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Réduction :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={cfg.discount_percent > 0 ? String(cfg.discount_percent) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
updateCatConfig({ ...cfg, discount_percent: isNaN(n) ? 0 : n });
}}
placeholder="10"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}>%</Text>
</View>
<PromotionProductPicker
catConfig={cfg}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
</>
)}
</View>
)}
</View>
);
})}
</View>
)}
</View>
{/* Récapitulatif */}
{promotions.length > 0 && (
<View style={{ backgroundColor: PROMO_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: PROMO_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: PROMO_ACCENT }}>Récapitulatif</Text>
{promotions.map((cfg, idx) => (
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{cfg.category} -{cfg.discount_percent}% sur {cfg.all_products
? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}`
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`}
</Text>
))}
</View>
)}
</View>
)}
</AccordionSection>
);
}
// Palette violette d'origine de l'application (thème par défaut historique)
const ORIGINAL_THEME_COLORS = {
admin_color_primary: "#7c3aed",
@@ -1178,6 +1562,8 @@ export default function SettingsScreen() {
shop_name: "Milieu-Nantais",
contact_telegram: "",
points_reward: null,
promotions_enabled: false,
promotions: [],
admin_color_primary: "#7c3aed",
admin_color_secondary: "#22d3ee",
admin_color_success: "#4ade80",
@@ -1257,6 +1643,11 @@ export default function SettingsScreen() {
})),
}
: null,
promotions_enabled: s.promotions_enabled ?? false,
promotions: (s.promotions ?? []).map((cfg) => ({
...cfg,
products: cfg.products ?? [],
})),
});
}
if (categoriesRes) {
@@ -1872,6 +2263,18 @@ export default function SettingsScreen() {
s={s}
/>
{/* Promotions — réduction automatique, indépendante des points */}
<PromotionsSection
enabled={settings.promotions_enabled ?? false}
promotions={settings.promotions ?? []}
allCategories={categories}
productsByCategory={productsByCategory}
onToggle={(v) => setSettings((p) => ({ ...p, promotions_enabled: v }))}
onChangePromotions={(promotions) => setSettings((p) => ({ ...p, promotions }))}
colors={colors}
s={s}
/>
{/* Horaires de livraison */}
<DeliveryScheduleSection
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}