chore: build
This commit is contained in:
@@ -108,8 +108,8 @@ export default function App() {
|
||||
await Updates.fetchUpdateAsync();
|
||||
await Updates.reloadAsync();
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore update errors
|
||||
} catch (e) {
|
||||
console.error("[OTA] Échec de la vérification/application de la mise à jour:", e);
|
||||
}
|
||||
};
|
||||
checkForUpdate();
|
||||
|
||||
@@ -1107,24 +1107,23 @@ export interface PointsTier {
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface RewardProductQuantity {
|
||||
product_id: number;
|
||||
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue, ex: 1g)
|
||||
}
|
||||
|
||||
export interface RewardCategoryConfig {
|
||||
category: string;
|
||||
type: "free_product" | "half_price_product";
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
}
|
||||
|
||||
export interface RewardItem {
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
price: number;
|
||||
quantity: number; // quantité uniforme si all_products = true
|
||||
products: RewardProductQuantity[]; // produits + quantité individuelle si all_products = false
|
||||
}
|
||||
|
||||
export interface PointsReward {
|
||||
threshold: number;
|
||||
description: string;
|
||||
category_configs: RewardCategoryConfig[];
|
||||
reward_items: RewardItem[];
|
||||
}
|
||||
|
||||
export interface PointsPool {
|
||||
|
||||
@@ -123,7 +123,6 @@ export async function calculateRoute(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: straight line
|
||||
if (coordinates.length === 0) {
|
||||
coordinates.push(origin, destination);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ export default function DeliveryNavigator() {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
|
||||
{/* Modal notifications */}
|
||||
<Modal
|
||||
visible={showModal}
|
||||
animationType="slide"
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
@@ -503,6 +502,9 @@ export default function DeliveryScreen() {
|
||||
padding: spacing.l,
|
||||
maxHeight: "80%",
|
||||
},
|
||||
ratingsList: {
|
||||
padding: spacing.s,
|
||||
},
|
||||
ratingsHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
@@ -1076,69 +1078,67 @@ export default function DeliveryScreen() {
|
||||
animationType="slide"
|
||||
onRequestClose={() => setRatingsModal(null)}
|
||||
>
|
||||
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
|
||||
<Pressable onPress={() => {}}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Avis — {ratingsModal?.username}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setRatingsModal(null)}>
|
||||
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{ratingsLoading ? (
|
||||
<Text style={styles.ratingsEmpty}>Chargement...</Text>
|
||||
) : ratingsModal && ratingsModal.count > 0 ? (
|
||||
<>
|
||||
<View style={styles.ratingsAvg}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
|
||||
size={20}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
<Text style={styles.ratingsAvgText}>
|
||||
{ratingsModal.average.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={styles.ratingsCount}>
|
||||
({ratingsModal.count} avis)
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{ratingsModal.ratings.map((r) => (
|
||||
<View key={r.id} style={styles.ratingItem}>
|
||||
<View style={styles.ratingItemHeader}>
|
||||
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
|
||||
<Text style={styles.ratingItemDate}>
|
||||
{new Date(r.created_at).toLocaleDateString("fr-FR")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ratingStarsRow}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= r.rating ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{r.comment !== "" && (
|
||||
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
|
||||
)}
|
||||
<View style={styles.ratingsOverlay}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Avis — {ratingsModal?.username}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setRatingsModal(null)}>
|
||||
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
{ratingsLoading ? (
|
||||
<Text style={styles.ratingsEmpty}>Chargement...</Text>
|
||||
) : ratingsModal && ratingsModal.count > 0 ? (
|
||||
<>
|
||||
<View style={styles.ratingsAvg}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
|
||||
size={20}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
<Text style={styles.ratingsAvgText}>
|
||||
{ratingsModal.average.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={styles.ratingsCount}>
|
||||
({ratingsModal.count} avis)
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView style={styles.ratingsList}>
|
||||
{ratingsModal.ratings.map((r) => (
|
||||
<View key={r.id} style={styles.ratingItem}>
|
||||
<View style={styles.ratingItemHeader}>
|
||||
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
|
||||
<Text style={styles.ratingItemDate}>
|
||||
{new Date(r.created_at).toLocaleDateString("fr-FR")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ratingStarsRow}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= r.rating ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{r.comment !== "" && (
|
||||
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal historique de connexion livreur ── */}
|
||||
@@ -1148,12 +1148,8 @@ export default function DeliveryScreen() {
|
||||
animationType="slide"
|
||||
onRequestClose={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.ratingsOverlay}
|
||||
onPress={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Pressable onPress={() => {}}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsOverlay}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Connexions — {loginHistoryModal?.username}
|
||||
@@ -1258,7 +1254,7 @@ export default function DeliveryScreen() {
|
||||
</Text>
|
||||
) : loginHistoryModal &&
|
||||
loginHistoryModal.weeks.length > 0 ? (
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<ScrollView style={styles.ratingsList}>
|
||||
{loginHistoryModal.weeks.map((week) => (
|
||||
<View key={week.week}>
|
||||
<Text style={styles.historyWeekLabel}>
|
||||
@@ -1310,9 +1306,8 @@ export default function DeliveryScreen() {
|
||||
Aucune connexion ce mois-ci
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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, 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 AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
@@ -49,7 +49,7 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Composant section accordéon générique
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
function AccordionSection({
|
||||
title,
|
||||
badge,
|
||||
@@ -695,7 +695,6 @@ const EMPTY_REWARD: PointsReward = {
|
||||
threshold: 20,
|
||||
description: "",
|
||||
category_configs: [],
|
||||
reward_items: [],
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -717,10 +716,29 @@ function CategoryProductPicker({
|
||||
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 });
|
||||
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, plutôt
|
||||
// qu'une valeur arbitraire qui pourrait ne correspondre à aucun palier réel.
|
||||
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 (
|
||||
@@ -728,7 +746,7 @@ function CategoryProductPicker({
|
||||
{/* Toggle tous / sélection */}
|
||||
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange({ ...catConfig, all_products: true, product_ids: [] })}
|
||||
onPress={() => onChange({ ...catConfig, all_products: true, products: [] })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||
@@ -759,34 +777,103 @@ function CategoryProductPicker({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Liste des produits si mode sélection */}
|
||||
{/* 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é
|
||||
(ex: produit A à 2g offerts, produit B à 1g offert) */}
|
||||
{!catConfig.all_products && (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||
<View style={{ 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 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 ? 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>
|
||||
)}
|
||||
|
||||
{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 ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: isSel ? REWARD_ACCENT + "22" : "transparent",
|
||||
flexDirection: "row", alignItems: "center", gap: 4,
|
||||
}}
|
||||
>
|
||||
{isSel && <Ionicons name="checkmark" size={10} color={REWARD_ACCENT} />}
|
||||
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{tier.quantity}{prod?.unit ?? ""} · {tier.price}€
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -819,29 +906,44 @@ function CentralRewardSection({
|
||||
const update = (patch: Partial<PointsReward>) =>
|
||||
onChange({ ...r, ...patch });
|
||||
|
||||
const getCatConfig = (catName: string): RewardCategoryConfig =>
|
||||
r.category_configs.find((c) => c.category === catName) ??
|
||||
{ category: catName, type: "free_product", all_products: true, product_ids: [] };
|
||||
// Une catégorie peut avoir jusqu'à deux configs actives en parallèle
|
||||
// (une "free_product" et une "half_price_product"), chacune avec sa
|
||||
// propre sélection de produits — d'où la recherche par (catégorie, type).
|
||||
const getCatConfig = (catName: string, type: RewardCategoryConfig["type"]): RewardCategoryConfig =>
|
||||
r.category_configs.find((c) => c.category === catName && c.type === type) ??
|
||||
{ category: catName, type, all_products: true, products: [], quantity: 1 };
|
||||
|
||||
const isTypeEnabled = (catName: string, type: RewardCategoryConfig["type"]) =>
|
||||
r.category_configs.some((c) => c.category === catName && c.type === type);
|
||||
|
||||
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) });
|
||||
const toggleCategoryType = (catName: string, type: RewardCategoryConfig["type"]) => {
|
||||
if (isTypeEnabled(catName, type)) {
|
||||
update({ category_configs: r.category_configs.filter((c) => !(c.category === catName && c.type === type)) });
|
||||
} else {
|
||||
update({ category_configs: [...r.category_configs, { category: catName, type: "free_product", all_products: true, product_ids: [] }] });
|
||||
update({ category_configs: [...r.category_configs, { category: catName, type, all_products: true, products: [], quantity: 1 }] });
|
||||
}
|
||||
};
|
||||
|
||||
const updateCatConfig = (cfg: RewardCategoryConfig) => {
|
||||
update({
|
||||
category_configs: r.category_configs.map((c) =>
|
||||
c.category === cfg.category ? cfg : c
|
||||
c.category === cfg.category && c.type === cfg.type ? cfg : c
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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 rewardBadge = (
|
||||
<View style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||
@@ -906,141 +1008,13 @@ function CentralRewardSection({
|
||||
/>
|
||||
</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
|
||||
(produit offert ou -50%), qui s'applique aux produits récompense
|
||||
de cette catégorie configurés ci-dessus */}
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</Text>
|
||||
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
||||
Sélectionnez les catégories, choisissez le type de récompense pour chacune, puis tous les produits ou une sélection.
|
||||
Sélectionnez une catégorie pour activer, indépendamment, un lot de produits offerts et/ou un lot de produits à -50%, chacun avec sa propre sélection de produits.
|
||||
</Text>
|
||||
{allCategories.length === 0 ? (
|
||||
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
|
||||
@@ -1048,12 +1022,13 @@ function CentralRewardSection({
|
||||
<View style={{ gap: spacing.m }}>
|
||||
{allCategories.map((cat) => {
|
||||
const selected = isCatSelected(cat.name);
|
||||
const expanded = expandedCats.has(cat.name);
|
||||
const catColor = cat.color || REWARD_ACCENT;
|
||||
return (
|
||||
<View key={cat.name}>
|
||||
{/* Chip catégorie */}
|
||||
{/* Chip catégorie (couleur = au moins un type actif, clic = déplier/replier) */}
|
||||
<TouchableOpacity
|
||||
onPress={() => toggleCategory(cat.name)}
|
||||
onPress={() => toggleExpanded(cat.name)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
alignSelf: "flex-start",
|
||||
@@ -1068,46 +1043,52 @@ function CentralRewardSection({
|
||||
{cat.name}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={selected ? "chevron-down" : "chevron-forward"}
|
||||
name={expanded ? "chevron-down" : "chevron-forward"}
|
||||
size={12}
|
||||
color={selected ? catColor : colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Type + sélecteur produits (visible si catégorie sélectionnée) */}
|
||||
{selected && (
|
||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||
{REWARD_TYPES.map((rt) => {
|
||||
const cfg = getCatConfig(cat.name);
|
||||
const sel = (cfg.type || "free_product") === rt.value;
|
||||
return (
|
||||
{/* Les deux types de récompense, activables indépendamment */}
|
||||
{expanded && (
|
||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.m }}>
|
||||
{REWARD_TYPES.map((rt) => {
|
||||
const typeEnabled = isTypeEnabled(cat.name, rt.value);
|
||||
return (
|
||||
<View key={rt.value} style={{ gap: spacing.s }}>
|
||||
<TouchableOpacity
|
||||
key={rt.value}
|
||||
onPress={() => updateCatConfig({ ...cfg, type: rt.value })}
|
||||
onPress={() => toggleCategoryType(cat.name, rt.value)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: 4,
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: sel ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
|
||||
borderColor: typeEnabled ? REWARD_ACCENT : colors.border,
|
||||
backgroundColor: typeEnabled ? REWARD_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons name={rt.icon as any} size={12} color={sel ? REWARD_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
|
||||
<Ionicons
|
||||
name={typeEnabled ? "checkbox" : "square-outline"}
|
||||
size={14}
|
||||
color={typeEnabled ? REWARD_ACCENT : colors.textMuted}
|
||||
/>
|
||||
<Ionicons name={rt.icon as any} size={12} color={typeEnabled ? REWARD_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: typeEnabled ? "700" : "400", color: typeEnabled ? REWARD_ACCENT : colors.textMuted }}>
|
||||
{rt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<CategoryProductPicker
|
||||
catConfig={getCatConfig(cat.name)}
|
||||
products={productsByCategory[cat.name] ?? []}
|
||||
onChange={updateCatConfig}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
{typeEnabled && (
|
||||
<CategoryProductPicker
|
||||
catConfig={getCatConfig(cat.name, rt.value)}
|
||||
products={productsByCategory[cat.name] ?? []}
|
||||
onChange={updateCatConfig}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -1118,7 +1099,7 @@ function CentralRewardSection({
|
||||
</View>
|
||||
|
||||
{/* 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 }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
|
||||
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
|
||||
@@ -1127,22 +1108,13 @@ function CentralRewardSection({
|
||||
{r.description !== "" && (
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
|
||||
)}
|
||||
{r.category_configs.map((cfg) => (
|
||||
<Text key={cfg.category} 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)`}
|
||||
{r.category_configs.map((cfg, idx) => (
|
||||
<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 · qté ${cfg.quantity > 0 ? cfg.quantity : 1}`
|
||||
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`}
|
||||
</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>
|
||||
@@ -1277,7 +1249,13 @@ export default function SettingsScreen() {
|
||||
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 ?? [] }
|
||||
? {
|
||||
...s.points_reward,
|
||||
category_configs: (s.points_reward.category_configs ?? []).map((cfg) => ({
|
||||
...cfg,
|
||||
products: cfg.products ?? [],
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -961,13 +961,18 @@ export const toggle2FA = async (
|
||||
// 🏆 POINTS — RÉCOMPENSES
|
||||
// ============================================
|
||||
|
||||
export type RewardConfigProduct = {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardCategoryConfig = {
|
||||
category: string;
|
||||
type: "free_product" | "half_price_product";
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
product_names: string[];
|
||||
amount: number;
|
||||
products: RewardConfigProduct[];
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardItemConfig = {
|
||||
|
||||
@@ -830,14 +830,14 @@ export default function OrderHistoryScreen() {
|
||||
</View>,
|
||||
]
|
||||
: (
|
||||
cfg.product_names ??
|
||||
cfg.products ??
|
||||
[]
|
||||
).map(
|
||||
(
|
||||
name,
|
||||
p,
|
||||
) => (
|
||||
<View
|
||||
key={`${cfg.category}-${name}`}
|
||||
key={`${cfg.category}-${p.product_id}`}
|
||||
style={
|
||||
styles.rewardAmountBadge
|
||||
}
|
||||
@@ -848,7 +848,7 @@ export default function OrderHistoryScreen() {
|
||||
}
|
||||
>
|
||||
{
|
||||
name
|
||||
p.product_name
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user