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
+183
View File
@@ -0,0 +1,183 @@
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import {
getCart, addToCart as apiAddToCart,
removeFromCart as apiRemoveFromCart, clearCart as apiClearCart,
} from '../api/api';
import { getToken } from '../auth/tokenStorage';
import { extractUsernameFromToken } from '../auth/jwtUtils';
export interface CartItem {
id: number;
product_id: number;
name_product: string;
price: number;
quantity: number; // grammes
category: string;
image?: string;
}
interface ToastData {
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
interface CartContextType {
cartItems: CartItem[];
addToCart: (item: Omit<CartItem, 'id'>) => Promise<void>;
removeFromCart: (id: number) => Promise<void>;
clearCart: () => Promise<void>;
cartCount: number;
cartTotal: number;
loading: boolean;
refreshCart: () => Promise<void>;
toast: ToastData | null;
clearToast: () => void;
isAuthenticated: boolean;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export function CartProvider({ children }: { children: ReactNode }) {
const [cartItems, setCartItems] = useState<CartItem[]>([]);
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState<ToastData | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const showToast = (message: string, type: ToastData['type'] = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const clearToast = () => setToast(null);
const getUsername = async (): Promise<string | null> => {
const token = await getToken();
if (!token) return null;
return extractUsernameFromToken(token);
};
const refreshCart = useCallback(async () => {
const username = await getUsername();
if (!username) {
setCartItems([]);
setIsAuthenticated(false);
return;
}
setIsAuthenticated(true);
setLoading(true);
try {
const response = await getCart(username);
if (response.success && response.panier) {
const items: CartItem[] = response.panier.map((item: any) => ({
id: item.id,
product_id: item.product_id,
name_product: item.product_name || item.name_product || 'Produit',
price: item.price,
quantity: item.quantity,
category: (item.category || 'autre').toLowerCase().trim(),
image: item.image,
}));
setCartItems(items);
} else {
setCartItems([]);
}
} catch {
setCartItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshCart();
}, [refreshCart]);
const addToCart = async (item: Omit<CartItem, 'id'>) => {
const username = await getUsername();
if (!username) {
showToast('Vous devez être connecté.', 'warning');
return;
}
if (!item.quantity || item.quantity <= 0) {
showToast('Quantité invalide', 'error');
return;
}
const cleanName = (item.name_product || 'Produit').replace(/\s*\([^)]*\)\s*/g, '').trim();
setLoading(true);
try {
const response = await apiAddToCart({
username,
name_product: cleanName,
category: (item.category || 'autre').toLowerCase().trim(),
quantity: Number(item.quantity),
price: Number(item.price) || 0,
});
if (response.success) {
await refreshCart();
showToast(`${cleanName} (${item.quantity}g) ajouté !`, 'success');
} else {
showToast(response.message || "Erreur lors de l'ajout", 'error');
}
} catch {
showToast("Erreur lors de l'ajout", 'error');
} finally {
setLoading(false);
}
};
const removeFromCart = async (id: number) => {
const username = await getUsername();
if (!username) { showToast('Vous devez être connecté.', 'warning'); return; }
setLoading(true);
try {
const response = await apiRemoveFromCart(id, username);
if (response.success) {
await refreshCart();
showToast('Produit supprimé', 'success');
} else {
showToast(response.message || 'Erreur suppression', 'error');
}
} catch {
showToast('Erreur suppression', 'error');
} finally {
setLoading(false);
}
};
const clearCartAction = async () => {
const username = await getUsername();
if (!username) { showToast('Vous devez être connecté.', 'warning'); return; }
setLoading(true);
try {
const response = await apiClearCart(username);
if (response.success) {
setCartItems([]);
showToast(response.message || 'Panier vidé', 'success');
} else {
showToast(response.message || 'Erreur vidage', 'error');
}
} catch {
showToast('Erreur vidage', 'error');
} finally {
setLoading(false);
}
};
const cartCount = cartItems.length;
const cartTotal = cartItems.reduce((sum, item) => sum + item.price, 0);
return (
<CartContext.Provider value={{
cartItems, addToCart, removeFromCart, clearCart: clearCartAction,
cartCount, cartTotal, loading, refreshCart, toast, clearToast, isAuthenticated,
}}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error('useCart must be used within a CartProvider');
return context;
}
+248
View File
@@ -0,0 +1,248 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
type ReactNode,
} from "react";
import { getClientNotifications, markNotificationsRead } from "../api/api";
import type { ClientNotification } from "../api/api";
import { getToken } from "../auth/tokenStorage";
import {
registerForPushNotificationsAsync,
sendPushTokenToBackend,
setupNotificationChannel,
addNotificationReceivedListener,
addNotificationResponseListener,
setBadgeCount,
getLastNotificationResponse,
} from "../services/pushNotifications";
interface ToastData {
message: string;
type: "success" | "error" | "warning" | "info";
}
interface NotificationContextType {
notifications: ClientNotification[];
unreadCount: number;
markAllRead: () => Promise<void>;
refreshNotifications: () => Promise<void>;
toast: ToastData | null;
clearToast: () => void;
pushToken: string | null;
navigateToOrder: number | null;
clearNavigateToOrder: () => void;
}
const NotificationContext = createContext<NotificationContextType>({
notifications: [],
unreadCount: 0,
markAllRead: async () => {},
refreshNotifications: async () => {},
toast: null,
clearToast: () => {},
pushToken: null,
navigateToOrder: null,
clearNavigateToOrder: () => {},
});
export function useNotifications() {
return useContext(NotificationContext);
}
function getToastType(
notifType: string,
): "success" | "error" | "warning" | "info" {
switch (notifType) {
case "order_confirmed":
case "order_approved":
return "success";
case "order_en_route":
case "order_assigned":
return "info";
case "order_delivered":
return "warning";
default:
return "info";
}
}
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<ClientNotification[]>(
[],
);
const [unreadCount, setUnreadCount] = useState(0);
const [toast, setToast] = useState<ToastData | null>(null);
const [pushToken, setPushToken] = useState<string | null>(null);
const [navigateToOrder, setNavigateToOrder] = useState<number | null>(null);
const seenIdsRef = useRef<Set<string>>(new Set());
const isFirstLoadRef = useRef(true);
const clearToast = useCallback(() => setToast(null), []);
const clearNavigateToOrder = useCallback(
() => setNavigateToOrder(null),
[],
);
const showToast = useCallback(
(message: string, type: ToastData["type"]) => {
setToast({ message, type });
setTimeout(() => setToast(null), 5000);
},
[],
);
// Enregistrement push notifications
useEffect(() => {
let isMounted = true;
const initPush = async () => {
const token = await getToken();
if (!token) return;
// Configurer le channel Android
await setupNotificationChannel();
// Obtenir le push token
const expoPushToken = await registerForPushNotificationsAsync();
if (expoPushToken && isMounted) {
setPushToken(expoPushToken);
// Envoyer au backend
await sendPushTokenToBackend(expoPushToken);
}
// Vérifier si l'app a été ouverte par une notification
const lastResponse = await getLastNotificationResponse();
if (lastResponse && isMounted) {
const data = lastResponse.notification.request.content.data;
if (data?.command_id) {
setNavigateToOrder(data.command_id as number);
}
}
};
initPush();
return () => {
isMounted = false;
};
}, []);
// Listeners pour les notifications push
useEffect(() => {
// Notification reçue en foreground
const receivedSub = addNotificationReceivedListener((notification) => {
const { title, body } = notification.request.content;
const data = notification.request.content.data;
// Afficher un toast pour la notification push en foreground
if (body) {
const notifType = (data?.type as string) || "";
showToast(body, getToastType(notifType));
}
// Rafraîchir la liste des notifications
fetchNotifications();
});
// L'utilisateur tap sur une notification
const responseSub = addNotificationResponseListener((response) => {
const data = response.notification.request.content.data;
if (data?.command_id) {
setNavigateToOrder(data.command_id as number);
}
// Rafraîchir les notifications
fetchNotifications();
});
return () => {
receivedSub.remove();
responseSub.remove();
};
}, [showToast]);
const fetchNotifications = useCallback(async () => {
const token = await getToken();
if (!token) return;
try {
const response = await getClientNotifications();
if (response.success) {
const newNotifs = response.notifications;
setNotifications(newNotifs);
setUnreadCount(response.unread_count);
// Mettre à jour le badge de l'app
await setBadgeCount(response.unread_count);
if (!isFirstLoadRef.current) {
// Détecter les NOUVELLES notifications non lues
for (const notif of newNotifs) {
if (notif.read) continue;
const key = `${notif.command_id}-${notif.type}-${notif.created_at}`;
if (!seenIdsRef.current.has(key)) {
seenIdsRef.current.add(key);
showToast(notif.message, getToastType(notif.type));
}
}
} else {
// Premier chargement : enregistrer tous les IDs sans toast
for (const notif of newNotifs) {
const key = `${notif.command_id}-${notif.type}-${notif.created_at}`;
seenIdsRef.current.add(key);
}
isFirstLoadRef.current = false;
}
}
} catch {
// Silencieux en cas d'erreur
}
}, [showToast]);
const markAllRead = useCallback(async () => {
const token = await getToken();
if (!token) return;
try {
const response = await markNotificationsRead();
if (response.success) {
setUnreadCount(0);
setNotifications((prev) =>
prev.map((n) => ({ ...n, read: true })),
);
// Remettre le badge à 0
await setBadgeCount(0);
}
} catch {
// Silencieux
}
}, []);
// Polling toutes les 15 secondes
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000);
return () => clearInterval(interval);
}, [fetchNotifications]);
return (
<NotificationContext.Provider
value={{
notifications,
unreadCount,
markAllRead,
refreshNotifications: fetchNotifications,
toast,
clearToast,
pushToken,
navigateToOrder,
clearNavigateToOrder,
}}
>
{children}
</NotificationContext.Provider>
);
}
+48
View File
@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useEffect, type ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { darkColors, lightColors, type Colors } from "../theme/colors";
type ThemeMode = "dark" | "light";
interface ThemeContextType {
colors: Colors;
mode: ThemeMode;
toggleTheme: () => void;
isDark: boolean;
}
const STORAGE_KEY = "@theme_mode";
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<ThemeMode>("dark");
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY).then((val) => {
if (val === "light" || val === "dark") setMode(val);
});
}, []);
const toggleTheme = () => {
const next = mode === "dark" ? "light" : "dark";
setMode(next);
AsyncStorage.setItem(STORAGE_KEY, next);
};
const value: ThemeContextType = {
colors: mode === "dark" ? darkColors : lightColors,
mode,
toggleTheme,
isDark: mode === "dark",
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}