chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+468
View File
@@ -0,0 +1,468 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
FlatList,
Image,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from "react-native";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import { useCart } from "../../context/CartContext";
import { getProductById } from "../../api/api";
import type { ClientStackParamList } from "../../navigation/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import Toast from "../../components/ui/Toast";
import { useTheme } from "../../context/ThemeContext";
import {
spacing,
borderRadius,
fontSize,
fontWeight,
shadows,
} from "../../theme";
import { API_BASE_URL } from "../../api/client";
type Nav = NativeStackNavigationProp<ClientStackParamList>;
interface EnrichedItem {
id: number;
product_id: number;
name_product: string;
price: number;
quantity: number;
category: string;
imageUri?: string;
}
export default function CartScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const {
cartItems,
removeFromCart,
clearCart,
cartCount,
cartTotal,
loading,
refreshCart,
toast,
clearToast,
} = useCart();
const [enrichedItems, setEnrichedItems] = useState<EnrichedItem[]>([]);
const [loadingMedia, setLoadingMedia] = useState(false);
const [removeId, setRemoveId] = useState<number | null>(null);
const [showClearModal, setShowClearModal] = useState(false);
useFocusEffect(
useCallback(() => {
refreshCart();
}, [refreshCart]),
);
useEffect(() => {
const enrichItems = async () => {
if (cartItems.length === 0) {
setEnrichedItems([]);
return;
}
setLoadingMedia(true);
const enriched: EnrichedItem[] = await Promise.all(
cartItems.map(async (item) => {
try {
const res = await getProductById(item.product_id);
const p = res?.data || res?.product || res;
const img = p?.media?.find(
(m: any) => m.type === "image",
);
return {
...item,
imageUri: img
? `${API_BASE_URL}${img.url}`
: undefined,
};
} catch {
return { ...item, imageUri: undefined };
}
}),
);
setEnrichedItems(enriched);
setLoadingMedia(false);
};
enrichItems();
}, [cartItems]);
const handleRemove = (id: number) => setRemoveId(id);
const confirmRemove = () => {
if (removeId !== null) removeFromCart(removeId);
setRemoveId(null);
};
const handleClear = () => setShowClearModal(true);
const confirmClear = () => {
clearCart();
setShowClearModal(false);
};
const removeItemName = removeId
? (
enrichedItems.find((i) => i.id === removeId) ||
cartItems.find((i) => i.id === removeId)
)?.name_product || "ce produit"
: "";
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
emptyTitle: {
color: colors.textPrimary,
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
marginTop: spacing.l,
},
emptySubtitle: {
color: colors.textMuted,
fontSize: fontSize.md,
marginTop: spacing.s,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: spacing.l,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerTitle: {
color: colors.textPrimary,
fontSize: fontSize.lg,
fontWeight: fontWeight.semibold,
},
clearBtn: {
color: colors.danger,
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
},
list: {
padding: spacing.l,
paddingBottom: 180,
},
cartItem: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.m,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.borderLight,
},
itemImage: {
width: 60,
height: 60,
borderRadius: borderRadius.sm,
},
itemImagePlaceholder: {
width: 60,
height: 60,
borderRadius: borderRadius.sm,
backgroundColor: colors.bgInput,
justifyContent: "center",
alignItems: "center",
},
itemInfo: {
flex: 1,
marginLeft: spacing.m,
},
itemName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
itemQuantity: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
itemPrice: {
color: colors.success,
fontSize: fontSize.md,
fontWeight: fontWeight.bold,
marginTop: 2,
},
removeBtn: {
padding: spacing.m,
},
modalBody: {
gap: spacing.l,
},
modalIconContainer: {
alignItems: "center",
},
modalIconCircleDanger: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "rgba(239,68,68,0.12)",
justifyContent: "center",
alignItems: "center",
},
modalText: {
color: colors.textPrimary,
fontSize: fontSize.md,
textAlign: "center",
lineHeight: 22,
},
modalBold: {
color: colors.textWhite,
fontWeight: fontWeight.bold,
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: spacing.m,
},
footer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: colors.bgSecondary,
padding: spacing.xl,
borderTopWidth: 1,
borderTopColor: colors.border,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.l,
},
totalLabel: {
color: colors.textSecondary,
fontSize: fontSize.lg,
},
totalValue: {
color: colors.success,
fontSize: fontSize.xxl,
fontWeight: fontWeight.bold,
},
}),
[colors],
);
if (loading && cartItems.length === 0) {
return <LoadingSpinner message="Chargement du panier..." />;
}
return (
<View style={styles.container}>
{toast && (
<Toast
message={toast.message}
type={toast.type}
visible={!!toast}
onHide={clearToast}
/>
)}
{cartItems.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons
name="cart-outline"
size={80}
color={colors.textMuted}
/>
<Text style={styles.emptyTitle}>Panier vide</Text>
<Text style={styles.emptySubtitle}>
Ajoutez des produits pour commencer
</Text>
</View>
) : (
<>
<View style={styles.header}>
<Text style={styles.headerTitle}>
{cartCount} article{cartCount > 1 ? "s" : ""}
</Text>
<TouchableOpacity onPress={handleClear}>
<Text style={styles.clearBtn}>Tout vider</Text>
</TouchableOpacity>
</View>
<FlatList
data={
enrichedItems.length > 0 ? enrichedItems : cartItems
}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refreshCart}
tintColor={colors.accent}
/>
}
renderItem={({ item }) => (
<View style={[styles.cartItem, shadows.sm]}>
{(item as EnrichedItem).imageUri ? (
<Image
source={{
uri: (item as EnrichedItem)
.imageUri,
}}
style={styles.itemImage}
/>
) : (
<View style={styles.itemImagePlaceholder}>
<Ionicons
name="leaf-outline"
size={24}
color={colors.textMuted}
/>
</View>
)}
<View style={styles.itemInfo}>
<Text
style={styles.itemName}
numberOfLines={1}
>
{item.name_product}
</Text>
<Text style={styles.itemQuantity}>
{item.quantity}g
</Text>
<Text style={styles.itemPrice}>
{item.price.toFixed(2)}
</Text>
</View>
<TouchableOpacity
onPress={() => handleRemove(item.id)}
style={styles.removeBtn}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.danger}
/>
</TouchableOpacity>
</View>
)}
/>
<View style={[styles.footer, shadows.lg]}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total</Text>
<Text style={styles.totalValue}>
{cartTotal.toFixed(2)}
</Text>
</View>
<Button
title="Commander"
onPress={() => navigation.navigate("Checkout")}
variant="success"
size="lg"
fullWidth
/>
</View>
</>
)}
<Modal
visible={removeId !== null}
onClose={() => setRemoveId(null)}
title="Supprimer le produit"
icon="trash-outline"
iconColor={colors.danger}
>
<View style={styles.modalBody}>
<View style={styles.modalIconContainer}>
<View style={styles.modalIconCircleDanger}>
<Ionicons
name="trash"
size={28}
color={colors.danger}
/>
</View>
</View>
<Text style={styles.modalText}>
Retirer{" "}
<Text style={styles.modalBold}>{removeItemName}</Text>{" "}
du panier ?
</Text>
<View style={styles.modalActions}>
<Button
title="Annuler"
onPress={() => setRemoveId(null)}
variant="outline"
size="md"
/>
<Button
title="Supprimer"
onPress={confirmRemove}
variant="danger"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={showClearModal}
onClose={() => setShowClearModal(false)}
title="Vider le panier"
icon="cart-outline"
iconColor={colors.danger}
>
<View style={styles.modalBody}>
<View style={styles.modalIconContainer}>
<View style={styles.modalIconCircleDanger}>
<Ionicons
name="cart"
size={28}
color={colors.danger}
/>
</View>
</View>
<Text style={styles.modalText}>
Supprimer les{" "}
<Text style={styles.modalBold}>
{cartCount} article{cartCount > 1 ? "s" : ""}
</Text>{" "}
du panier ?
</Text>
<View style={styles.modalActions}>
<Button
title="Annuler"
onPress={() => setShowClearModal(false)}
variant="outline"
size="md"
/>
<Button
title="Vider"
onPress={confirmClear}
variant="danger"
size="md"
/>
</View>
</View>
</Modal>
</View>
);
}
@@ -0,0 +1,420 @@
import React, { useState, useMemo } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import { useCart } from "../../context/CartContext";
import { checkoutCart } from "../../api/api";
import type { ClientStackParamList } from "../../navigation/types";
import TextInput from "../../components/ui/TextInput";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import { useTheme } from "../../context/ThemeContext";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
type Nav = NativeStackNavigationProp<ClientStackParamList>;
export default function CheckoutScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const { cartItems, cartTotal, clearCart, refreshCart } = useCart();
const [nom, setNom] = useState("");
const [prenom, setPrenom] = useState("");
const [telephone, setTelephone] = useState("");
const [address, setAddress] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationData, setConfirmationData] = useState<any>(null);
const handleCheckout = async () => {
if (!nom.trim()) {
setError("Veuillez saisir votre nom");
return;
}
if (!prenom.trim()) {
setError("Veuillez saisir votre prenom");
return;
}
if (!telephone.trim()) {
setError("Veuillez saisir votre numero de telephone");
return;
}
if (!address.trim()) {
setError("Veuillez saisir une adresse de livraison");
return;
}
if (cartItems.length === 0) {
setError("Votre panier est vide");
return;
}
setError(null);
setLoading(true);
try {
const res = await checkoutCart(
address.trim(),
nom.trim(),
prenom.trim(),
telephone.trim(),
);
if (res.success) {
setConfirmationData(res);
setShowConfirmation(true);
await refreshCart();
} else {
setError(res.message || "Erreur lors de la commande");
}
} catch {
setError("Erreur de connexion au serveur");
} finally {
setLoading(false);
}
};
const handleConfirmClose = () => {
setShowConfirmation(false);
navigation.navigate("ClientTabs");
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
content: { padding: spacing.xl, paddingBottom: spacing.xxxl },
section: { marginBottom: spacing.xl },
sectionTitle: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: spacing.m,
},
fieldGroup: { gap: spacing.m },
row: { flexDirection: "row", gap: spacing.m },
halfField: { flex: 1 },
summaryCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
},
summaryItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.s,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
summaryItemLeft: { flex: 1, marginRight: spacing.m },
summaryItemName: {
color: colors.textPrimary,
fontSize: fontSize.md,
},
summaryItemQty: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
summaryItemPrice: {
color: colors.textPrimary,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
summaryTotal: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingTop: spacing.m,
marginTop: spacing.s,
},
summaryTotalLabel: {
color: colors.textSecondary,
fontSize: fontSize.lg,
fontWeight: fontWeight.semibold,
},
summaryTotalValue: {
color: colors.success,
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
},
errorText: {
color: colors.danger,
fontSize: fontSize.sm,
textAlign: "center",
},
confirmContent: { gap: spacing.m },
confirmIconContainer: { alignItems: "center" },
confirmIconCircle: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: "rgba(74,222,128,0.1)",
justifyContent: "center",
alignItems: "center",
},
confirmText: {
color: colors.textPrimary,
fontSize: fontSize.md,
textAlign: "center",
lineHeight: 22,
},
confirmInfo: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
backgroundColor: colors.bgInput,
padding: spacing.m,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderLight,
},
confirmInfoText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
flex: 1,
},
}),
[colors],
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Resume de la commande
</Text>
<View style={styles.summaryCard}>
{cartItems.map((item) => (
<View key={item.id} style={styles.summaryItem}>
<View style={styles.summaryItemLeft}>
<Text
style={styles.summaryItemName}
numberOfLines={1}
>
{item.name_product}
</Text>
<Text style={styles.summaryItemQty}>
{item.quantity}g
</Text>
</View>
<Text style={styles.summaryItemPrice}>
{item.price.toFixed(2)}
</Text>
</View>
))}
<View style={styles.summaryTotal}>
<Text style={styles.summaryTotalLabel}>Total</Text>
<Text style={styles.summaryTotalValue}>
{cartTotal.toFixed(2)}
</Text>
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Informations personnelles
</Text>
<View style={styles.fieldGroup}>
<View style={styles.row}>
<View style={styles.halfField}>
<TextInput
placeholder="Nom"
value={nom}
onChangeText={(text) => {
setNom(text);
setError(null);
}}
icon={
<Ionicons
name="person-outline"
size={20}
color={colors.textMuted}
/>
}
error={
error && !nom.trim() ? error : undefined
}
/>
</View>
<View style={styles.halfField}>
<TextInput
placeholder="Prenom"
value={prenom}
onChangeText={(text) => {
setPrenom(text);
setError(null);
}}
icon={
<Ionicons
name="person-outline"
size={20}
color={colors.textMuted}
/>
}
error={
error && nom.trim() && !prenom.trim()
? error
: undefined
}
/>
</View>
</View>
<TextInput
placeholder="Numero de telephone"
value={telephone}
onChangeText={(text) => {
setTelephone(text);
setError(null);
}}
icon={
<Ionicons
name="call-outline"
size={20}
color={colors.textMuted}
/>
}
keyboardType="phone-pad"
error={
error &&
nom.trim() &&
prenom.trim() &&
!telephone.trim()
? error
: undefined
}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Adresse de livraison
</Text>
<TextInput
placeholder="Entrez votre adresse complete"
value={address}
onChangeText={(text) => {
setAddress(text);
setError(null);
}}
icon={
<Ionicons
name="location-outline"
size={20}
color={colors.textMuted}
/>
}
error={
error &&
nom.trim() &&
prenom.trim() &&
telephone.trim() &&
!address.trim()
? error
: undefined
}
multiline
numberOfLines={3}
/>
</View>
{error &&
nom.trim() &&
prenom.trim() &&
telephone.trim() &&
address.trim() && (
<Text style={styles.errorText}>{error}</Text>
)}
<Button
title="Valider la commande"
onPress={handleCheckout}
loading={loading}
disabled={loading || cartItems.length === 0}
variant="success"
size="lg"
fullWidth
style={{ marginTop: spacing.l }}
/>
</ScrollView>
<Modal
visible={showConfirmation}
onClose={handleConfirmClose}
title="Commande confirmee !"
icon="checkmark-circle"
iconColor={colors.success}
>
<View style={styles.confirmContent}>
<View style={styles.confirmIconContainer}>
<View style={styles.confirmIconCircle}>
<Ionicons
name="checkmark-circle"
size={48}
color={colors.success}
/>
</View>
</View>
<Text style={styles.confirmText}>
Votre commande #{confirmationData?.command_id} a ete
creee avec succes.
</Text>
{confirmationData?.assigned_to && (
<View style={styles.confirmInfo}>
<Ionicons
name="bicycle-outline"
size={16}
color={colors.info}
/>
<Text style={styles.confirmInfoText}>
Livreur: {confirmationData.assigned_to.username}{" "}
(
{confirmationData.assigned_to.distance_km?.toFixed(
1,
)}{" "}
km)
</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}
variant="primary"
size="md"
fullWidth
style={{ marginTop: spacing.l }}
/>
</View>
</Modal>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,537 @@
import React, { useState, useEffect, useMemo } from "react";
import { View, Text, ScrollView, Image, StyleSheet } from "react-native";
import { useRoute } from "@react-navigation/native";
import type { RouteProp } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
import {
getCommandItemsWithDetails,
getProductById,
formatOrderDate,
formatPrice,
} from "../../api/api";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import { useTheme } from "../../context/ThemeContext";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { API_BASE_URL } from "../../api/client";
type Route = RouteProp<ClientStackParamList, "OrderDetails">;
const TIMELINE_STEPS = [
{
key: "pending",
label: "Confirmee",
icon: "checkmark-circle-outline" as const,
},
{ key: "assigned", label: "Assignee", icon: "person-outline" as const },
{ key: "en_route", label: "En route", icon: "bicycle-outline" as const },
{ key: "arrived", label: "Arrivee", icon: "flag-outline" as const },
{ key: "livre", label: "Livree", icon: "cube-outline" as const },
{
key: "approved",
label: "Terminee",
icon: "shield-checkmark-outline" as const,
},
];
const STATUS_INDEX: Record<string, number> = {
pending: 0,
assigned: 1,
support: 1,
preparing: 1,
en_route: 2,
arrived: 3,
ready: 3,
livre: 4,
delivered: 4,
approved: 5,
cancelled: -1,
};
export default function OrderDetailsScreen() {
const { params } = useRoute<Route>();
const { colors } = useTheme();
const [order, setOrder] = useState<any>(null);
const [products, setProducts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const res = await getCommandItemsWithDetails(params.orderId);
if (res.success && res.data) {
const data = res.data;
const cmd = data.command || data.command_info || data;
if (cmd.command_status && !cmd.status)
cmd.status = cmd.command_status;
if (cmd.command_address && !cmd.delivery_address)
cmd.delivery_address = cmd.command_address;
if (data.client_info) {
cmd.client_nom = cmd.client_nom || data.client_info.nom;
cmd.client_prenom =
cmd.client_prenom || data.client_info.prenom;
cmd.client_telephone =
cmd.client_telephone || data.client_info.telephone;
}
setOrder(cmd);
const items = data.items || data.products || [];
const enriched = await Promise.all(
items.map(async (item: any) => {
try {
const pRes = await getProductById(
item.product_id || item.id,
);
const p = pRes?.data || pRes?.product || pRes;
const img = p?.media?.find(
(m: any) => m.type === "image",
);
return {
...item,
imageUri: img
? `${API_BASE_URL}${img.url}`
: undefined,
};
} catch {
return item;
}
}),
);
setProducts(enriched);
} else {
setError("Commande introuvable");
}
} catch {
setError("Erreur de chargement");
} finally {
setLoading(false);
}
})();
}, [params.orderId]);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
content: { padding: spacing.l, paddingBottom: spacing.xxxl },
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.bgPrimary,
},
errorText: { color: colors.danger, fontSize: fontSize.md },
headerCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.xl,
marginBottom: spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
title: {
color: colors.textWhite,
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
},
date: { color: colors.textMuted, fontSize: fontSize.sm },
sectionTitle: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: spacing.l,
},
timeline: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
timelineStep: {
alignItems: "center",
flex: 1,
position: "relative",
},
timelineDot: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.bgInput,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: colors.border,
zIndex: 1,
},
timelineDotCompleted: {
backgroundColor: colors.success,
borderColor: colors.successDark,
},
timelineDotCurrent: {
backgroundColor: colors.accent,
borderColor: colors.accentLight,
shadowColor: colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 6,
},
timelineLabel: {
color: colors.textMuted,
fontSize: 10,
marginTop: spacing.xs,
textAlign: "center",
},
timelineLabelCompleted: {
color: colors.success,
fontWeight: fontWeight.medium,
},
timelineLabelCurrent: {
color: colors.accent,
fontWeight: fontWeight.bold,
},
timelineLine: {
position: "absolute",
top: 15,
left: "58%",
right: "-42%",
height: 2,
backgroundColor: colors.border,
zIndex: 0,
},
timelineLineCompleted: { backgroundColor: colors.success },
timelineLineCurrent: { backgroundColor: colors.accent },
currentBadge: { marginTop: 4, alignItems: "center" },
currentDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: colors.accent,
},
cancelledBanner: {
alignItems: "center",
gap: spacing.m,
paddingVertical: spacing.l,
},
cancelledIconCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "rgba(239,68,68,0.12)",
justifyContent: "center",
alignItems: "center",
},
cancelledText: {
color: colors.danger,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
infoRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.m,
},
infoText: {
color: colors.textPrimary,
fontSize: fontSize.md,
flex: 1,
},
productRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.m,
},
productRowBorder: {
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
productImage: {
width: 50,
height: 50,
borderRadius: borderRadius.sm,
},
productImagePlaceholder: {
width: 50,
height: 50,
borderRadius: borderRadius.sm,
backgroundColor: colors.bgInput,
justifyContent: "center",
alignItems: "center",
},
productInfo: { flex: 1, marginLeft: spacing.m },
productName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.medium,
},
productQty: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: 2,
},
productPrice: {
color: colors.success,
fontSize: fontSize.md,
fontWeight: fontWeight.bold,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
totalLabel: {
color: colors.textSecondary,
fontSize: fontSize.lg,
fontWeight: fontWeight.semibold,
},
totalValue: {
color: colors.success,
fontSize: fontSize.xxl,
fontWeight: fontWeight.bold,
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement..." />;
if (error || !order) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>
{error || "Commande introuvable"}
</Text>
</View>
);
}
const status = order.status || "pending";
const currentIdx = STATUS_INDEX[status] ?? -1;
const isCancelled = status === "cancelled";
const address = order.delivery_address || order.adresse || "N/A";
const total =
order.total ||
order.total_prix ||
products.reduce((s: number, p: any) => s + (p.prix || p.price || 0), 0);
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
>
<View style={styles.headerCard}>
<View style={styles.headerRow}>
<Text style={styles.title}>Commande #{order.id}</Text>
<StatusBadge status={status} />
</View>
<Text style={styles.date}>
{formatOrderDate(order.created_at)}
</Text>
</View>
<Card style={{ marginBottom: spacing.l }}>
<Text style={styles.sectionTitle}>Suivi</Text>
{isCancelled ? (
<View style={styles.cancelledBanner}>
<View style={styles.cancelledIconCircle}>
<Ionicons
name="close-circle"
size={28}
color={colors.danger}
/>
</View>
<Text style={styles.cancelledText}>
Commande annulee
</Text>
</View>
) : (
<View style={styles.timeline}>
{TIMELINE_STEPS.map((step, idx) => {
const isFinished =
currentIdx >= TIMELINE_STEPS.length - 1;
const completed = isFinished
? true
: idx < currentIdx;
const current = isFinished
? false
: idx === currentIdx;
const lineCompleted = isFinished
? true
: idx < currentIdx;
return (
<View
key={step.key}
style={styles.timelineStep}
>
{idx < TIMELINE_STEPS.length - 1 && (
<View
style={[
styles.timelineLine,
lineCompleted &&
styles.timelineLineCompleted,
current &&
styles.timelineLineCurrent,
]}
/>
)}
<View
style={[
styles.timelineDot,
completed &&
styles.timelineDotCompleted,
current &&
styles.timelineDotCurrent,
]}
>
{completed ? (
<Ionicons
name="checkmark"
size={16}
color={colors.white}
/>
) : (
<Ionicons
name={step.icon}
size={16}
color={
current
? colors.white
: colors.textMuted
}
/>
)}
</View>
<Text
style={[
styles.timelineLabel,
completed &&
styles.timelineLabelCompleted,
current &&
styles.timelineLabelCurrent,
]}
>
{step.label}
</Text>
{current && (
<View style={styles.currentBadge}>
<View style={styles.currentDot} />
</View>
)}
</View>
);
})}
</View>
)}
</Card>
<Card style={{ marginBottom: spacing.l }}>
<Text style={styles.sectionTitle}>Livraison</Text>
<View style={styles.infoRow}>
<Ionicons
name="location-outline"
size={16}
color={colors.textMuted}
/>
<Text style={styles.infoText}>{address}</Text>
</View>
{order.livreur_assign && (
<View style={styles.infoRow}>
<Ionicons
name="bicycle-outline"
size={16}
color={colors.textMuted}
/>
<Text style={styles.infoText}>
{order.livreur_assign}
</Text>
</View>
)}
{(order.client_prenom || order.first_name) && (
<View style={styles.infoRow}>
<Ionicons
name="person-outline"
size={16}
color={colors.textMuted}
/>
<Text style={styles.infoText}>
{order.first_name || order.client_prenom}{" "}
{order.last_name || order.client_nom}
</Text>
</View>
)}
{(order.phone || order.client_telephone) && (
<View style={styles.infoRow}>
<Ionicons
name="call-outline"
size={16}
color={colors.textMuted}
/>
<Text style={styles.infoText}>
{order.phone || order.client_telephone}
</Text>
</View>
)}
</Card>
<Card style={{ marginBottom: spacing.l }}>
<Text style={styles.sectionTitle}>
Produits ({products.length})
</Text>
{products.map((product, idx) => (
<View
key={idx}
style={[
styles.productRow,
idx > 0 && styles.productRowBorder,
]}
>
{product.imageUri ? (
<Image
source={{ uri: product.imageUri }}
style={styles.productImage}
/>
) : (
<View style={styles.productImagePlaceholder}>
<Ionicons
name="leaf-outline"
size={20}
color={colors.textMuted}
/>
</View>
)}
<View style={styles.productInfo}>
<Text style={styles.productName} numberOfLines={1}>
{product.produit ||
product.product_name ||
product.name_product ||
"Produit"}
</Text>
<Text style={styles.productQty}>
{product.quantite || product.quantity || 0}g
</Text>
</View>
<Text style={styles.productPrice}>
{formatPrice(product.prix || product.price || 0)}
</Text>
</View>
))}
</Card>
<Card>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total</Text>
<Text style={styles.totalValue}>{formatPrice(total)}</Text>
</View>
</Card>
</ScrollView>
);
}
@@ -0,0 +1,401 @@
import React, { useState, useCallback, useMemo } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from "react-native";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import {
getMyCompletedOrders,
getMyPenalties,
formatOrderDate,
formatPrice,
} from "../../api/api";
import type {
CompletedOrder,
ClientStats,
PenaltyInfo,
} from "../../api/api_types";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import { useTheme } from "../../context/ThemeContext";
import {
spacing,
borderRadius,
fontSize,
fontWeight,
shadows,
} from "../../theme";
type Nav = NativeStackNavigationProp<ClientStackParamList>;
export default function OrderHistoryScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const [orders, setOrders] = useState<CompletedOrder[]>([]);
const [stats, setStats] = useState<ClientStats | null>(null);
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchData = useCallback(async () => {
try {
const [histRes, penRes] = await Promise.all([
getMyCompletedOrders(),
getMyPenalties(),
]);
if (histRes.success) {
setOrders(histRes.commands || []);
setStats(histRes.client_stats || null);
}
if (penRes.success) {
setPenalties(penRes.data || null);
}
} catch {
/* ignore */
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useFocusEffect(
useCallback(() => {
setLoading(true);
fetchData();
}, [fetchData]),
);
const onRefresh = () => {
setRefreshing(true);
fetchData();
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
listContent: {
padding: spacing.l,
paddingBottom: spacing.xxxl,
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.m,
marginBottom: spacing.xl,
},
statCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
alignItems: "center",
flex: 1,
minWidth: "45%",
borderWidth: 1,
borderColor: colors.borderLight,
},
statValue: {
color: colors.textWhite,
fontSize: fontSize.xxl,
fontWeight: fontWeight.bold,
marginTop: spacing.s,
},
statLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
penaltyBanner: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
padding: spacing.m,
borderRadius: borderRadius.sm,
marginBottom: spacing.xl,
},
penaltyWarning: {
backgroundColor: colors.warning + "22",
borderWidth: 1,
borderColor: colors.warning + "44",
},
penaltyCritical: {
backgroundColor: colors.danger + "22",
borderWidth: 1,
borderColor: colors.danger + "44",
},
penaltyText: {
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
},
sectionTitle: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: spacing.m,
},
emptyContainer: {
alignItems: "center",
paddingTop: spacing.xxxl,
},
emptyTitle: {
color: colors.textPrimary,
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
marginTop: spacing.l,
},
emptySubtitle: {
color: colors.textMuted,
fontSize: fontSize.md,
marginTop: spacing.s,
},
orderCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.borderLight,
},
orderHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.m,
},
orderIdText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
orderRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
orderDetail: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginLeft: spacing.s,
flex: 1,
},
orderFooter: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.m,
paddingTop: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
orderTotal: {
color: colors.success,
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
},
}),
[colors],
);
if (loading && !refreshing)
return <LoadingSpinner message="Chargement historique..." />;
const totalPoints = (stats?.points || 0) + (stats?.points_zipette || 0);
const penaltyCount = penalties?.total_penalty || stats?.penalties || 0;
return (
<View style={styles.container}>
<FlatList
data={orders}
keyExtractor={(item) => String(item.id)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
<View>
<View style={styles.statsGrid}>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="receipt-outline"
size={24}
color={colors.accent}
/>
<Text style={styles.statValue}>
{stats?.total_commands || orders.length}
</Text>
<Text style={styles.statLabel}>Commandes</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="leaf-outline"
size={24}
color={colors.categoryWeedHash}
/>
<Text style={styles.statValue}>
{stats?.points || 0}
</Text>
<Text style={styles.statLabel}>
Pts Weed/Hash
</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="flash-outline"
size={24}
color={colors.info}
/>
<Text style={styles.statValue}>
{stats?.points_zipette || 0}
</Text>
<Text style={styles.statLabel}>
Pts Zipette
</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="trophy-outline"
size={24}
color={colors.warning}
/>
<Text style={styles.statValue}>
{totalPoints}
</Text>
<Text style={styles.statLabel}>
Total Points
</Text>
</View>
</View>
{penaltyCount > 0 && (
<View
style={[
styles.penaltyBanner,
penaltyCount >= 3
? styles.penaltyCritical
: styles.penaltyWarning,
]}
>
<Ionicons
name="warning"
size={20}
color={
penaltyCount >= 3
? colors.danger
: colors.warning
}
/>
<Text
style={[
styles.penaltyText,
{
color:
penaltyCount >= 3
? colors.danger
: colors.warning,
},
]}
>
{penaltyCount} penalite
{penaltyCount > 1 ? "s" : ""}
</Text>
</View>
)}
{orders.length > 0 && (
<Text style={styles.sectionTitle}>
Historique des commandes
</Text>
)}
</View>
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons
name="time-outline"
size={80}
color={colors.textMuted}
/>
<Text style={styles.emptyTitle}>Aucun historique</Text>
<Text style={styles.emptySubtitle}>
Vos commandes terminees apparaitront ici
</Text>
</View>
}
renderItem={({ item: order }) => (
<TouchableOpacity
activeOpacity={0.7}
onPress={() =>
navigation.navigate("OrderDetails", {
orderId: order.id,
})
}
style={[styles.orderCard, shadows.sm]}
>
<View style={styles.orderHeader}>
<Text style={styles.orderIdText}>
Commande #{order.id}
</Text>
<StatusBadge status={order.status} />
</View>
<View style={styles.orderRow}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.orderDetail} numberOfLines={1}>
{order.adresse || "N/A"}
</Text>
</View>
<View style={styles.orderRow}>
<Ionicons
name="time-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.orderDetail}>
{formatOrderDate(order.created_at)}
</Text>
</View>
{order.livreur_assign && (
<View style={styles.orderRow}>
<Ionicons
name="bicycle-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.orderDetail}>
{order.livreur_assign}
</Text>
</View>
)}
<View style={styles.orderFooter}>
<Text style={styles.orderTotal}>
{formatPrice(order.total_prix || 0)}
</Text>
<Ionicons
name="chevron-forward"
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
)}
/>
</View>
);
}
@@ -0,0 +1,660 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
TextInput as RNTextInput,
} from "react-native";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import {
getMyOrders,
getOrderTracking,
getOrderETA,
confirmReception,
cancelCommand,
formatOrderDate,
formatPrice,
calculateOrderTotal,
} from "../../api/api";
import type {
OrderDetail,
TrackingResponse,
ETAResponse,
CancelCommandResponse,
} from "../../api/api_types";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import Toast from "../../components/ui/Toast";
import { useTheme } from "../../context/ThemeContext";
import {
spacing,
borderRadius,
fontSize,
fontWeight,
shadows,
} from "../../theme";
type Nav = NativeStackNavigationProp<ClientStackParamList>;
const STATUS_PROGRESS: Record<string, number> = {
pending: 15,
assigned: 25,
support: 35,
en_route: 60,
arrived: 85,
livre: 95,
approved: 100,
cancelled: 0,
};
export default function OrderTrackingScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const [orders, setOrders] = useState<OrderDetail[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [tracking, setTracking] = useState<Record<number, TrackingResponse>>(
{},
);
const [etas, setEtas] = useState<Record<number, ETAResponse>>({});
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [confirmLoading, setConfirmLoading] = useState(false);
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelLoading, setCancelLoading] = useState(false);
const [penaltyWarning, setPenaltyWarning] =
useState<CancelCommandResponse | null>(null);
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
const [toastMsg, setToastMsg] = useState("");
const [toastType, setToastType] = useState<
"success" | "error" | "warning" | "info"
>("success");
const [toastVisible, setToastVisible] = useState(false);
const showToast = (
msg: string,
type: "success" | "error" | "warning" | "info" = "success",
) => {
setToastMsg(msg);
setToastType(type);
setToastVisible(true);
};
const fetchOrders = useCallback(async () => {
try {
const res = await getMyOrders();
if (res.success) setOrders(res.commands || []);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, []);
useFocusEffect(
useCallback(() => {
setLoading(true);
fetchOrders();
}, [fetchOrders]),
);
useEffect(() => {
const interval = setInterval(fetchOrders, 10000);
return () => clearInterval(interval);
}, [fetchOrders]);
useEffect(() => {
if (expandedId === null) return;
(async () => {
const [trackRes, etaRes] = await Promise.all([
getOrderTracking(expandedId),
getOrderETA(expandedId),
]);
if (trackRes.success)
setTracking((prev) => ({ ...prev, [expandedId]: trackRes }));
if (etaRes.success)
setEtas((prev) => ({ ...prev, [expandedId]: etaRes }));
})();
}, [expandedId]);
const handleConfirm = async (orderId: number) => {
setConfirmLoading(true);
try {
const res = await confirmReception(orderId);
if (res.success) {
showToast(
`Livraison confirmee ! +${res.points_earned || 0} points`,
"success",
);
fetchOrders();
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur de confirmation", "error");
} finally {
setConfirmLoading(false);
setConfirmingId(null);
}
};
const handleCancel = async (orderId: number, force = false) => {
setCancelLoading(true);
try {
const res = await cancelCommand(
orderId,
cancelReason || "Annulation client",
force,
);
if (res.success) {
showToast(res.message || "Commande annulee", "success");
setCancellingId(null);
setCancelReason("");
setPenaltyWarning(null);
fetchOrders();
} else if (res.warning) {
setPenaltyOrderId(orderId);
setPenaltyWarning(res);
setCancellingId(null);
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur annulation", "error");
} finally {
setCancelLoading(false);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
emptyTitle: {
color: colors.textMuted,
fontSize: fontSize.lg,
marginTop: spacing.l,
},
list: { padding: spacing.l, paddingBottom: spacing.xxxl },
card: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.m,
},
orderId: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
progressBarBg: {
height: 4,
backgroundColor: colors.bgInput,
borderRadius: 2,
marginBottom: spacing.m,
overflow: "hidden",
},
progressBarFill: {
height: "100%",
backgroundColor: colors.accent,
borderRadius: 2,
},
cardRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
cardDetail: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginLeft: spacing.s,
flex: 1,
},
cardFooter: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.m,
paddingTop: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
cardTotal: {
color: colors.success,
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
},
expandedSection: {
marginTop: spacing.l,
paddingTop: spacing.l,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
trackRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
},
trackText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
expandedActions: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.s,
marginTop: spacing.l,
},
modalBody: { gap: spacing.m },
modalText: {
color: colors.textPrimary,
fontSize: fontSize.md,
lineHeight: 22,
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: spacing.m,
marginTop: spacing.m,
},
cancelInput: {
backgroundColor: colors.bgInput,
color: colors.textPrimary,
borderRadius: 12,
padding: spacing.m,
fontSize: fontSize.md,
borderWidth: 1,
borderColor: colors.borderLight,
minHeight: 80,
textAlignVertical: "top",
},
penaltyContent: { gap: spacing.l },
penaltyIconContainer: { alignItems: "center" },
penaltyIconCircle: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "rgba(245,158,11,0.12)",
justifyContent: "center",
alignItems: "center",
},
penaltyText: {
color: colors.textPrimary,
fontSize: fontSize.md,
textAlign: "center",
lineHeight: 22,
},
penaltyBadge: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
backgroundColor: "rgba(245,158,11,0.1)",
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: 10,
alignSelf: "center",
},
penaltyDetail: {
color: colors.warning,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
},
}),
[colors],
);
if (loading)
return <LoadingSpinner message="Chargement des commandes..." />;
return (
<View style={styles.container}>
<Toast
message={toastMsg}
type={toastType}
visible={toastVisible}
onHide={() => setToastVisible(false)}
/>
{orders.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons
name="receipt-outline"
size={80}
color={colors.textMuted}
/>
<Text style={styles.emptyTitle}>
Aucune commande active
</Text>
</View>
) : (
<FlatList
data={orders}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
renderItem={({ item: order }) => {
const expanded = expandedId === order.id;
const total = calculateOrderTotal(order);
const progress = STATUS_PROGRESS[order.status] || 0;
const track = tracking[order.id];
const eta = etas[order.id];
const canConfirm = ["livre", "arrived"].includes(
order.status,
);
const canCancel = [
"pending",
"assigned",
"support",
"en_route",
].includes(order.status);
return (
<TouchableOpacity
activeOpacity={0.8}
onPress={() =>
setExpandedId(expanded ? null : order.id)
}
style={[styles.card, shadows.sm]}
>
<View style={styles.cardHeader}>
<Text style={styles.orderId}>
Commande #{order.id}
</Text>
<StatusBadge status={order.status} />
</View>
<View style={styles.progressBarBg}>
<View
style={[
styles.progressBarFill,
{ width: `${progress}%` },
]}
/>
</View>
<View style={styles.cardRow}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={styles.cardDetail}
numberOfLines={1}
>
{order.delivery_address ||
order.adresse ||
"N/A"}
</Text>
</View>
<View style={styles.cardRow}>
<Ionicons
name="time-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.cardDetail}>
{formatOrderDate(order.created_at)}
</Text>
</View>
<View style={styles.cardFooter}>
<Text style={styles.cardTotal}>
{formatPrice(total)}
</Text>
<Ionicons
name={
expanded
? "chevron-up"
: "chevron-down"
}
size={18}
color={colors.textMuted}
/>
</View>
{expanded && (
<View style={styles.expandedSection}>
{track?.livreur_username && (
<View style={styles.trackRow}>
<Ionicons
name="bicycle-outline"
size={16}
color={colors.info}
/>
<Text style={styles.trackText}>
Livreur:{" "}
{track.livreur_username}
</Text>
</View>
)}
{eta?.eta_minutes != null &&
eta.eta_minutes > 0 && (
<View style={styles.trackRow}>
<Ionicons
name="timer-outline"
size={16}
color={colors.warning}
/>
<Text
style={styles.trackText}
>
Temps de livraison
estimé : ~
{eta.eta_minutes} min
</Text>
</View>
)}
{track?.current_step && (
<View style={styles.trackRow}>
<Ionicons
name="footsteps-outline"
size={16}
color={colors.accent}
/>
<Text style={styles.trackText}>
{track.current_step}
</Text>
</View>
)}
<View style={styles.expandedActions}>
<Button
title="Details"
onPress={() =>
navigation.navigate(
"OrderDetails",
{ orderId: order.id },
)
}
variant="outline"
size="sm"
/>
{canConfirm && (
<Button
title="Confirmer reception"
onPress={() =>
setConfirmingId(
order.id,
)
}
variant="success"
size="sm"
/>
)}
{canCancel && (
<Button
title="Annuler"
onPress={() =>
setCancellingId(
order.id,
)
}
variant="danger"
size="sm"
/>
)}
</View>
</View>
)}
</TouchableOpacity>
);
}}
/>
)}
<Modal
visible={confirmingId !== null}
onClose={() => setConfirmingId(null)}
title="Confirmer la reception"
icon="checkmark-circle-outline"
iconColor={colors.success}
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Confirmez-vous avoir recu votre commande #{confirmingId}{" "}
?
</Text>
<View style={styles.modalActions}>
<Button
title="Annuler"
onPress={() => setConfirmingId(null)}
variant="outline"
size="md"
/>
<Button
title="Confirmer"
onPress={() =>
confirmingId && handleConfirm(confirmingId)
}
loading={confirmLoading}
variant="success"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={cancellingId !== null}
onClose={() => {
setCancellingId(null);
setCancelReason("");
}}
title="Annuler la commande"
icon="close-circle-outline"
iconColor={colors.danger}
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Raison de l'annulation :
</Text>
<RNTextInput
style={styles.cancelInput}
placeholder="Raison (optionnel)"
placeholderTextColor={colors.textMuted}
value={cancelReason}
onChangeText={setCancelReason}
multiline
/>
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => {
setCancellingId(null);
setCancelReason("");
}}
variant="outline"
size="md"
/>
<Button
title="Annuler la commande"
onPress={() =>
cancellingId && handleCancel(cancellingId)
}
loading={cancelLoading}
variant="danger"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={penaltyWarning !== null}
onClose={() => {
setPenaltyWarning(null);
setPenaltyOrderId(null);
}}
title="Attention - Penalite"
icon="warning-outline"
iconColor={colors.warning}
>
<View style={styles.penaltyContent}>
<View style={styles.penaltyIconContainer}>
<View style={styles.penaltyIconCircle}>
<Ionicons
name="warning"
size={32}
color={colors.warning}
/>
</View>
</View>
<Text style={styles.penaltyText}>
{penaltyWarning?.message}
</Text>
{penaltyWarning?.penalty_warning && (
<View style={styles.penaltyBadge}>
<Ionicons
name="remove-circle-outline"
size={16}
color={colors.warning}
/>
<Text style={styles.penaltyDetail}>
Penalite:{" "}
{penaltyWarning.penalty_warning.penalty_amount}{" "}
points
</Text>
</View>
)}
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => setPenaltyWarning(null)}
variant="outline"
size="md"
/>
<Button
title="Confirmer l'annulation"
onPress={() => {
if (penaltyOrderId)
handleCancel(penaltyOrderId, true);
setPenaltyWarning(null);
setPenaltyOrderId(null);
}}
variant="danger"
size="md"
/>
</View>
</View>
</Modal>
</View>
);
}
@@ -0,0 +1,732 @@
import React, { useState, useEffect, useMemo } from "react";
import {
View,
Text,
Image,
ScrollView,
StyleSheet,
TouchableOpacity,
Dimensions,
Modal,
Pressable,
} from "react-native";
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 { useCart } from "../../context/CartContext";
import type { Product } from "../../api/api_types";
import type { ClientStackParamList } from "../../navigation/types";
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">;
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export default function ProductDetailScreen() {
const { params } = useRoute<Route>();
const navigation = useNavigation();
const { colors, isDark } = useTheme();
const { addToCart } = useCart();
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(0);
const [adding, setAdding] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [showQuantityPicker, setShowQuantityPicker] = useState(false);
const [showVideo, setShowVideo] = useState(false);
useEffect(() => {
(async () => {
try {
const res = await getProductById(params.productId);
const p = res?.data || res?.product || res;
if (p && p.id) {
const fixedProduct = {
...p,
prices:
p.prices?.map((pr: any) => ({
quantity: parseFloat(String(pr.quantity)),
price: parseFloat(String(pr.price)),
})) || [],
};
setProduct(fixedProduct);
if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity);
setSelectedPrice(fixedProduct.prices[0].price);
}
} else {
setError("Produit introuvable");
}
} catch {
setError("Erreur de chargement");
} finally {
setLoading(false);
}
})();
}, [params.productId]);
const handleGramsChange = (quantity: number) => {
setSelectedGrams(quantity);
const opt = product?.prices?.find((p) => p.quantity === quantity);
if (opt) setSelectedPrice(opt.price);
setShowQuantityPicker(false);
};
const handleAddToCart = async () => {
if (!product || isOutOfStock || selectedGrams === null) return;
setAdding(true);
try {
await addToCart({
product_id: product.id,
name_product: product.name,
category: product.category,
quantity: selectedGrams,
price: selectedPrice,
});
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 2500);
} finally {
setAdding(false);
}
};
const overlayBg = isDark ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)";
const overlayBorder = isDark
? "rgba(255,255,255,0.08)"
: "rgba(0,0,0,0.08)";
const overlayText = isDark ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.85)";
const overlayTextSub = isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.6)";
const overlayBgLight = isDark
? "rgba(255,255,255,0.05)"
: "rgba(0,0,0,0.05)";
const overlayBgDisabled = isDark
? "rgba(255,255,255,0.08)"
: "rgba(0,0,0,0.08)";
const overlayTextDisabled = isDark
? "rgba(255,255,255,0.4)"
: "rgba(0,0,0,0.35)";
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
content: { paddingBottom: spacing.xxxl },
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.bgPrimary,
padding: spacing.xxl,
},
errorTitle: {
color: colors.danger,
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
marginBottom: spacing.xl,
textAlign: "center",
},
backBtn: {
backgroundColor: overlayBgLight,
borderWidth: 1,
borderColor: overlayBorder,
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 24,
},
backBtnText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
imageSection: {
width: "100%",
aspectRatio: 1,
backgroundColor: overlayBg,
borderBottomWidth: 1,
borderBottomColor: overlayBorder,
position: "relative",
overflow: "hidden",
},
imageSectionOut: { opacity: 0.4 },
image: { width: "100%", height: "100%", padding: spacing.l },
imagePlaceholder: {
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
},
soldOutBadge: {
position: "absolute",
top: "50%",
left: "50%",
transform: [
{ translateX: -90 },
{ translateY: -30 },
{ rotate: "-15deg" },
],
backgroundColor: "#ef4444",
borderWidth: 4,
borderColor: colors.white,
paddingHorizontal: 40,
paddingVertical: 16,
elevation: 10,
},
soldOutText: {
color: colors.white,
fontSize: 32,
fontWeight: "900",
letterSpacing: 6,
textTransform: "uppercase",
textShadowColor: "rgba(0,0,0,0.9)",
textShadowOffset: { width: 2, height: 2 },
textShadowRadius: 12,
},
videoBtn: {
position: "absolute",
top: 16,
right: 16,
width: 44,
height: 44,
borderRadius: 22,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
},
infoSection: { padding: spacing.xl, gap: spacing.xl },
productName: {
color: colors.textWhite,
fontSize: 32,
fontWeight: "700",
lineHeight: 36,
letterSpacing: -1,
},
priceRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
priceIndicator: {
width: 4,
height: 28,
backgroundColor: "#10b981",
borderRadius: 2,
},
priceText: {
color: "#10b981",
fontSize: 28,
fontWeight: "800",
},
descriptionCard: {
backgroundColor: overlayBg,
borderWidth: 1,
borderColor: overlayBorder,
borderLeftWidth: 3,
borderLeftColor: "#7c3aed",
borderRadius: 12,
padding: spacing.xl,
},
descriptionTitle: {
color: overlayText,
fontSize: fontSize.lg,
fontWeight: fontWeight.semibold,
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: spacing.m,
},
descriptionText: {
color: overlayTextSub,
fontSize: fontSize.md,
lineHeight: 24,
},
stockSection: {
backgroundColor: overlayBg,
borderRadius: 12,
borderWidth: 1,
borderColor: overlayBorder,
padding: spacing.l,
gap: spacing.m,
},
selectorLabel: {
color: overlayText,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
dropdown: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: overlayBgLight,
borderWidth: 1,
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 16,
},
dropdownDisabled: { opacity: 0.5 },
dropdownText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
addToCartBtn: {
backgroundColor: "#7c3aed",
borderRadius: 12,
paddingVertical: 18,
alignItems: "center",
shadowColor: "#7c3aed",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 30,
elevation: 8,
},
addToCartBtnDisabled: {
backgroundColor: overlayBgDisabled,
shadowOpacity: 0,
elevation: 0,
},
addToCartText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1.5,
},
addToCartTextDisabled: { color: overlayTextDisabled },
pickerOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
pickerContent: {
backgroundColor: colors.bgCard,
borderRadius: 20,
paddingHorizontal: spacing.xl,
paddingBottom: spacing.xl,
width: "100%",
maxWidth: 400,
borderWidth: 1,
borderColor: overlayBorder,
overflow: "hidden",
shadowColor: "#7c3aed",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.15,
shadowRadius: 30,
elevation: 20,
},
pickerAccentBar: {
height: 3,
backgroundColor: "#7c3aed",
marginHorizontal: -spacing.xl,
marginBottom: spacing.l,
},
pickerHeader: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginBottom: spacing.l,
},
pickerIconCircle: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(124,58,237,0.15)",
justifyContent: "center",
alignItems: "center",
},
pickerTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
pickerOption: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: overlayBorder,
marginBottom: spacing.s,
backgroundColor: overlayBg,
},
pickerOptionLeft: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
pickerOptionQty: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
pickerOptionPrice: {
color: colors.textMuted,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
pickerCheckCircle: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1.5,
justifyContent: "center",
alignItems: "center",
},
videoOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.9)",
justifyContent: "center",
alignItems: "center",
padding: 20,
},
videoContent: {
width: "100%",
maxWidth: 500,
backgroundColor: colors.bgSecondary,
borderRadius: 20,
overflow: "hidden",
borderWidth: 1,
borderColor: overlayBorder,
shadowColor: "#7c3aed",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.15,
shadowRadius: 30,
elevation: 20,
},
videoTopBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: overlayBg,
borderBottomWidth: 1,
borderBottomColor: overlayBorder,
},
videoTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
videoTitleText: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "600",
},
videoCloseBtn: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: overlayBgLight,
justifyContent: "center",
alignItems: "center",
},
videoPlayer: { width: "100%", height: 300 },
}),
[colors, isDark],
);
if (loading) return <LoadingSpinner message="Chargement du produit..." />;
if (error || !product) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>
{error || "Produit introuvable"}
</Text>
<TouchableOpacity
style={styles.backBtn}
onPress={() => navigation.goBack()}
>
<Text style={styles.backBtnText}>Retour aux produits</Text>
</TouchableOpacity>
</View>
);
}
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;
const videoUri = videoMedia ? `${API_BASE_URL}${videoMedia.url}` : null;
return (
<View style={styles.container}>
<Toast
message="Produit ajoute au panier !"
type="success"
visible={showSuccess}
onHide={() => setShowSuccess(false)}
/>
<ScrollView contentContainerStyle={styles.content}>
<View
style={[
styles.imageSection,
isOutOfStock && styles.imageSectionOut,
]}
>
{imageUri ? (
<Image
source={{ uri: imageUri }}
style={styles.image}
resizeMode="contain"
/>
) : (
<View
style={[
styles.imagePlaceholder,
{ backgroundColor: catColor + "15" },
]}
>
<Ionicons
name="leaf-outline"
size={80}
color={catColor}
/>
</View>
)}
{isOutOfStock && (
<View style={styles.soldOutBadge}>
<Text style={styles.soldOutText}>SOLD OUT</Text>
</View>
)}
{videoUri && !isOutOfStock && (
<TouchableOpacity
style={[
styles.videoBtn,
{ backgroundColor: catColor + "DD" },
]}
onPress={() => setShowVideo(true)}
>
<Ionicons
name="videocam"
size={20}
color={colors.white}
/>
</TouchableOpacity>
)}
</View>
<View style={styles.infoSection}>
<Text style={styles.productName}>{product.name}</Text>
{selectedPrice > 0 && (
<View style={styles.priceRow}>
<View style={styles.priceIndicator} />
<Text style={styles.priceText}>
{selectedPrice.toFixed(2)} {" "}
{selectedGrams && `pour ${selectedGrams}g`}
</Text>
</View>
)}
<View style={styles.descriptionCard}>
<Text style={styles.descriptionTitle}>Description</Text>
<Text style={styles.descriptionText}>
{product.description ||
"Aucune description disponible."}
</Text>
</View>
{hasValidPrices && (
<View style={styles.stockSection}>
<Text style={styles.selectorLabel}>Quantite:</Text>
<TouchableOpacity
style={[
styles.dropdown,
{ borderColor: catColor },
isOutOfStock && styles.dropdownDisabled,
]}
onPress={() =>
!isOutOfStock && setShowQuantityPicker(true)
}
disabled={isOutOfStock}
activeOpacity={0.7}
>
<Text style={styles.dropdownText}>
{selectedGrams !== null
? `${selectedGrams}g - ${selectedPrice.toFixed(2)}`
: "Choisir une quantite"}
</Text>
<Ionicons
name="chevron-down"
size={18}
color={colors.textWhite}
/>
</TouchableOpacity>
</View>
)}
<TouchableOpacity
style={[
styles.addToCartBtn,
(isOutOfStock || selectedGrams === null) &&
styles.addToCartBtnDisabled,
]}
onPress={handleAddToCart}
disabled={
isOutOfStock || selectedGrams === null || adding
}
activeOpacity={0.7}
>
<Text
style={[
styles.addToCartText,
(isOutOfStock || selectedGrams === null) &&
styles.addToCartTextDisabled,
]}
>
{isOutOfStock
? "Rupture de stock"
: "Ajouter au panier"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
<Modal
visible={showQuantityPicker}
transparent
animationType="fade"
onRequestClose={() => setShowQuantityPicker(false)}
>
<Pressable
style={styles.pickerOverlay}
onPress={() => setShowQuantityPicker(false)}
>
<View style={styles.pickerContent}>
<View style={styles.pickerAccentBar} />
<View style={styles.pickerHeader}>
<View style={styles.pickerIconCircle}>
<Ionicons
name="scale-outline"
size={20}
color={colors.accent}
/>
</View>
<Text style={styles.pickerTitle}>
Choisir une quantite
</Text>
</View>
{product.prices?.map((p, index) => (
<TouchableOpacity
key={p.quantity}
style={[
styles.pickerOption,
selectedGrams === p.quantity && {
backgroundColor: catColor + "18",
borderColor: catColor,
},
index ===
(product.prices?.length || 0) - 1 && {
marginBottom: 0,
},
]}
onPress={() => handleGramsChange(p.quantity)}
activeOpacity={0.7}
>
<View style={styles.pickerOptionLeft}>
<Text
style={[
styles.pickerOptionQty,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.quantity}g
</Text>
<Text
style={[
styles.pickerOptionPrice,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.price.toFixed(2)}
</Text>
</View>
{selectedGrams === p.quantity && (
<View
style={[
styles.pickerCheckCircle,
{
backgroundColor:
catColor + "25",
borderColor: catColor,
},
]}
>
<Ionicons
name="checkmark"
size={16}
color={catColor}
/>
</View>
)}
</TouchableOpacity>
))}
</View>
</Pressable>
</Modal>
<Modal
visible={showVideo}
transparent
animationType="fade"
onRequestClose={() => setShowVideo(false)}
>
<Pressable
style={styles.videoOverlay}
onPress={() => setShowVideo(false)}
>
<View style={styles.videoContent}>
<View style={styles.videoTopBar}>
<View style={styles.videoTitleRow}>
<Ionicons
name="videocam"
size={16}
color={colors.accent}
/>
<Text style={styles.videoTitleText}>
Video du produit
</Text>
</View>
<TouchableOpacity
style={styles.videoCloseBtn}
onPress={() => setShowVideo(false)}
>
<Ionicons
name="close"
size={18}
color={colors.textWhite}
/>
</TouchableOpacity>
</View>
{videoUri && (
<Video
source={{ uri: videoUri }}
style={styles.videoPlayer}
useNativeControls
resizeMode={ResizeMode.CONTAIN}
shouldPlay
/>
)}
</View>
</Pressable>
</Modal>
</View>
);
}
@@ -0,0 +1,209 @@
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import {
View,
FlatList,
ScrollView,
Text,
StyleSheet,
RefreshControl,
Dimensions,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { getAllProducts, getProductsByCategory } from "../../api/api";
import type { Product } from "../../api/api_types";
import ProductCard from "../../components/ProductCard";
import CategoryPill from "../../components/CategoryPill";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import type { ClientStackParamList } from "../../navigation/types";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = SCREEN_WIDTH - 48;
const CATEGORIES = [
{ label: "Tous", value: "tous" },
{ label: "Weed&Hash", value: "weed&hash" },
{ label: "Zipette&Co", value: "zipette&co" },
{ label: "Gros&Semi", value: "gros&semi" },
];
const CATEGORY_TITLES: Record<string, string> = {
tous: "Tous les produits",
"weed&hash": "Weed & Hash",
"zipette&co": "Zipette & Co",
"gros&semi": "Gros & Semi",
};
type Nav = NativeStackNavigationProp<ClientStackParamList>;
export default function ProductsScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const [products, setProducts] = useState<Product[]>([]);
const [selectedCategory, setSelectedCategory] = useState("tous");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const carouselRef = useRef<FlatList>(null);
const fetchProducts = useCallback(async () => {
setError(null);
try {
let response: any;
if (selectedCategory === "tous") {
response = await getAllProducts();
} else {
response = await getProductsByCategory(selectedCategory);
}
const list = response?.data || response?.products || [];
setProducts(Array.isArray(list) ? list : []);
} catch {
setError("Impossible de charger les produits");
setProducts([]);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [selectedCategory]);
useEffect(() => {
setLoading(true);
fetchProducts();
}, [fetchProducts]);
useEffect(() => {
if (carouselRef.current && products.length > 0) {
carouselRef.current.scrollToOffset({ offset: 0, animated: false });
}
}, [products]);
const onRefresh = () => {
setRefreshing(true);
fetchProducts();
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgSecondary },
filtersWrapper: {
paddingVertical: spacing.m,
paddingBottom: spacing.l,
},
filters: { paddingHorizontal: spacing.l, gap: spacing.s },
categoryHeader: {
paddingHorizontal: spacing.xl,
marginBottom: spacing.l,
},
categoryTitle: {
fontSize: 28,
fontWeight: "700",
color: colors.textWhite,
letterSpacing: -0.5,
},
carousel: {
paddingHorizontal: 24,
paddingBottom: spacing.xxxl,
},
cardWrapper: { marginRight: 16 },
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
errorText: {
color: colors.danger,
fontSize: fontSize.md,
textAlign: "center",
},
emptyText: {
color: colors.textMuted,
fontSize: fontSize.md,
textAlign: "center",
},
}),
[colors],
);
if (loading && !refreshing) {
return <LoadingSpinner message="Chargement des produits..." />;
}
return (
<View style={styles.container}>
<View style={styles.filtersWrapper}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filters}
>
{CATEGORIES.map((cat) => (
<CategoryPill
key={cat.value}
label={cat.label}
active={selectedCategory === cat.value}
onPress={() => setSelectedCategory(cat.value)}
/>
))}
</ScrollView>
</View>
<View style={styles.categoryHeader}>
<Text style={styles.categoryTitle}>
{CATEGORY_TITLES[selectedCategory]}
</Text>
</View>
{error ? (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : products.length === 0 ? (
<View style={styles.center}>
<Text style={styles.emptyText}>
Aucun produit disponible dans cette categorie.
</Text>
</View>
) : (
<FlatList
ref={carouselRef}
data={products}
horizontal
pagingEnabled={false}
snapToInterval={CARD_WIDTH + 16}
snapToAlignment="center"
decelerationRate="fast"
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.carousel}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
renderItem={({ item }) => (
<View style={styles.cardWrapper}>
<ProductCard
product={item}
onPress={() =>
navigation.navigate("ProductDetail", {
productId: item.id,
})
}
/>
</View>
)}
/>
)}
</View>
);
}