chore: build
Backend - Build & Lint / build (push) Canceled after 11m16s
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Web - Build & Lint / build (push) Canceled after 0s

This commit is contained in:
Xor290
2026-09-08 17:44:28 +02:00
parent 06abfee274
commit 82f9a2fae9
37 changed files with 2164 additions and 598 deletions
+2 -2
View File
@@ -101,8 +101,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();
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Admin Panel",
"slug": "frontend-admin",
"version": "1.0.1",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
@@ -53,7 +53,7 @@
}
},
"owner": "xor290",
"runtimeVersion": "admin-1.0.1",
"runtimeVersion": "admin-1.0.0",
"updates": {
"url": "https://u.expo.dev/fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440",
"codeSigningCertificate": "./certs/certificate.pem",
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGzCCAgOgAwIBAgIUE8d7MB8k8EDm+Ai4QgEHdIJi3nUwDQYJKoZIhvcNAQEL
BQAwIjEgMB4GA1UEAwwXVWJlciBTdHVwIEFkbWluIFByZXByb2QwHhcNMjYwODI2
MTAyMjQ1WhcNMzYwODIzMTAyMjQ1WjAiMSAwHgYDVQQDDBdVYmVyIFN0dXAgQWRt
aW4gUHJlcHJvZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM55/nO+
DDMIqAcoaRMe/IFu6GTP+iX+M4UpvJXMYH9n4oCp7cHegxrq5KkW5Q8Hg1Qic/2N
4W47G9LoEyjg58lOZISeBu6tltnfiFIMaqyuxDJvq851jFf2g4uXR2DpG4nW46dz
d36MWAbI2UyEhUKPVEJGhZc5s9eP+CECYmSDj0oyObseMcieolqCV7itSzwmck2e
VWrbOJF5TgQ26G8buA8gXUbJUVHMyan8LDWDl/+JTckJ1ENdcrBPyjA/ce3wbVBV
m33Te758JWb5wxAP2nMi2rqy/GdvgQDH6m82u/BBEFsmk+Nn7PJBYlEUeUP0rdf5
RqPJH0bzuPUzygkCAwEAAaNJMEcwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM
MAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSObwIT1/4owQnnb9fpdKQJyIeZXjANBgkq
hkiG9w0BAQsFAAOCAQEAKFPhk6qGylJzpjJzh7WTJrD78zkvzQfyl2OLHLy6q4HI
0NUeKlwGccUe6ujvB85HBqlLox3mQOB4uuR3hz1fKhIJ2StNvX/3Ko/da8a+WeiN
ZfniBDNPUKAaRG6/DH80n83r7GT07hHq4zJrWIauOOSdkOmwHYrHl79ceNk94WhC
XHtr+9/n/z0WG83NePHPPqnTT/IRCpWPCNzFQf1vT7GPWTKaRjTKfRpgAFzzumho
wj65OMSD5ZRenkTG7KMxssYRN+2UPeoZ+nAKgx0K5vVbFfFVfogfYLFf6xwvXg8i
1Iokq3r8g7BACGJlWxPqtDo272lOD7HdQ7sLxLqxUw==
-----END CERTIFICATE-----
+19 -18
View File
@@ -1,18 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV
BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX
DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy
IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z
FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7
PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo
VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE
orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN
N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW
39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr
BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp
4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad
vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV
K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg
jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk
VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDCzCCAfOgAwIBAgIUP26Wjyp3YylJDp5TspqcnBfttXgwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMB4XDTI2MDgyNjEwMjI0NVoX
DTM2MDgyMzEwMjI0NVowGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv5BLKcCBTmbDf5kh8Iwrtuhbhizt
kHF1CsR8CRh4diuoXT8fdlwmQ6xf8tYFMkF4Q1ytHXHZ1VfLslU4fWVTErJW9/e0
yTx1sP4sITzpujkOTSeFlvNxJ2Y6MKFoqwxVG/999oSteNTLAQeBNbnwgHox7Bu1
WJGV3fAjv7y6VttH/u9ZUtAn6dwrHcsGFZ5vqr4z2ZMM+dU1L/sjF41wQAaCLSpY
5rjch2FeD1gjVFpVMmMqxJado7B4UPcYZf1YCftjpp3Ojb0ZCy11uXo7rIOYR5EB
g1vTEgkMIp7CsC4FUdZKNltHDkNiml7hELp29C+auDjcHRkYZvSZNPa6vQIDAQAB
o0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwMwHQYD
VR0OBBYEFI1iBsChUiK+E/rHk0O4Sr7gPgRpMA0GCSqGSIb3DQEBCwUAA4IBAQBf
q3IFEOFM+7FNuYEUDhNDjAC6teZPbM5yUMeX13Ei3MOdalaNCwuTTSQTIrBpjMpm
Lqd6y/qjF/jefXDOF4VHUv/MWhTtwlklPB4zvYK81gZu0piNK9CDPgnoYa8WASlj
8MZURgmmVHvoCAVjtqVrU+8H4SFTCL0SxBq1giJwqyEogsMGyaTIXDfOn0+HRsEg
BZatKJwWCSHCox18i+6gMED+WsgrS/topvjiV7PR6iZQGckT1rEmG11m2IjgrvFt
MFXPeyDEhvr2E9cqaOyMgRP/r+0f5AhELzZygom+9XTXdNwvGQuUuT76YiQGfJEf
B64IP3rw+0Rs+9XAHXF3
-----END CERTIFICATE-----
+2 -2
View File
@@ -23,7 +23,7 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
"EXPO_PUBLIC_UPDATE_URL": "https://ota-preprod.uber-stup.club/api/manifest"
},
"channel": "pre-prod-admin"
},
@@ -35,7 +35,7 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
"EXPO_PUBLIC_UPDATE_URL": "https://ota-prod.uber-stup.club/api/manifest"
},
"channel": "production-admin"
}
+22 -8
View File
@@ -1107,24 +1107,36 @@ 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 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 {
@@ -1226,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[];
-1
View File
@@ -164,7 +164,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
// ============================================
// LIVREURS
// ============================================
const parseStatus = (status: any): "available" | "busy" | "offline" => {
if (!status) return "offline";
-1
View File
@@ -123,7 +123,6 @@ export async function calculateRoute(
}
}
// Fallback: straight line
if (coordinates.length === 0) {
coordinates.push(origin, destination);
}
-1
View File
@@ -31,7 +31,6 @@ export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
export const setRole = (role: string) => AsyncStorage.setItem(ROLE_KEY, role);
export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY);
// Clear all auth data
export const clearAllAuth = async () => {
await AsyncStorage.multiRemove([
TOKEN_KEY,
@@ -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, 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";
@@ -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,19 +1108,196 @@ 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);
</View>
)}
</View>
)}
</AccordionSection>
);
}
// ──────────────────────────────────────────────────────────────
// 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 (
<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}` : ""}
<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>
);
})}
@@ -1147,6 +1305,204 @@ function CentralRewardSection({
)}
</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>
);
}
@@ -1206,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",
@@ -1277,8 +1635,19 @@ 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,
promotions_enabled: s.promotions_enabled ?? false,
promotions: (s.promotions ?? []).map((cfg) => ({
...cfg,
products: cfg.products ?? [],
})),
});
}
if (categoriesRes) {
@@ -1894,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}