chore: update
This commit is contained in:
@@ -704,6 +704,12 @@ export interface PointsTier {
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface PostalZone {
|
||||
name: string;
|
||||
min_amount: number;
|
||||
codes: string[];
|
||||
}
|
||||
|
||||
export interface DaySchedule {
|
||||
enabled: boolean;
|
||||
open_time: string; // "09:00"
|
||||
@@ -720,6 +726,12 @@ export interface DeliverySchedule {
|
||||
sunday: DaySchedule;
|
||||
}
|
||||
|
||||
export const DEFAULT_POSTAL_ZONES: PostalZone[] = [
|
||||
{ name: "Zone 30€", min_amount: 30, codes: ["44000", "44100", "44200", "44300"] },
|
||||
{ name: "Zone 50€", min_amount: 50, codes: ["44400", "44880", "44120", "44230", "44115", "44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830"] },
|
||||
{ name: "Zone 100€", min_amount: 100, codes: ["44860", "44220", "44118", "44710", "44690", "44119"] },
|
||||
];
|
||||
|
||||
export const DEFAULT_DAY: DaySchedule = { enabled: true, open_time: "09:00", close_time: "20:00" };
|
||||
|
||||
export const DEFAULT_DELIVERY_SCHEDULE: DeliverySchedule = {
|
||||
@@ -741,6 +753,7 @@ export interface AppSettings {
|
||||
points_total_tiers: PointsTier[];
|
||||
referral_enabled: boolean;
|
||||
delivery_schedule: DeliverySchedule;
|
||||
postal_zones: PostalZone[];
|
||||
}
|
||||
|
||||
export const getSettings = async (): Promise<{
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getSettings, updateSettings, getCategories, DEFAULT_DELIVERY_SCHEDULE } from "../../api/api_admin";
|
||||
import type { AppSettings, Category, PointsTier, DaySchedule, DeliverySchedule } from "../../api/api_admin";
|
||||
import { getSettings, updateSettings, getCategories, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
||||
import type { AppSettings, Category, PointsTier, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||
import AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
@@ -119,6 +119,226 @@ function DeliveryScheduleSection({
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Composant éditeur de zones postales
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function PostalZonesSection({
|
||||
zones,
|
||||
onChange,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
zones: PostalZone[];
|
||||
onChange: (zones: PostalZone[]) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(null);
|
||||
|
||||
const addZone = () => {
|
||||
const next = [...zones, { name: "Nouvelle zone", min_amount: 0, codes: [] }];
|
||||
onChange(next);
|
||||
setExpandedIndex(next.length - 1);
|
||||
};
|
||||
|
||||
const removeZone = (i: number) => {
|
||||
onChange(zones.filter((_, idx) => idx !== i));
|
||||
if (expandedIndex === i) setExpandedIndex(null);
|
||||
};
|
||||
|
||||
const updateZone = (i: number, patch: Partial<PostalZone>) => {
|
||||
onChange(zones.map((z, idx) => (idx === i ? { ...z, ...patch } : z)));
|
||||
};
|
||||
|
||||
const addCode = (i: number, code: string) => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed || zones[i].codes.includes(trimmed)) return;
|
||||
updateZone(i, { codes: [...zones[i].codes, trimmed] });
|
||||
};
|
||||
|
||||
const removeCode = (zoneIdx: number, codeIdx: number) => {
|
||||
updateZone(zoneIdx, {
|
||||
codes: zones[zoneIdx].codes.filter((_, idx) => idx !== codeIdx),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Zones de livraison</Text>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s }]}>
|
||||
Définissez les zones de livraison et leur montant minimum de commande.
|
||||
</Text>
|
||||
|
||||
{zones.map((zone, i) => {
|
||||
const expanded = expandedIndex === i;
|
||||
return (
|
||||
<View key={i} style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||
{/* En-tête zone */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setExpandedIndex(expanded ? null : i)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingVertical: spacing.m,
|
||||
gap: spacing.s,
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={expanded ? "chevron-down" : "chevron-forward"}
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
<Text style={[s.rowLabel, { flex: 1 }]} numberOfLines={1}>
|
||||
{zone.name || "Zone sans nom"}
|
||||
</Text>
|
||||
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "700" }}>
|
||||
{zone.min_amount > 0 ? `${zone.min_amount}€ min` : "Sans min"}
|
||||
</Text>
|
||||
<Text style={{ fontSize: fontSize.xs, color: colors.textMuted, marginLeft: spacing.xs }}>
|
||||
{zone.codes.length} codes
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => removeZone(i)} style={{ marginLeft: spacing.s }}>
|
||||
<Ionicons name="trash-outline" size={18} color={colors.danger} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Détail zone */}
|
||||
{expanded && (
|
||||
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.m }}>
|
||||
{/* Nom */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||
<Text style={[s.thresholdSep, { width: 48 }]}>Nom</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { flex: 1, width: undefined, textAlign: "left", paddingHorizontal: spacing.m }]}
|
||||
value={zone.name}
|
||||
onChangeText={(v) => updateZone(i, { name: v })}
|
||||
placeholder="Ex: Zone centre-ville"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
{/* Minimum */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
|
||||
<Text style={[s.thresholdSep, { width: 48 }]}>Min €</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 80 }]}
|
||||
value={String(zone.min_amount)}
|
||||
onChangeText={(v) => {
|
||||
const n = parseFloat(v);
|
||||
if (!isNaN(n)) updateZone(i, { min_amount: n });
|
||||
}}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
{/* Codes postaux */}
|
||||
<Text style={[s.thresholdSep, { marginBottom: 0 }]}>Codes postaux :</Text>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||
{zone.codes.map((code, ci) => (
|
||||
<TouchableOpacity
|
||||
key={ci}
|
||||
onPress={() => removeCode(i, ci)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.accent + "20",
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: spacing.s,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
|
||||
{code}
|
||||
</Text>
|
||||
<Ionicons name="close" size={12} color={colors.accent} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<PostalCodeInput
|
||||
onAdd={(code) => addCode(i, code)}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{zones.length === 0 && (
|
||||
<Text style={[s.hint, { paddingTop: 0 }]}>Aucune zone définie — toutes les commandes seront acceptées sans minimum.</Text>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={addZone}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingVertical: spacing.m,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={18} color={colors.accent} />
|
||||
<Text style={{ fontSize: fontSize.sm, color: colors.accent, fontWeight: "600" }}>
|
||||
Ajouter une zone
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PostalCodeInput({
|
||||
onAdd,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
onAdd: (code: string) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const [value, setValue] = React.useState("");
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 72, fontSize: fontSize.sm }]}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder="44XXX"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
keyboardType="number-pad"
|
||||
maxLength={5}
|
||||
onSubmitEditing={() => {
|
||||
if (value.length === 5) {
|
||||
onAdd(value);
|
||||
setValue("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (value.length === 5) {
|
||||
onAdd(value);
|
||||
setValue("");
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: borderRadius.sm,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={14} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Composant éditeur de paliers
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -270,6 +490,7 @@ export default function SettingsScreen() {
|
||||
points_total_tiers: [],
|
||||
referral_enabled: true,
|
||||
delivery_schedule: DEFAULT_DELIVERY_SCHEDULE,
|
||||
postal_zones: DEFAULT_POSTAL_ZONES,
|
||||
});
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
@@ -287,6 +508,7 @@ export default function SettingsScreen() {
|
||||
points_categories_total: settingsRes.settings.points_categories_total ?? [],
|
||||
points_total_tiers: settingsRes.settings.points_total_tiers ?? [],
|
||||
delivery_schedule: settingsRes.settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
|
||||
postal_zones: settingsRes.settings.postal_zones ?? DEFAULT_POSTAL_ZONES,
|
||||
});
|
||||
}
|
||||
if (categoriesRes) {
|
||||
@@ -683,6 +905,14 @@ export default function SettingsScreen() {
|
||||
s={s}
|
||||
/>
|
||||
|
||||
{/* Zones de livraison */}
|
||||
<PostalZonesSection
|
||||
zones={settings.postal_zones ?? DEFAULT_POSTAL_ZONES}
|
||||
onChange={(zones) => setSettings((p) => ({ ...p, postal_zones: zones }))}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={s.saveButton}
|
||||
onPress={handleSave}
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from "react";
|
||||
import { TouchableOpacity, Text, StyleSheet } from "react-native";
|
||||
import { spacing, borderRadius, fontSize, fontWeight } from "../theme";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { getCategoryColor } from "../utils/constants";
|
||||
|
||||
interface CategoryPillProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
@@ -18,7 +16,7 @@ export default function CategoryPill({
|
||||
color,
|
||||
}: CategoryPillProps) {
|
||||
const { colors } = useTheme();
|
||||
const catColor = color || getCategoryColor(label, colors);
|
||||
const catColor = color || colors.accent;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { spacing, borderRadius, fontSize, fontWeight } from "../theme";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { getCategoryColor } from "../utils/constants";
|
||||
import { API_BASE_URL } from "../api/client";
|
||||
import { useCart } from "../context/CartContext";
|
||||
|
||||
@@ -38,7 +37,7 @@ interface ProductCardProps {
|
||||
export default function ProductCard({ product, onPress, categoryColor }: ProductCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const { addToCart } = useCart();
|
||||
const catColor = categoryColor ?? getCategoryColor(product.category, colors);
|
||||
const catColor = categoryColor ?? colors.accent;
|
||||
const isSoldOut = product.stock <= 0;
|
||||
const firstPrice = product.prices?.[0]?.price ?? null;
|
||||
|
||||
|
||||
@@ -555,19 +555,6 @@ export default function CheckoutScreen() {
|
||||
<Text style={styles.confirmInfoText}>Livreur</Text>
|
||||
</View>
|
||||
)}
|
||||
{confirmationData?.queue_info && (
|
||||
<View style={styles.confirmInfo}>
|
||||
<Ionicons
|
||||
name="time-outline"
|
||||
size={16}
|
||||
color={colors.warning}
|
||||
/>
|
||||
<Text style={styles.confirmInfoText}>
|
||||
Position: {confirmationData.queue_info.position}{" "}
|
||||
- {confirmationData.queue_info.estimated_wait}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
title="Voir mes commandes"
|
||||
onPress={handleConfirmClose}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useRoute, useNavigation } from "@react-navigation/native";
|
||||
import type { RouteProp } from "@react-navigation/native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { getProductById } from "../../api/api";
|
||||
import { getProductById, getCategories } from "../../api/api";
|
||||
import { useCart } from "../../context/CartContext";
|
||||
import type { Product } from "../../api/api_types";
|
||||
import type { ClientStackParamList } from "../../navigation/types";
|
||||
@@ -22,7 +22,6 @@ import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
import Toast from "../../components/ui/Toast";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||
import { getCategoryColor } from "../../utils/constants";
|
||||
import { API_BASE_URL } from "../../api/client";
|
||||
|
||||
type Route = RouteProp<ClientStackParamList, "ProductDetail">;
|
||||
@@ -43,11 +42,15 @@ export default function ProductDetailScreen() {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showQuantityPicker, setShowQuantityPicker] = useState(false);
|
||||
const [showVideo, setShowVideo] = useState(false);
|
||||
const [catColor, setCatColor] = useState<string>("#7c3aed");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getProductById(params.productId);
|
||||
const [res, categories] = await Promise.all([
|
||||
getProductById(params.productId),
|
||||
getCategories(),
|
||||
]);
|
||||
const p = res?.data || res?.product || res;
|
||||
if (p && p.id) {
|
||||
const fixedProduct = {
|
||||
@@ -63,6 +66,10 @@ export default function ProductDetailScreen() {
|
||||
setSelectedGrams(fixedProduct.prices[0].quantity);
|
||||
setSelectedPrice(fixedProduct.prices[0].price);
|
||||
}
|
||||
const matched = categories.find(
|
||||
(c) => c.name.toLowerCase() === (p.category || "").toLowerCase(),
|
||||
);
|
||||
if (matched?.color) setCatColor(matched.color);
|
||||
} else {
|
||||
setError("Produit introuvable");
|
||||
}
|
||||
@@ -231,7 +238,7 @@ export default function ProductDetailScreen() {
|
||||
borderWidth: 1,
|
||||
borderColor: overlayBorder,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#7c3aed",
|
||||
borderLeftColor: catColor,
|
||||
borderRadius: 12,
|
||||
padding: spacing.xl,
|
||||
},
|
||||
@@ -278,11 +285,11 @@ export default function ProductDetailScreen() {
|
||||
fontWeight: fontWeight.semibold,
|
||||
},
|
||||
addToCartBtn: {
|
||||
backgroundColor: "#7c3aed",
|
||||
backgroundColor: catColor,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 18,
|
||||
alignItems: "center",
|
||||
shadowColor: "#7c3aed",
|
||||
shadowColor: catColor,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 30,
|
||||
@@ -318,7 +325,7 @@ export default function ProductDetailScreen() {
|
||||
borderWidth: 1,
|
||||
borderColor: overlayBorder,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#7c3aed",
|
||||
shadowColor: catColor,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 30,
|
||||
@@ -326,7 +333,7 @@ export default function ProductDetailScreen() {
|
||||
},
|
||||
pickerAccentBar: {
|
||||
height: 3,
|
||||
backgroundColor: "#7c3aed",
|
||||
backgroundColor: catColor,
|
||||
marginHorizontal: -spacing.xl,
|
||||
marginBottom: spacing.l,
|
||||
},
|
||||
@@ -340,7 +347,7 @@ export default function ProductDetailScreen() {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(124,58,237,0.15)",
|
||||
backgroundColor: catColor + "26",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
@@ -399,7 +406,7 @@ export default function ProductDetailScreen() {
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: overlayBorder,
|
||||
shadowColor: "#7c3aed",
|
||||
shadowColor: catColor,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 30,
|
||||
@@ -435,7 +442,7 @@ export default function ProductDetailScreen() {
|
||||
},
|
||||
videoPlayer: { width: "100%", height: 300 },
|
||||
}),
|
||||
[colors, isDark],
|
||||
[colors, isDark, catColor],
|
||||
);
|
||||
|
||||
if (loading) return <LoadingSpinner message="Chargement du produit..." />;
|
||||
@@ -458,7 +465,6 @@ export default function ProductDetailScreen() {
|
||||
|
||||
const isOutOfStock = product.stock === 0;
|
||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||
const catColor = getCategoryColor(product.category, colors);
|
||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||
const imageUri = imageMedia ? `${API_BASE_URL}${imageMedia.url}` : null;
|
||||
|
||||
@@ -34,13 +34,3 @@ export const getStatusColors = (colors: Colors): Record<string, string> => ({
|
||||
offline: colors.textMuted,
|
||||
});
|
||||
|
||||
export const getCategoryColor = (category: string, colors: Colors): string => {
|
||||
const map: Record<string, string> = {
|
||||
"weed&hash": colors.categoryWeedHash,
|
||||
tous: colors.categoryTous,
|
||||
"zipette&co": colors.categoryZipette,
|
||||
"gros&semi": colors.categoryGros,
|
||||
};
|
||||
const key = category?.toLowerCase().trim();
|
||||
return map[key] || colors.accent;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user