chore: build
This commit is contained in:
@@ -1112,19 +1112,13 @@ export interface RewardCategoryConfig {
|
|||||||
type: "free_product" | "half_price_product";
|
type: "free_product" | "half_price_product";
|
||||||
all_products: boolean;
|
all_products: boolean;
|
||||||
product_ids: number[];
|
product_ids: number[];
|
||||||
}
|
quantity: number; // quantité offerte / à -50% (palier de prix catalogue, ex: 1g)
|
||||||
|
|
||||||
export interface RewardItem {
|
|
||||||
product_id: number;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PointsReward {
|
export interface PointsReward {
|
||||||
threshold: number;
|
threshold: number;
|
||||||
description: string;
|
description: string;
|
||||||
category_configs: RewardCategoryConfig[];
|
category_configs: RewardCategoryConfig[];
|
||||||
reward_items: RewardItem[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PointsPool {
|
export interface PointsPool {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ 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";
|
||||||
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } 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 { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||||
import type { Product } from "../../api/types";
|
import type { Product } from "../../api/types";
|
||||||
import AlertModal from "../../components/ui/AlertModal";
|
import AlertModal from "../../components/ui/AlertModal";
|
||||||
import { useAlert } from "../../hooks/useAlert";
|
import { useAlert } from "../../hooks/useAlert";
|
||||||
@@ -695,7 +695,6 @@ const EMPTY_REWARD: PointsReward = {
|
|||||||
threshold: 20,
|
threshold: 20,
|
||||||
description: "",
|
description: "",
|
||||||
category_configs: [],
|
category_configs: [],
|
||||||
reward_items: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -725,6 +724,27 @@ function CategoryProductPicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
||||||
|
{/* Quantité offerte / à -50% — correspond à un palier de prix du
|
||||||
|
catalogue (ex: si le produit a un palier quantity=1 à 30€,
|
||||||
|
indiquer 1 ici donne "30€ offert" ou "15€ à -50%"). */}
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Toggle tous / sélection */}
|
{/* Toggle tous / sélection */}
|
||||||
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -824,7 +844,7 @@ function CentralRewardSection({
|
|||||||
// propre sélection de produits — d'où la recherche par (catégorie, type).
|
// propre sélection de produits — d'où la recherche par (catégorie, type).
|
||||||
const getCatConfig = (catName: string, type: RewardCategoryConfig["type"]): RewardCategoryConfig =>
|
const getCatConfig = (catName: string, type: RewardCategoryConfig["type"]): RewardCategoryConfig =>
|
||||||
r.category_configs.find((c) => c.category === catName && c.type === type) ??
|
r.category_configs.find((c) => c.category === catName && c.type === type) ??
|
||||||
{ category: catName, type, all_products: true, product_ids: [] };
|
{ category: catName, type, all_products: true, product_ids: [], quantity: 1 };
|
||||||
|
|
||||||
const isTypeEnabled = (catName: string, type: RewardCategoryConfig["type"]) =>
|
const isTypeEnabled = (catName: string, type: RewardCategoryConfig["type"]) =>
|
||||||
r.category_configs.some((c) => c.category === catName && c.type === type);
|
r.category_configs.some((c) => c.category === catName && c.type === type);
|
||||||
@@ -836,7 +856,7 @@ function CentralRewardSection({
|
|||||||
if (isTypeEnabled(catName, type)) {
|
if (isTypeEnabled(catName, type)) {
|
||||||
update({ category_configs: r.category_configs.filter((c) => !(c.category === catName && c.type === type)) });
|
update({ category_configs: r.category_configs.filter((c) => !(c.category === catName && c.type === type)) });
|
||||||
} else {
|
} else {
|
||||||
update({ category_configs: [...r.category_configs, { category: catName, type, all_products: true, product_ids: [] }] });
|
update({ category_configs: [...r.category_configs, { category: catName, type, all_products: true, product_ids: [], quantity: 1 }] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -921,134 +941,6 @@ function CentralRewardSection({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Produits récompense — proposés au client selon le type choisi
|
|
||||||
pour la catégorie de chaque produit (voir "Catégories éligibles" ci-dessous) */}
|
|
||||||
<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 ou à -50% selon le type configuré pour la catégorie du produit. 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 — chaque catégorie choisit son propre type
|
{/* Catégories éligibles — chaque catégorie choisit son propre type
|
||||||
(produit offert ou -50%), qui s'applique aux produits récompense
|
(produit offert ou -50%), qui s'applique aux produits récompense
|
||||||
de cette catégorie configurés ci-dessus */}
|
de cette catégorie configurés ci-dessus */}
|
||||||
@@ -1140,7 +1032,7 @@ function CentralRewardSection({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Récapitulatif */}
|
{/* Récapitulatif */}
|
||||||
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
|
{r.category_configs.length > 0 && (
|
||||||
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
|
<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, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
|
||||||
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
|
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
|
||||||
@@ -1151,20 +1043,9 @@ function CentralRewardSection({
|
|||||||
)}
|
)}
|
||||||
{r.category_configs.map((cfg, idx) => (
|
{r.category_configs.map((cfg, idx) => (
|
||||||
<Text key={`${cfg.category}-${cfg.type}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
|
<Text key={`${cfg.category}-${cfg.type}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||||
• {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`}
|
• {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`} · qté {cfg.quantity > 0 ? cfg.quantity : 1}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -1299,7 +1180,7 @@ export default function SettingsScreen() {
|
|||||||
category_routes: s.delivery_mode?.category_routes ?? [],
|
category_routes: s.delivery_mode?.category_routes ?? [],
|
||||||
},
|
},
|
||||||
points_reward: s.points_reward
|
points_reward: s.points_reward
|
||||||
? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [], reward_items: s.points_reward.reward_items ?? [] }
|
? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [] }
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -967,7 +967,7 @@ export type RewardCategoryConfig = {
|
|||||||
all_products: boolean;
|
all_products: boolean;
|
||||||
product_ids: number[];
|
product_ids: number[];
|
||||||
product_names: string[];
|
product_names: string[];
|
||||||
amount: number;
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RewardItemConfig = {
|
export type RewardItemConfig = {
|
||||||
|
|||||||
Reference in New Issue
Block a user