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
+659
View File
@@ -0,0 +1,659 @@
import apiClient from "./client";
import type {
ConfirmReceptionResponse,
CheckoutCartResponse,
HistoryResponse,
ETAResponse,
TrackingResponse,
CancelCommandResponse,
PenaltiesResponse,
} from "./api_types";
import { getToken } from "../auth/tokenStorage";
import { extractUsernameFromToken } from "../auth/jwtUtils";
const V1 = "http://172.20.167.237:8080/api/v1";
// ============================================
// AUTH HELPERS
// ============================================
export const getJwtUsername = async (): Promise<string | null> => {
const token = await getToken();
if (!token) return null;
return extractUsernameFromToken(token);
};
// ============================================
// AUTH
// ============================================
export interface AuthResponse {
success: boolean;
message?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
user?: {
id: number;
username: string;
nom?: string;
prenom?: string;
telephone?: string;
role?: string;
session_id?: string;
};
}
export const loginUser = async (
username: string,
password: string,
): Promise<AuthResponse> => {
try {
const { data } = await apiClient.post(`${V1}/auth/login`, {
username,
password,
});
if (!data.access_token) {
return { success: false, message: "Token non reçu du serveur" };
}
return {
success: true,
message: "Connexion réussie",
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
user: data.user,
};
} catch (error: any) {
const msg =
error.response?.data?.error ||
error.message ||
"Erreur de connexion";
return { success: false, message: msg };
}
};
export const registerUser = async (
username: string,
password: string,
nom: string,
prenom: string,
telephone: string,
): Promise<AuthResponse> => {
try {
const { data } = await apiClient.post(`${V1}/auth/register`, {
username,
password,
nom,
prenom,
telephone,
});
if (!data.access_token) {
return { success: false, message: "Token non reçu du serveur" };
}
return {
success: true,
message: "Inscription réussie",
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
user: data.user,
};
} catch (error: any) {
const msg =
error.response?.data?.error ||
error.message ||
"Erreur d'inscription";
return { success: false, message: msg };
}
};
export const logoutUser = async (): Promise<void> => {
try {
await apiClient.post(`${V1}/auth/logout`);
} catch {
/* ignore */
}
};
// ============================================
// PRODUCTS
// ============================================
export const getAllProducts = async () => {
try {
const { data } = await apiClient.get(`${V1}/products`);
return data;
} catch {
return { success: false, data: [] };
}
};
export const getProductsByCategory = async (category: string) => {
try {
const { data } = await apiClient.get(
`${V1}/products/category/${category}`,
);
return data;
} catch {
return { success: false, data: [] };
}
};
export const getProductById = async (id: number) => {
try {
const { data } = await apiClient.get(`${V1}/products/${id}`);
return data;
} catch {
return { success: false, data: null };
}
};
// ============================================
// CART
// ============================================
export const getCart = async (username: string) => {
const jwtUsername = await getJwtUsername();
if (!jwtUsername || jwtUsername !== username) {
return { success: false, message: "Accès refusé", panier: [] };
}
try {
const { data } = await apiClient.get(`${V1}/panier/${jwtUsername}`);
return {
success: true,
panier: data.panier || [],
message: data.message,
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur récupération",
panier: [],
};
}
};
export const addToCart = async (cartItem: {
username?: string;
name_product: string;
category: string;
quantity: number;
price: number;
}) => {
const jwtUsername = await getJwtUsername();
if (!jwtUsername) return { success: false, message: "Session invalide" };
cartItem.username = jwtUsername;
try {
const { data } = await apiClient.post(`${V1}/panier/add`, cartItem);
return {
success: true,
message: "Produit ajouté",
panier: data.panier,
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur ajout",
};
}
};
export const removeFromCart = async (id: number, username: string) => {
const jwtUsername = await getJwtUsername();
if (!jwtUsername || jwtUsername !== username)
return { success: false, message: "Accès refusé" };
try {
const { data } = await apiClient.delete(`${V1}/panier/remove`, {
data: { id, username: jwtUsername },
});
return { success: true, message: "Produit supprimé" };
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur suppression",
};
}
};
export const clearCart = async (username: string) => {
const jwtUsername = await getJwtUsername();
if (!jwtUsername || jwtUsername !== username)
return { success: false, message: "Accès refusé" };
try {
const { data } = await apiClient.delete(`${V1}/panier/clear`);
return {
success: true,
message: data.message || "Panier vidé",
stock_released: data.stock_released,
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur vidage",
};
}
};
// ============================================
// ORDERS
// ============================================
export const getMyOrders = async () => {
try {
const { data } = await apiClient.get(`${V1}/my-commands`);
return { success: true, commands: data.commands || [] };
} catch {
return { success: false, message: "Erreur récupération", commands: [] };
}
};
export const createCheckout = async (checkoutData: {
username?: string;
delivery_address: string;
}) => {
const jwtUsername = await getJwtUsername();
if (!jwtUsername) return { success: false, message: "Session invalide" };
checkoutData.username = jwtUsername;
try {
const { data } = await apiClient.post(`${V1}/checkout`, checkoutData);
return {
success: true,
message: "Commande créée",
command_id: data.command_id,
command: data.command,
delivery_address: data.delivery_address,
assigned_to: data.assigned_to,
queue_info: data.queue_info,
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur création",
};
}
};
export const checkoutCart = async (
delivery_address: string,
nom?: string,
prenom?: string,
telephone?: string,
): Promise<CheckoutCartResponse> => {
const jwtUsername = await getJwtUsername();
if (!jwtUsername) return { success: false, message: "Session invalide" };
if (!delivery_address.trim())
return {
success: false,
message: "Veuillez saisir une adresse de livraison",
};
try {
const payload: Record<string, string> = {
username: jwtUsername,
delivery_address,
};
if (nom) payload.nom = nom;
if (prenom) payload.prenom = prenom;
if (telephone) payload.telephone = telephone;
const { data } = await apiClient.post(`${V1}/checkout`, payload);
return {
success: true,
message: data.message || "Commande validée avec succès",
command_id: data.command_id,
delivery_address: data.delivery_address,
command: data.command,
assigned_to: data.assigned_to,
queue_info: data.queue_info,
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur serveur",
};
}
};
export const approveDelivery = async (
commandId: number,
reqData?: { rating?: number; comment?: string },
) => {
try {
const { data } = await apiClient.post(
`${V1}/commands/${commandId}/approve`,
reqData || {},
);
return {
success: true,
message: "Commande approuvée",
points_earned: data.points_earned,
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur approbation",
};
}
};
export const confirmReception = async (
commandId: number,
): Promise<ConfirmReceptionResponse> => {
try {
const { data } = await apiClient.post(
`${V1}/commands/${commandId}/approve`,
{},
);
return {
success: true,
message: "Commande confirmée avec succès",
points_earned: data.points_earned || 10,
data,
};
} catch (error: any) {
return {
success: false,
message:
error.response?.data?.error || "Erreur lors de la confirmation",
};
}
};
// ============================================
// TRACKING
// ============================================
export const getOrderTracking = async (
commandId: number,
): Promise<TrackingResponse> => {
try {
const { data } = await apiClient.get(
`${V1}/commands/${commandId}/track`,
);
return {
success: true,
command_id: data.id || commandId,
status: data.status || "pending",
current_step: data.current_step || "En attente",
livreur_username: data.livreur_username || data.livreur,
livreur_distance: data.livreur_distance,
eta_minutes: data.eta_minutes,
estimated_arrival: data.estimated_arrival,
delivery_address: data.delivery_address || data.adresse,
location: data.location,
incidents_detected: data.incidents_detected || 0,
updated_at: data.updated_at,
created_at: data.created_at,
message: "Suivi récupéré",
};
} catch (error: any) {
return {
success: false,
command_id: commandId,
status: "error",
current_step: "Erreur",
message: error.response?.data?.error || "Erreur récupération suivi",
};
}
};
export const getOrderETA = async (commandId: number): Promise<ETAResponse> => {
try {
const { data } = await apiClient.get(`${V1}/commands/${commandId}/eta`);
return {
success: true,
command_id: data.id || commandId,
eta_minutes: data.eta_minutes || 0,
estimated_arrival: data.estimated_arrival || "N/A",
status: data.status || "pending",
livreur_distance: data.livreur_distance,
message: "ETA récupéré",
};
} catch {
return {
success: false,
command_id: commandId,
eta_minutes: 0,
estimated_arrival: "N/A",
status: "error",
};
}
};
export const getOrdersWithTracking = async () => {
try {
const { data } = await apiClient.get(`${V1}/my-commands`);
return {
success: true,
commands: data.commands || [],
count: data.count || 0,
};
} catch {
return { success: false, commands: [], message: "Erreur récupération" };
}
};
// ============================================
// HISTORY
// ============================================
export const getMyCompletedOrders = async (): Promise<HistoryResponse> => {
try {
const { data } = await apiClient.get(`${V1}/my-commands/history`);
return {
success: true,
commands: data.commands || [],
count: data.count || 0,
client_stats: data.client_stats,
};
} catch {
return {
success: false,
commands: [],
count: 0,
message: "Erreur récupération",
};
}
};
// ============================================
// CANCEL
// ============================================
export const cancelCommand = async (
commandId: number,
reason?: string,
force = false,
): Promise<CancelCommandResponse> => {
try {
const { data } = await apiClient.post(
`${V1}/commands/${commandId}/cancel`,
{
reason: reason || "Annulation par le client",
force,
},
);
return {
success: true,
message: data.message || "Commande annulée avec succès",
command_id: data.command_id,
new_status: data.new_status,
cancelled_by: data.cancelled_by,
reason: data.reason,
penalty: data.penalty,
info: data.info,
};
} catch (error: any) {
const resp = error.response;
if (resp?.status === 409 && resp?.data?.warning) {
return {
success: false,
warning: true,
message: resp.data.message,
details: resp.data.details,
penalty_warning: resp.data.penalty_warning,
action_required: resp.data.action_required,
};
}
return {
success: false,
message: resp?.data?.error || "Erreur lors de l'annulation",
};
}
};
// ============================================
// PENALTIES
// ============================================
export const getMyPenalties = async (): Promise<PenaltiesResponse> => {
try {
const { data } = await apiClient.get(`${V1}/penalties`);
return { success: true, data: data.data };
} catch {
return { success: false, message: "Erreur récupération" };
}
};
// ============================================
// ORDER DETAILS
// ============================================
export const getOrderDetails = async (commandId: number) => {
try {
const { data } = await apiClient.get(`${V1}/commands/${commandId}`);
return {
success: true,
order: data.command || data,
message: "Commande récupérée",
};
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur de connexion",
order: null,
};
}
};
export const getCommandItemsWithDetails = async (commandId: number) => {
try {
const { data } = await apiClient.get(
`${V1}/commands/${commandId}/items`,
);
return { success: true, data, message: "Items récupérés" };
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur de connexion",
data: null,
};
}
};
// ============================================
// UTILS
// ============================================
export const formatOrderDate = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateString;
}
};
export const getOrderAge = (dateString: string): string => {
try {
const diffMs = Date.now() - new Date(dateString).getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Aujourd'hui";
if (diffDays === 1) return "Hier";
if (diffDays < 7) return `Il y a ${diffDays} jours`;
if (diffDays < 30) {
const w = Math.floor(diffDays / 7);
return `Il y a ${w} semaine${w > 1 ? "s" : ""}`;
}
const m = Math.floor(diffDays / 30);
return `Il y a ${m} mois`;
} catch {
return "Date inconnue";
}
};
export const formatPrice = (price: number): string => `${price.toFixed(2)}`;
// ============================================
// NOTIFICATIONS
// ============================================
export interface ClientNotification {
command_id: number;
type: string;
message: string;
created_at: string;
read: boolean;
}
export interface NotificationsResponse {
success: boolean;
notifications: ClientNotification[];
unread_count: number;
total: number;
message?: string;
}
export const getClientNotifications =
async (): Promise<NotificationsResponse> => {
try {
const { data } = await apiClient.get(`${V1}/notifications`);
return {
success: true,
notifications: data.notifications || [],
unread_count: data.unread_count || 0,
total: data.total || 0,
};
} catch {
return {
success: false,
notifications: [],
unread_count: 0,
total: 0,
message: "Erreur récupération notifications",
};
}
};
export const markNotificationsRead = async (): Promise<{
success: boolean;
marked_count?: number;
}> => {
try {
const { data } = await apiClient.post(`${V1}/notifications/read`);
return { success: true, marked_count: data.marked_count };
} catch {
return { success: false };
}
};
export const calculateOrderTotal = (order: any): number => {
if (typeof order.total === "number" && order.total > 0) return order.total;
if (typeof order.total_prix === "number" && order.total_prix > 0)
return order.total_prix;
if (Array.isArray(order.items) && order.items.length > 0) {
return order.items.reduce((sum: number, item: any) => {
return (
sum +
(item.prix || item.price || 0) *
(item.quantite || item.quantity || 1)
);
}, 0);
}
return 0;
};
+761
View File
@@ -0,0 +1,761 @@
// ============================================
// api/api_TYPES.ts - TOUTES LES INTERFACES
// ============================================
// Interfaces complètes pour le frontend
// À importer dans les composants
// ============================================
// 🔐 AUTHENTIFICATION - TYPES
// ============================================
/**
* Réponse générique de l'API
*/
export interface ApiResponse {
success: boolean;
message?: string;
error?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
user?: UserResponse;
[key: string]: any; // Pour les champs additionnels
}
/**
* Données utilisateur retournées après login/register
*/
export interface UserResponse {
id: number;
username: string;
nom?: string;
prenom?: string;
telephone?: string;
role?: string;
session_id?: string;
command?: number;
point?: number;
point_zipette?: number; // ✅ AJOUTER CETTE LIGNE
amende?: number;
}
/**
* Requête de login
*/
export interface LoginRequest {
username: string;
password: string;
}
/**
* Réponse de login
*/
export interface LoginResponse {
success: boolean;
message?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
user?: UserResponse;
}
/**
* Requête d'enregistrement client
*/
export interface RegisterRequest {
username: string;
password: string;
nom: string;
prenom: string;
telephone: string;
}
/**
* Alternative: RegisterData (utilisée dans Register.tsx)
*/
export interface RegisterData {
username: string;
password: string;
nom: string;
prenom: string;
telephone: string;
}
/**
* Réponse d'enregistrement
*/
export interface RegisterResponse {
success: boolean;
message?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
user?: UserResponse;
}
/**
* Requête d'enregistrement admin
*/
export interface RegisterAdminRequest {
username: string;
password: string;
role: "admin" | "cabine" | "livreur";
}
// ============================================
// 🛒 PANIER - TYPES
// ============================================
/**
* Item du panier côté frontend
*/
export interface CartItem {
id: number;
product_id: number;
name_product: string;
price: number;
quantity: number;
category: string;
image?: string;
}
/**
* Item du panier retourné par l'API
*/
export interface BasketItem {
id: number;
product_id: number;
username: string;
name_product?: string;
product_name?: string;
category: string;
price: number;
quantity: number;
image?: string;
created_at?: string;
updated_at?: string;
}
/**
* Requête d'ajout au panier
*/
export interface AddToBasketRequest {
username: string;
name_product: string;
category: string;
quantity: number;
price: number;
}
/**
* Réponse du panier
*/
export interface BasketResponse {
success: boolean;
message?: string;
panier?: BasketItem[];
data?: {
panier?: BasketItem[];
};
count?: number;
total?: number;
}
/**
* Requête de suppression du panier
*/
export interface RemoveFromBasketRequest {
id: number;
username: string;
}
/**
* Requête de vidage du panier
*/
export interface ClearBasketRequest {
username: string;
}
// ============================================
// 📦 COMMANDES - TYPES
// ============================================
/**
* Requête de checkout
*/
export interface CheckoutRequest {
username: string;
delivery_address: string;
first_name?: string;
last_name?: string;
phone?: string;
payment_method?: string;
}
/**
* Data pour le checkout
*/
export interface CheckoutData {
username?: string;
delivery_address: string;
first_name?: string;
last_name?: string;
phone?: string;
payment_method?: string;
}
/**
* Item de commande
*/
export interface OrderItem {
id: number;
command_id: number;
produit?: string;
product_name?: string;
name_product?: string;
prix?: number;
price?: number;
quantite?: number;
quantity?: number;
category?: string;
image?: string;
}
/**
* Détails d'une commande
*/
export interface OrderDetail {
id: number;
username: string;
status: string;
delivery_address?: string;
adresse?: string;
created_at: string;
updated_at?: string;
total?: number;
total_prix?: number;
livreur_assign?: string;
items?: OrderItem[];
// Infos client enrichies
client_id?: number;
client_prenom?: string;
client_nom?: string;
client_telephone?: string;
// Infos du formulaire checkout
first_name?: string;
last_name?: string;
phone?: string;
// Infos livreur
livreur_username?: string;
livreur_distance?: number;
// Métadonnées
payment_method?: string;
notes?: string;
[key: string]: any;
}
/**
* Réponse des commandes
*/
export interface OrdersResponse {
success: boolean;
message?: string;
commands?: OrderDetail[];
count?: number;
}
/**
* Informations de livraison assignée
*/
export interface AssignedDelivery {
username: string;
distance_km: number;
eta_minutes: number;
last_update?: string;
status?: string;
}
/**
* Position dans la file d'attente
*/
export interface QueueInfo {
position: number;
status: string;
estimated_wait: string;
total_in_queue?: number;
}
/**
* Réponse de checkout
*/
export interface CheckoutResponse {
success: boolean;
message?: string;
command_id?: number;
command?: {
id: number;
status: string;
total?: number;
livreur_assign?: string;
created_at?: string;
};
delivery_address?: string;
assigned_to?: AssignedDelivery;
queue_info?: QueueInfo;
}
/**
* Requête d'approbation de livraison
*/
export interface ApproveDeliveryRequest {
rating?: number;
comment?: string;
}
/**
* Réponse d'approbation
*/
export interface ApproveDeliveryResponse {
success: boolean;
message?: string;
points_earned?: number;
category?: string; // ✅ AJOUTER CETTE LIGNE
points_info?: string; // ✅ AJOUTER CETTE LIGNE
}
// ============================================
// 📍 TRACKING & SUIVI - TYPES
// ============================================
export interface TrackingResponse {
success: boolean;
command_id: number;
status: string;
current_step: string;
livreur?: string;
livreur_username?: string;
livreur_distance?: number;
estimated_arrival?: string;
eta_minutes?: number;
location?: {
latitude: number;
longitude: number;
};
delivery_address?: string;
incidents_detected?: number;
updated_at?: number;
created_at?: string;
message?: string;
[key: string]: any;
}
// ============================================
// 📦 PRODUITS - TYPES
// ============================================
export interface ProductPrice {
quantity: number;
price: number;
}
export interface Product {
id: number;
name: string;
description?: string;
category: string;
stock: number;
prices?: ProductPrice[];
media?: Array<{
// ✅ CHANGÉ
url: string;
type: string;
id?: number;
created_at?: string;
}>;
created_at?: string;
updated_at?: string;
[key: string]: any;
}
export interface ProductsResponse {
success: boolean;
message?: string;
data?: Product[];
products?: Product[];
count?: number;
[key: string]: any;
}
/**
* Réponse produit unique
*/
export interface ProductResponse {
success: boolean;
message?: string;
data?: Product;
product?: Product;
}
// ============================================
// 🔐 SESSION - TYPES
// ============================================
/**
* Données de session côté frontend
*/
export interface SessionData {
token: string;
username: string;
user_id?: number;
session_id?: string;
created_at?: number;
expires_at?: number;
}
/**
* JWT Payload (claims)
*/
export interface JWTPayload {
client_id?: number;
user_id?: number;
username: string;
role: string;
session_id: string;
iat: number;
exp: number;
iss: string;
[key: string]: any;
}
// ============================================
// 🎯 ERROR - TYPES
// ============================================
/**
* Réponse d'erreur API
*/
export interface ErrorResponse {
success: false;
error: string;
message?: string;
details?: string;
status_code?: number;
[key: string]: any;
}
/**
* Erreur de validation
*/
export interface ValidationError {
field: string;
message: string;
}
/**
* Réponse de validation échouée
*/
export interface ValidationErrorResponse {
success: false;
errors: ValidationError[];
message?: string;
}
// ============================================
// 📊 CONTEXTE PANIER - TYPES
// ============================================
/**
* État du contexte panier
*/
export interface CartContextState {
cartItems: CartItem[];
cartCount: number;
cartTotal: number;
loading: boolean;
isAuthenticated: boolean;
error?: string;
}
/**
* Actions du contexte panier
*/
export interface CartContextActions {
addToCart: (item: Omit<CartItem, "id">) => Promise<void>;
removeFromCart: (id: number) => Promise<void>;
updateQuantity: (id: number, quantity: number) => Promise<void>;
clearCart: () => Promise<void>;
refreshCart: () => Promise<void>;
showToast: (
message: string,
type: "success" | "error" | "warning" | "info",
) => void;
}
/**
* Toast notification
*/
export interface ToastMessage {
id: string;
message: string;
type: "success" | "error" | "warning" | "info";
duration?: number;
}
// ============================================
// 🔐 AUTHENTIFICATION UTILS - TYPES
// ============================================
/**
* Résultat de validation du formulaire de login
*/
export interface LoginFormValidation {
valid: boolean;
errors: {
username?: string;
password?: string;
};
}
/**
* Résultat de validation du formulaire d'enregistrement
*/
export interface RegisterFormValidation {
valid: boolean;
errors: {
nom?: string;
prenom?: string;
telephone?: string;
username?: string;
password?: string;
};
}
/**
* État du formulaire de login
*/
export interface LoginFormState {
username: string;
password: string;
}
/**
* État du formulaire d'enregistrement
*/
export interface RegisterFormState {
nom: string;
prenom: string;
telephone: string;
username: string;
password: string;
}
/**
* État du formulaire de checkout
*/
export interface CheckoutFormState {
firstName: string;
lastName: string;
address: string;
phone: string;
paymentMethod: string;
}
export interface CompletedOrder {
id: number;
username: string;
status: string;
adresse: string;
total_prix: number;
livreur_assign?: string;
created_at: string;
updated_at: string;
}
export interface ClientStats {
username: string;
nom?: string;
prenom?: string;
telephone?: string;
total_commands: number;
points: number;
points_zipette: number; // ✅ AJOUTER CETTE LIGNE
penalties: number;
}
export interface HistoryResponse {
success: boolean;
commands: CompletedOrder[];
count: number;
client_stats?: ClientStats;
message?: string;
}
export interface ETAResponse {
success: boolean;
command_id: number;
eta_minutes: number;
estimated_arrival: string;
status: string;
livreur_distance?: number;
message?: string;
[key: string]: any;
}
// ============================================
// 🚫 ANNULATION DE COMMANDES - TYPES
// ============================================
export interface CancelCommandRequest {
reason?: string;
force?: boolean;
}
export interface PenaltyDetails {
points: number;
total_violations: number;
reason: string;
message: string;
}
export interface PenaltyWarning {
will_apply: boolean;
penalty_amount: number;
current_violations: number;
message: string;
scale: {
"1st_cancel": string;
"2nd_cancel": string;
"3rd_cancel": string;
"4th+_cancel": string;
your_next: string;
};
}
export interface CancelCommandResponse {
success: boolean;
message?: string;
warning?: boolean;
command_id?: number;
penalty?: PenaltyDetails;
penalty_warning?: PenaltyWarning;
details?: {
livreur?: string;
status?: string;
position_in_queue?: number;
queue_info?: any;
};
action_required?: string;
example?: any;
new_status?: string;
cancelled_by?: {
username: string;
role: string;
};
reason?: string;
info?: string;
}
export interface CancellationHistoryItem {
command_id: number;
cancelled_at: string;
reason: string;
penalty_applied: number;
had_livreur: boolean;
livreur_username?: string;
}
export interface CancellationHistoryResponse {
success: boolean;
data?: {
username: string;
history: CancellationHistoryItem[];
total_penalties: number;
warning?: string;
info?: string;
};
message?: string;
}
export interface GetOrderDetailsResponse {
success: boolean;
message?: string;
order?: OrderDetailsData | null;
}
export interface OrderDetailsData extends OrderDetail {
products?: OrderProduct[];
phone?: string;
email?: string;
payment_method?: string;
delivery_time?: string;
delivery_notes?: string;
}
export interface OrderProduct {
id: number;
name_product: string;
category: string;
price: number;
quantity: number;
image?: string;
}
export interface PenaltyInfo {
username: string;
total_penalty: number;
cancellations_count: number;
has_penalties: boolean;
points?: number; // ✅ AJOUTER CETTE LIGNE (points weed/hash)
points_zipette?: number; // ✅ AJOUTER CETTE LIGNE (points zipette)
cancellation_history?: {
current_amende: number;
next_penalty: number;
};
}
export interface PenaltiesResponse {
success: boolean;
data?: PenaltyInfo;
message?: string;
}
export interface CheckoutCartData {
username?: string;
delivery_address: string;
}
export interface CheckoutCartResponse {
success: boolean;
message: string;
command_id?: number;
delivery_address?: string;
command?: {
id: number;
status: string;
total: number;
livreur_assign?: string;
created_at: string;
};
assigned_to?: {
username: string;
distance_km: number;
eta_minutes: number;
};
queue_info?: {
position: number;
status: string;
estimated_wait: string;
};
}
export interface ConfirmReceptionResponse {
success: boolean;
message: string;
points_earned?: number;
data?: {
category?: string; // ✅ Catégorie de points ('zipette&co', 'weed&hash', etc.)
points_earned?: number;
[key: string]: any;
};
}
+42
View File
@@ -0,0 +1,42 @@
import axios from "axios";
import { getToken, getAdminToken } from "../auth/tokenStorage";
// Change this to your server IP/domain
export const API_BASE_URL = "http://172.20.167.237:8080";
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 15000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor: attach JWT token
apiClient.interceptors.request.use(async (config) => {
// Determine which token to use based on URL
const isAdminRoute =
config.url?.includes("/api/v2/") ||
config.url?.includes("/api/v1/cabine/") ||
config.url?.includes("/api/v1/livreur/");
const token = isAdminRoute ? await getAdminToken() : await getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: handle common errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.log("Session expirée");
}
return Promise.reject(error);
},
);
export default apiClient;
+149
View File
@@ -0,0 +1,149 @@
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import {
getToken, setToken as storeToken, removeToken,
getAdminToken, setAdminToken as storeAdminToken, removeAdminToken,
setUsername, removeUsername,
setAdminUsername, removeAdminUsername,
setRole as storeRole, getRole, removeRole,
clearAllAuth,
} from './tokenStorage';
import { extractUsernameFromToken, extractRoleFromToken, isTokenExpired } from './jwtUtils';
export type UserRole = 'client' | 'admin' | 'cabine' | 'livreur' | null;
interface AuthState {
token: string | null;
username: string | null;
role: UserRole;
isLoading: boolean;
isAuthenticated: boolean;
}
interface AuthContextType extends AuthState {
loginClient: (token: string) => Promise<void>;
loginAdmin: (token: string, role: 'admin' | 'cabine' | 'livreur') => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
token: null,
username: null,
role: null,
isLoading: true,
isAuthenticated: false,
});
// Restore session on mount
useEffect(() => {
const restore = async () => {
try {
const savedRole = await getRole();
if (savedRole === 'client') {
const token = await getToken();
if (token && !isTokenExpired(token)) {
const uname = extractUsernameFromToken(token);
setState({
token,
username: uname,
role: 'client',
isLoading: false,
isAuthenticated: true,
});
return;
}
} else if (savedRole === 'admin' || savedRole === 'cabine' || savedRole === 'livreur') {
const token = await getAdminToken();
if (token && !isTokenExpired(token)) {
const uname = extractUsernameFromToken(token);
setState({
token,
username: uname,
role: savedRole as UserRole,
isLoading: false,
isAuthenticated: true,
});
return;
}
}
// No valid session
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
});
} catch {
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
});
}
};
restore();
}, []);
const loginClient = async (token: string) => {
const uname = extractUsernameFromToken(token);
await storeToken(token);
if (uname) await setUsername(uname);
await storeRole('client');
setState({
token,
username: uname,
role: 'client',
isLoading: false,
isAuthenticated: true,
});
};
const loginAdmin = async (token: string, role: 'admin' | 'cabine' | 'livreur') => {
const uname = extractUsernameFromToken(token);
await storeAdminToken(token);
if (uname) await setAdminUsername(uname);
await storeRole(role);
setState({
token,
username: uname,
role,
isLoading: false,
isAuthenticated: true,
});
};
const logout = async () => {
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
});
};
return (
<AuthContext.Provider value={{ ...state, loginClient, loginAdmin, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+36
View File
@@ -0,0 +1,36 @@
import { jwtDecode } from 'jwt-decode';
export interface JWTPayload {
client_id?: number;
user_id?: number;
username: string;
role: string;
session_id: string;
iat: number;
exp: number;
iss: string;
}
export const decodeToken = (token: string): JWTPayload | null => {
try {
return jwtDecode<JWTPayload>(token);
} catch {
return null;
}
};
export const extractUsernameFromToken = (token: string): string | null => {
const payload = decodeToken(token);
return payload?.username ?? null;
};
export const extractRoleFromToken = (token: string): string | null => {
const payload = decodeToken(token);
return payload?.role ?? null;
};
export const isTokenExpired = (token: string): boolean => {
const payload = decodeToken(token);
if (!payload) return true;
return Date.now() >= payload.exp * 1000;
};
+43
View File
@@ -0,0 +1,43 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
const TOKEN_KEY = 'token';
const ADMIN_TOKEN_KEY = 'admin_token';
const USERNAME_KEY = 'username';
const ADMIN_USERNAME_KEY = 'admin_username';
const ROLE_KEY = 'user_role';
// Client token
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
export const setToken = (token: string) => AsyncStorage.setItem(TOKEN_KEY, token);
export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY);
// Admin/Cabine/Livreur token
export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY);
export const setAdminToken = (token: string) => AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY);
// Username
export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY);
export const setUsername = (username: string) => AsyncStorage.setItem(USERNAME_KEY, username);
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
// Admin username
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
export const setAdminUsername = (username: string) => AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
export const removeAdminUsername = () => AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
// Role
export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
export const setRole = (role: string) => AsyncStorage.setItem(ROLE_KEY, role);
export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY);
// Clear all auth data
export const clearAllAuth = async () => {
await AsyncStorage.multiRemove([
TOKEN_KEY,
ADMIN_TOKEN_KEY,
USERNAME_KEY,
ADMIN_USERNAME_KEY,
ROLE_KEY,
]);
};
+58
View File
@@ -0,0 +1,58 @@
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;
onPress: () => void;
}
export default function CategoryPill({
label,
active,
onPress,
}: CategoryPillProps) {
const { colors } = useTheme();
const catColor = getCategoryColor(label, colors);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.pill,
active
? { backgroundColor: catColor, borderColor: catColor }
: {
backgroundColor: "transparent",
borderColor: colors.border,
},
]}
>
<Text
style={[
styles.text,
{ color: active ? colors.black : colors.textSecondary },
]}
>
{label}
</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
pill: {
paddingHorizontal: spacing.l,
paddingVertical: spacing.s,
borderRadius: borderRadius.xl,
borderWidth: 1,
marginRight: spacing.s,
},
text: {
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
},
});
+152
View File
@@ -0,0 +1,152 @@
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, borderRadius, fontSize, fontWeight, shadows } from "../theme";
import { useTheme } from "../context/ThemeContext";
import StatusBadge from "./StatusBadge";
import { formatOrderDate, calculateOrderTotal, formatPrice } from "../api/api";
interface OrderCardProps {
order: {
id: number;
status: string;
adresse?: string;
delivery_address?: string;
created_at: string;
total?: number;
total_prix?: number;
livreur_assign?: string;
items?: any[];
};
onPress: () => void;
}
export default function OrderCard({ order, onPress }: OrderCardProps) {
const { colors } = useTheme();
const total = calculateOrderTotal(order);
const address =
order.delivery_address || order.adresse || "Adresse inconnue";
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.card,
shadows.sm,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
]}
>
<View style={styles.header}>
<Text style={[styles.orderId, { color: colors.textWhite }]}>
Commande #{order.id}
</Text>
<StatusBadge status={order.status} />
</View>
<View style={styles.body}>
<View style={styles.row}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={[styles.detail, { color: colors.textSecondary }]}
numberOfLines={1}
>
{address}
</Text>
</View>
<View style={styles.row}>
<Ionicons
name="time-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={[styles.detail, { color: colors.textSecondary }]}
>
{formatOrderDate(order.created_at)}
</Text>
</View>
{order.livreur_assign && (
<View style={styles.row}>
<Ionicons
name="bicycle-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={[
styles.detail,
{ color: colors.textSecondary },
]}
>
{order.livreur_assign}
</Text>
</View>
)}
</View>
<View
style={[styles.footer, { borderTopColor: colors.borderLight }]}
>
<Text style={[styles.total, { color: colors.success }]}>
{formatPrice(total)}
</Text>
<Ionicons
name="chevron-forward"
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: borderRadius.md,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.m,
},
orderId: {
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
body: {
marginBottom: spacing.m,
},
row: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
detail: {
fontSize: fontSize.sm,
marginLeft: spacing.s,
flex: 1,
},
footer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderTopWidth: 1,
paddingTop: spacing.m,
},
total: {
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
},
});
+569
View File
@@ -0,0 +1,569 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
Image,
StyleSheet,
Dimensions,
Modal,
Pressable,
ScrollView,
} from "react-native";
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";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = SCREEN_WIDTH - 48;
interface ProductCardProps {
product: {
id: number;
name: string;
category: string;
stock: number;
prices?: Array<{ quantity: number; price: number }>;
media?: Array<{ url: string; type: string }>;
};
onPress: () => void;
}
export default function ProductCard({ product, onPress }: ProductCardProps) {
const { colors } = useTheme();
const { addToCart } = useCart();
const catColor = getCategoryColor(product.category, colors);
const isSoldOut = product.stock <= 0;
const firstPrice = product.prices?.[0]?.price ?? null;
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;
const [showQuantitySelect, setShowQuantitySelect] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const handleQuickAdd = () => {
if (!isSoldOut && product.prices && product.prices.length > 0) {
setShowQuantitySelect(!showQuantitySelect);
}
};
const handleSelectQuantity = (priceOption: {
quantity: number;
price: number;
}) => {
addToCart({
product_id: product.id,
name_product: product.name,
category: (product.category || "autre").toLowerCase().trim(),
quantity: priceOption.quantity,
price: priceOption.price,
});
setShowSuccess(true);
setShowQuantitySelect(false);
setTimeout(() => setShowSuccess(false), 1500);
};
return (
<View
style={[
styles.card,
{
backgroundColor: colors.bgSecondary,
borderColor: catColor,
shadowColor: catColor,
},
isSoldOut && styles.soldOut,
]}
>
<View
style={[
styles.imageContainer,
{ backgroundColor: colors.bgInput },
]}
>
{imageUri ? (
<Image
source={{ uri: imageUri }}
style={styles.image}
resizeMode="cover"
/>
) : (
<View
style={[
styles.imagePlaceholder,
{ backgroundColor: catColor + "15" },
]}
>
<Ionicons
name="leaf-outline"
size={60}
color={catColor}
/>
</View>
)}
{videoUri && !isSoldOut && (
<TouchableOpacity
style={[
styles.videoBtn,
{ backgroundColor: catColor + "DD" },
]}
onPress={() => setShowVideo(true)}
activeOpacity={0.7}
>
<Ionicons
name="videocam"
size={18}
color={colors.white}
/>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.detailsBtn}
onPress={onPress}
activeOpacity={0.7}
>
<Ionicons
name="information-circle-outline"
size={16}
color={colors.white}
/>
<Text
style={[styles.detailsBtnText, { color: colors.white }]}
>
Details
</Text>
</TouchableOpacity>
{isSoldOut && (
<View style={styles.soldOutOverlay}>
<Text style={styles.soldOutText}>SOLD OUT</Text>
</View>
)}
</View>
<View
style={[
styles.info,
{
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
},
]}
>
<Text
style={[styles.name, { color: colors.textWhite }]}
numberOfLines={1}
>
{product.name}
</Text>
<Text style={[styles.price, { color: colors.success }]}>
{firstPrice !== null
? `${firstPrice.toFixed(2)}`
: "Prix non disponible"}
</Text>
</View>
<View
style={[
styles.quickAddSection,
{
backgroundColor: colors.bgPrimary,
borderTopColor: colors.border,
},
]}
>
{showSuccess ? (
<View
style={[
styles.successBanner,
{ backgroundColor: catColor },
]}
>
<Text
style={[
styles.successText,
{
color: product.category
?.toLowerCase()
.includes("zipette")
? colors.black
: colors.white,
},
]}
>
Ajoute !
</Text>
</View>
) : (
<TouchableOpacity
style={[
styles.quickAddBtn,
{ backgroundColor: catColor },
isSoldOut && {
backgroundColor: colors.textMuted,
opacity: 0.6,
},
]}
onPress={handleQuickAdd}
disabled={isSoldOut}
activeOpacity={0.7}
>
<Text
style={[
styles.quickAddBtnText,
{
color: product.category
?.toLowerCase()
.includes("zipette")
? colors.black
: colors.white,
},
]}
>
{isSoldOut
? "Rupture de stock"
: "Ajouter rapidement"}
</Text>
</TouchableOpacity>
)}
</View>
<Modal
visible={showQuantitySelect}
transparent
animationType="slide"
onRequestClose={() => setShowQuantitySelect(false)}
>
<Pressable
style={styles.pickerOverlay}
onPress={() => setShowQuantitySelect(false)}
>
<View
style={[
styles.pickerSheet,
{ backgroundColor: colors.bgSecondary },
]}
>
<View
style={[
styles.pickerHandle,
{ backgroundColor: colors.textMuted },
]}
/>
<Text
style={[
styles.pickerTitle,
{ color: colors.textWhite },
]}
>
Choisir une quantite
</Text>
<ScrollView
style={styles.pickerScroll}
showsVerticalScrollIndicator={false}
>
{product.prices?.map((p) => (
<TouchableOpacity
key={p.quantity}
style={[
styles.pickerOption,
{
backgroundColor: colors.bgInput,
borderColor: catColor + "44",
},
]}
onPress={() => handleSelectQuantity(p)}
activeOpacity={0.6}
>
<View style={styles.pickerOptionLeft}>
<Text
style={[
styles.pickerOptionQty,
{ color: colors.textWhite },
]}
>
{p.quantity}g
</Text>
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.price.toFixed(2)}
</Text>
</View>
<Ionicons
name="add-circle"
size={28}
color={catColor}
/>
</TouchableOpacity>
))}
</ScrollView>
<TouchableOpacity
style={[
styles.pickerCloseBtn,
{ backgroundColor: colors.border },
]}
onPress={() => setShowQuantitySelect(false)}
>
<Text
style={[
styles.pickerCloseBtnText,
{ color: colors.textSecondary },
]}
>
Fermer
</Text>
</TouchableOpacity>
</View>
</Pressable>
</Modal>
<Modal
visible={showVideo}
transparent
animationType="fade"
onRequestClose={() => setShowVideo(false)}
>
<Pressable
style={styles.videoModalOverlay}
onPress={() => setShowVideo(false)}
>
<View
style={[
styles.videoModalContent,
{ backgroundColor: colors.black },
]}
>
<TouchableOpacity
style={styles.videoCloseBtn}
onPress={() => setShowVideo(false)}
>
<Ionicons
name="close"
size={22}
color={colors.white}
/>
</TouchableOpacity>
{videoUri && (
<Video
source={{ uri: videoUri }}
style={styles.videoPlayer}
useNativeControls
resizeMode={ResizeMode.CONTAIN}
shouldPlay
/>
)}
</View>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 15,
borderWidth: 2,
overflow: "hidden",
width: CARD_WIDTH,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 8,
},
soldOut: { opacity: 0.75 },
imageContainer: {
width: "100%",
aspectRatio: 1,
position: "relative",
overflow: "hidden",
},
image: { width: "100%", height: "100%" },
imagePlaceholder: {
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
},
videoBtn: {
position: "absolute",
top: 12,
right: 12,
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
},
detailsBtn: {
position: "absolute",
bottom: 12,
alignSelf: "center",
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "rgba(0,0,0,0.85)",
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 8,
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
},
detailsBtnText: {
fontSize: 14,
fontWeight: fontWeight.semibold,
letterSpacing: 0.5,
},
soldOutOverlay: {
position: "absolute",
top: "50%",
left: "50%",
transform: [
{ translateX: -80 },
{ translateY: -25 },
{ rotate: "-15deg" },
],
backgroundColor: "rgba(0,0,0,0.8)",
borderWidth: 4,
borderColor: "rgba(255,0,0,0.95)",
paddingHorizontal: 30,
paddingVertical: 12,
},
soldOutText: {
color: "rgba(255,0,0,0.95)",
fontSize: 28,
fontWeight: "900",
letterSpacing: 3,
textTransform: "uppercase",
textShadowColor: "rgba(0,0,0,0.9)",
textShadowOffset: { width: 2, height: 2 },
textShadowRadius: 6,
},
info: { padding: spacing.m, borderTopWidth: 1 },
name: {
fontSize: fontSize.lg,
fontWeight: fontWeight.semibold,
textAlign: "center",
marginBottom: spacing.xs,
},
price: {
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
textAlign: "center",
},
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
quickAddBtn: {
width: "100%",
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: "center",
},
quickAddBtnText: {
fontSize: fontSize.md,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 0.5,
},
successBanner: {
width: "100%",
paddingVertical: 12,
borderRadius: 8,
alignItems: "center",
},
successText: { fontSize: fontSize.md, fontWeight: "700" },
pickerOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "flex-end",
},
pickerSheet: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 24,
paddingBottom: 40,
maxHeight: "70%",
},
pickerHandle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: "center",
marginTop: 12,
marginBottom: 16,
},
pickerTitle: {
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
textAlign: "center",
marginBottom: 16,
},
pickerScroll: { maxHeight: 350 },
pickerOption: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
paddingVertical: 16,
paddingHorizontal: 20,
marginBottom: 10,
},
pickerOptionLeft: { gap: 2 },
pickerOptionQty: { fontSize: fontSize.lg, fontWeight: fontWeight.bold },
pickerOptionPrice: {
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
pickerCloseBtn: {
marginTop: 12,
borderRadius: 10,
paddingVertical: 14,
alignItems: "center",
},
pickerCloseBtnText: {
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
videoModalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.95)",
justifyContent: "center",
alignItems: "center",
padding: 20,
},
videoModalContent: {
width: "100%",
maxWidth: 500,
borderRadius: 12,
overflow: "hidden",
position: "relative",
},
videoCloseBtn: {
position: "absolute",
top: 15,
right: 15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
videoPlayer: { width: "100%", height: 300 },
});
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import Badge from "./ui/Badge";
import { STATUS_LABELS, getStatusColors } from "../utils/constants";
import { useTheme } from "../context/ThemeContext";
interface StatusBadgeProps {
status: string;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const { colors } = useTheme();
const statusColors = getStatusColors(colors);
const label = STATUS_LABELS[status] || status;
const color = statusColors[status] || colors.textMuted;
return <Badge label={label} color={color} />;
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { spacing, borderRadius, fontSize, fontWeight } from '../../theme';
interface BadgeProps {
label: string;
color: string;
textColor?: string;
}
export default function Badge({ label, color, textColor = '#fff' }: BadgeProps) {
return (
<View style={[styles.badge, { backgroundColor: color + '22', borderColor: color }]}>
<Text style={[styles.text, { color }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: spacing.m,
paddingVertical: spacing.xs,
borderRadius: borderRadius.xl,
borderWidth: 1,
alignSelf: 'flex-start',
},
text: {
fontSize: fontSize.xs,
fontWeight: fontWeight.semibold,
},
});
+108
View File
@@ -0,0 +1,108 @@
import React from "react";
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
type ViewStyle,
type TextStyle,
} from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ButtonProps {
title: string;
onPress: () => void;
variant?:
| "primary"
| "secondary"
| "danger"
| "success"
| "outline"
| "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
fullWidth?: boolean;
}
export default function Button({
title,
onPress,
variant = "primary",
size = "md",
loading = false,
disabled = false,
style,
textStyle,
fullWidth = false,
}: ButtonProps) {
const { colors } = useTheme();
const bgColor = {
primary: colors.accent,
secondary: colors.bgCard,
danger: colors.danger,
success: colors.success,
outline: "transparent",
ghost: "transparent",
}[variant];
const txtColor = variant === "success" ? colors.black : colors.textWhite;
const borderColor = variant === "outline" ? colors.border : "transparent";
const paddingV = { sm: spacing.s, md: spacing.m, lg: spacing.l }[size];
const paddingH = { sm: spacing.m, md: spacing.xl, lg: spacing.xxl }[size];
const fSize = { sm: fontSize.sm, md: fontSize.md, lg: fontSize.lg }[size];
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
style={[
styles.base,
{
backgroundColor: bgColor,
borderColor,
paddingVertical: paddingV,
paddingHorizontal: paddingH,
opacity: disabled ? 0.5 : 1,
},
fullWidth && styles.fullWidth,
style,
]}
>
{loading ? (
<ActivityIndicator color={txtColor} size="small" />
) : (
<Text
style={[
styles.text,
{ color: txtColor, fontSize: fSize },
textStyle,
]}
>
{title}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: borderRadius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
flexDirection: "row",
},
fullWidth: {
width: "100%",
},
text: {
fontWeight: fontWeight.semibold,
},
});
+36
View File
@@ -0,0 +1,36 @@
import React, { type ReactNode } from "react";
import { View, StyleSheet, type ViewStyle } from "react-native";
import { spacing, borderRadius, shadows } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CardProps {
children: ReactNode;
style?: ViewStyle;
}
export default function Card({ children, style }: CardProps) {
const { colors } = useTheme();
return (
<View
style={[
styles.card,
shadows.md,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
style,
]}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
},
});
@@ -0,0 +1,39 @@
import React from "react";
import { View, ActivityIndicator, Text, StyleSheet } from "react-native";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface LoadingSpinnerProps {
message?: string;
size?: "small" | "large";
}
export default function LoadingSpinner({
message,
size = "large",
}: LoadingSpinnerProps) {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.bgPrimary }]}>
<ActivityIndicator size={size} color={colors.accent} />
{message && (
<Text style={[styles.text, { color: colors.textSecondary }]}>
{message}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
text: {
fontSize: fontSize.md,
marginTop: spacing.l,
},
});
+195
View File
@@ -0,0 +1,195 @@
import React, { type ReactNode, useEffect, useRef } from "react";
import {
Modal as RNModal,
View,
TouchableOpacity,
Text,
StyleSheet,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ModalProps {
visible: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
}
export default function Modal({
visible,
onClose,
title,
children,
icon,
iconColor,
}: ModalProps) {
const { colors } = useTheme();
const scale = useRef(new Animated.Value(0.85)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 8,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
scale.setValue(0.85);
opacity.setValue(0);
}
}, [visible]);
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.content,
{
backgroundColor: colors.bgSecondary,
borderColor: colors.borderSubtle,
shadowColor: colors.accent,
transform: [{ scale }],
opacity,
},
]}
>
<View
style={[
styles.accentBar,
{ backgroundColor: colors.accent },
]}
/>
<View style={styles.header}>
<View style={styles.titleRow}>
{icon && (
<View
style={[
styles.iconCircle,
{
backgroundColor:
(iconColor || colors.accent) +
"20",
},
]}
>
<Ionicons
name={icon}
size={20}
color={iconColor || colors.accent}
/>
</View>
)}
{title && (
<Text
style={[
styles.title,
{ color: colors.textWhite },
]}
>
{title}
</Text>
)}
</View>
<TouchableOpacity
onPress={onClose}
style={[
styles.closeBtn,
{ backgroundColor: colors.borderSubtle },
]}
activeOpacity={0.7}
>
<Ionicons
name="close"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<View style={styles.body}>{children}</View>
</Animated.View>
</View>
</RNModal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
content: {
borderRadius: 20,
width: "100%",
maxHeight: "80%",
borderWidth: 1,
overflow: "hidden",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.15,
shadowRadius: 30,
elevation: 20,
},
accentBar: {
height: 3,
width: "100%",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing.xl,
paddingTop: spacing.l,
paddingBottom: spacing.m,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
flex: 1,
},
iconCircle: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
},
title: {
fontSize: fontSize.lg,
fontWeight: "700",
flex: 1,
},
closeBtn: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
},
body: {
paddingHorizontal: spacing.xl,
paddingBottom: spacing.xl,
},
});
+94
View File
@@ -0,0 +1,94 @@
import React from "react";
import {
View,
TextInput as RNTextInput,
Text,
StyleSheet,
type TextInputProps,
} from "react-native";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CustomTextInputProps extends TextInputProps {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export default function TextInput({
label,
error,
icon,
style,
...props
}: CustomTextInputProps) {
const { colors } = useTheme();
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
</Text>
)}
<View
style={[
styles.inputWrapper,
{
backgroundColor: colors.bgInput,
borderColor: error ? colors.danger : colors.border,
},
]}
>
{icon && <View style={styles.icon}>{icon}</View>}
<RNTextInput
style={[
styles.input,
{ color: colors.textPrimary },
icon && styles.inputWithIcon,
style,
]}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accent}
{...props}
/>
</View>
{error && (
<Text style={[styles.errorText, { color: colors.danger }]}>
{error}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.l,
},
label: {
fontSize: fontSize.sm,
marginBottom: spacing.s,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderRadius: borderRadius.sm,
borderWidth: 1,
},
icon: {
paddingLeft: spacing.m,
},
input: {
flex: 1,
fontSize: fontSize.md,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
},
inputWithIcon: {
paddingLeft: spacing.s,
},
errorText: {
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
});
+102
View File
@@ -0,0 +1,102 @@
import React, { useEffect, useRef } from "react";
import { Animated, Text, StyleSheet } from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ToastProps {
message: string;
type: "success" | "error" | "warning" | "info";
visible: boolean;
onHide: () => void;
duration?: number;
}
export default function Toast({
message,
type,
visible,
onHide,
duration = 3000,
}: ToastProps) {
const { colors } = useTheme();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-50)).current;
const TYPE_COLORS = {
success: colors.success,
error: colors.danger,
warning: colors.warning,
info: colors.info,
};
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => {
Animated.parallel([
Animated.timing(opacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: -50,
duration: 300,
useNativeDriver: true,
}),
]).start(() => onHide());
}, duration);
return () => clearTimeout(timer);
}
}, [visible]);
if (!visible) return null;
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: TYPE_COLORS[type],
opacity,
transform: [{ translateY }],
},
]}
>
<Text style={[styles.text, { color: colors.black }]}>
{message}
</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
top: 60,
left: spacing.l,
right: spacing.l,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
borderRadius: borderRadius.sm,
zIndex: 9999,
},
text: {
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
textAlign: "center",
},
});
+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;
}
+420
View File
@@ -0,0 +1,420 @@
import React, { useState, useEffect } from "react";
import {
TouchableOpacity,
View,
Text,
Modal,
FlatList,
StyleSheet,
Pressable,
} from "react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useNavigation } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
import { useCart } from "../context/CartContext";
import { useNotifications } from "../context/NotificationContext";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../context/ThemeContext";
import { logoutUser } from "../api/api";
import { removePushTokenFromBackend } from "../services/pushNotifications";
import type { ClientNotification } from "../api/api";
import { fontSize, spacing, borderRadius } from "../theme";
import Toast from "../components/ui/Toast";
import type { ClientTabParamList, ClientStackParamList } from "./types";
import ProductsScreen from "../screens/client/ProductsScreen";
import CartScreen from "../screens/client/CartScreen";
import OrderTrackingScreen from "../screens/client/OrderTrackingScreen";
import OrderHistoryScreen from "../screens/client/OrderHistoryScreen";
import ProductDetailScreen from "../screens/client/ProductDetailScreen";
import CheckoutScreen from "../screens/client/CheckoutScreen";
import OrderDetailsScreen from "../screens/client/OrderDetailsScreen";
const Tab = createBottomTabNavigator<ClientTabParamList>();
const Stack = createNativeStackNavigator<ClientStackParamList>();
function formatNotifDate(dateStr: string): string {
try {
const diffMs = Date.now() - new Date(dateStr).getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "A l'instant";
if (diffMin < 60) return `Il y a ${diffMin} min`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `Il y a ${diffH}h`;
const diffD = Math.floor(diffH / 24);
if (diffD === 1) return "Hier";
return `Il y a ${diffD} jours`;
} catch {
return "";
}
}
function ClientTabs() {
const { cartCount } = useCart();
const {
notifications,
unreadCount,
markAllRead,
toast,
clearToast,
pushToken,
navigateToOrder,
clearNavigateToOrder,
} = useNotifications();
const { logout } = useAuth();
const { colors, isDark, toggleTheme } = useTheme();
const navigation = useNavigation<any>();
const [notifModalVisible, setNotifModalVisible] = useState(false);
// Naviguer vers le suivi quand on tap une push notification
useEffect(() => {
if (navigateToOrder) {
setNotifModalVisible(false);
navigation.navigate("Tracking");
clearNavigateToOrder();
}
}, [navigateToOrder, navigation, clearNavigateToOrder]);
const handleLogout = async () => {
// Supprimer le push token du backend avant logout
if (pushToken) {
await removePushTokenFromBackend(pushToken);
}
await logoutUser();
await logout();
};
const openNotifModal = async () => {
setNotifModalVisible(true);
if (unreadCount > 0) {
await markAllRead();
}
};
const renderNotifItem = ({ item }: { item: ClientNotification }) => {
if (!item) return null;
return (
<View
style={[
styles.notifItem,
{
borderBottomColor: colors.border,
borderLeftColor: item.read
? "transparent"
: colors.accent,
backgroundColor: item.read
? "transparent"
: colors.accent + "10",
},
]}
>
<Text
style={[styles.notifMessage, { color: colors.textPrimary }]}
>
{item.message || "Notification"}
</Text>
<Text style={[styles.notifDate, { color: colors.textMuted }]}>
{item.created_at ? formatNotifDate(item.created_at) : ""}
</Text>
</View>
);
};
return (
<>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
headerRight: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginRight: spacing.l,
gap: spacing.m,
}}
>
{/* Cloche notifications */}
<TouchableOpacity
onPress={openNotifModal}
style={{ position: "relative" }}
>
<Ionicons
name="notifications-outline"
size={24}
color={colors.textSecondary}
/>
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{unreadCount > 9
? "9+"
: unreadCount}
</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity onPress={toggleTheme}>
<Ionicons
name={
isDark
? "sunny-outline"
: "moon-outline"
}
size={22}
color={colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
<Ionicons
name="log-out-outline"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
),
tabBarStyle: {
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: { fontSize: fontSize.xs },
}}
>
<Tab.Screen
name="Products"
component={ProductsScreen}
options={{
title: "Produits",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="leaf-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Cart"
component={CartScreen}
options={{
title: "Panier",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="cart-outline"
size={size}
color={color}
/>
),
tabBarBadge: cartCount > 0 ? cartCount : undefined,
tabBarBadgeStyle: { backgroundColor: colors.accent },
}}
/>
<Tab.Screen
name="Tracking"
component={OrderTrackingScreen}
options={{
title: "Suivi",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="navigate-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="History"
component={OrderHistoryScreen}
options={{
title: "Historique",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="time-outline"
size={size}
color={color}
/>
),
}}
/>
</Tab.Navigator>
{/* Modal notifications */}
<Modal
visible={notifModalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setNotifModalVisible(false)}
>
<View
style={[
styles.modalOverlay,
{ backgroundColor: "rgba(0,0,0,0.5)" },
]}
>
<View
style={[
styles.modalContent,
{ backgroundColor: colors.bgPrimary },
]}
>
<View
style={[
styles.modalHeader,
{ borderBottomColor: colors.border },
]}
>
<Text
style={[
styles.modalTitle,
{ color: colors.textPrimary },
]}
>
Notifications
</Text>
<Pressable
onPress={() => setNotifModalVisible(false)}
>
<Ionicons
name="close"
size={24}
color={colors.textSecondary}
/>
</Pressable>
</View>
<FlatList
data={notifications || []}
keyExtractor={(item, index) => {
if (
item?.command_id &&
item?.type &&
item?.created_at
) {
return `${item.command_id}-${item.type}-${item.created_at}`;
}
return `notif-${index}`;
}}
renderItem={renderNotifItem}
ListEmptyComponent={
<Text
style={[
styles.emptyText,
{ color: colors.textMuted },
]}
>
Aucune notification
</Text>
}
/>
</View>
</View>
</Modal>
<Toast
message={toast?.message || ""}
type={toast?.type || "info"}
visible={!!toast}
onHide={clearToast}
duration={5000}
/>
</>
);
}
export default function ClientNavigator() {
const { colors } = useTheme();
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
}}
>
<Stack.Screen
name="ClientTabs"
component={ClientTabs}
options={{ headerShown: false }}
/>
<Stack.Screen
name="ProductDetail"
component={ProductDetailScreen}
options={{ title: "Detail produit" }}
/>
<Stack.Screen
name="Checkout"
component={CheckoutScreen}
options={{ title: "Validation commande" }}
/>
<Stack.Screen
name="OrderDetails"
component={OrderDetailsScreen}
options={{ title: "Detail commande" }}
/>
</Stack.Navigator>
);
}
const styles = StyleSheet.create({
notifItem: {
padding: spacing.m,
borderBottomWidth: 1,
borderLeftWidth: 3,
},
notifMessage: {
fontSize: fontSize.sm,
marginBottom: spacing.xs,
},
notifDate: {
fontSize: fontSize.xs,
},
badge: {
position: "absolute",
top: -4,
right: -4,
backgroundColor: "#FF3B30",
borderRadius: 10,
minWidth: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "bold",
},
modalOverlay: {
flex: 1,
justifyContent: "flex-end",
},
modalContent: {
height: "70%",
borderTopLeftRadius: borderRadius.lg,
borderTopRightRadius: borderRadius.lg,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: spacing.l,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: fontSize.lg,
fontWeight: "bold",
},
emptyText: {
textAlign: "center",
marginTop: spacing.xl,
fontSize: fontSize.sm,
},
});
+52
View File
@@ -0,0 +1,52 @@
export type RootStackParamList = {
home: undefined;
login: undefined;
register: undefined;
role: undefined;
};
export type AuthStackParamList = {
RoleSelect: undefined;
ClientLogin: undefined;
AdminLogin: undefined;
CabineLogin: undefined;
LivreurLogin: undefined;
Register: undefined;
};
export type ClientTabParamList = {
Products: undefined;
Cart: undefined;
Tracking: undefined;
History: undefined;
};
export type ClientStackParamList = {
ClientTabs: undefined;
ProductDetail: { productId: number };
Checkout: undefined;
OrderDetails: { orderId: number };
};
export type DeliveryTabParamList = {
Dashboard: undefined;
Stats: undefined;
Alerts: undefined;
};
export type AdminTabParamList = {
Dashboard: undefined;
Orders: undefined;
Users: undefined;
Products: undefined;
Delivery: undefined;
Alerts: undefined;
};
export type CabineTabParamList = {
Dashboard: undefined;
Orders: undefined;
Delivery: undefined;
Users: undefined;
Alerts: undefined;
};
+93
View File
@@ -0,0 +1,93 @@
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/types";
import { useTheme } from "../context/ThemeContext";
type Props = NativeStackScreenProps<RootStackParamList, "home">;
export default function HomeScreen({ navigation }: Props) {
const { colors } = useTheme();
return (
<View
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
>
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
<Text style={[styles.title, { color: colors.textWhite }]}>
Bienvenue sur l'app !
</Text>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
Connectez-vous ou créez un compte pour continuer
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate("login")}
>
<Text style={styles.buttonText}>Se connecter</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.registerButton]}
onPress={() => navigation.navigate("register")}
>
<Text
style={[styles.buttonText, styles.registerButtonText]}
>
Créer un compte
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
card: {
width: "100%",
maxWidth: 400,
borderRadius: 16,
padding: 24,
alignItems: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 8,
textAlign: "center",
},
subtitle: {
fontSize: 14,
marginBottom: 24,
textAlign: "center",
},
button: {
backgroundColor: "#7c3aed",
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 24,
alignItems: "center",
width: "100%",
marginBottom: 12,
},
registerButton: {
backgroundColor: "transparent",
borderWidth: 1,
borderColor: "#7c3aed",
},
buttonText: {
color: "white",
fontWeight: "600",
fontSize: 16,
},
registerButtonText: {
color: "#7c3aed",
},
});
@@ -0,0 +1,280 @@
import React, { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from "react-native";
import { Feather, FontAwesome } from "@expo/vector-icons";
import { loginUser } from "../../api/api";
import { useAuth } from "../../auth/AuthContext";
import type { LoginRequest } from "../../api/api_types";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../../navigation/types";
import { useTheme } from "../../context/ThemeContext";
type LoginScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
"login"
>;
const LoginClient = () => {
const navigation = useNavigation<LoginScreenNavigationProp>();
const { loginClient } = useAuth();
const { colors } = useTheme();
const [formData, setFormData] = useState<LoginRequest>({
username: "",
password: "",
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{
username?: string;
password?: string;
}>({});
const [apiError, setApiError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const validateForm = () => {
const newErrors: { username?: string; password?: string } = {};
if (!formData.username.trim()) newErrors.username = "Username requis";
else if (formData.username.trim().length < 3)
newErrors.username = "Username trop court";
if (!formData.password) newErrors.password = "Mot de passe requis";
else if (formData.password.length < 6)
newErrors.password = "Mot de passe trop court";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) return;
setIsLoading(true);
setApiError("");
try {
const result = await loginUser(
formData.username,
formData.password,
);
if (result.success && result.access_token) {
await loginClient(result.access_token);
} else {
const errorMessage =
result.message || "Identifiants incorrects";
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Erreur de connexion";
setApiError(message);
setErrors({ username: message });
} finally {
setIsLoading(false);
}
};
const handleChange = (name: "username" | "password", value: string) => {
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name]) setErrors((prev) => ({ ...prev, [name]: undefined }));
if (apiError) setApiError("");
};
return (
<View
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
>
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
<View style={styles.header}>
<FontAwesome name="user-circle" size={48} color="#7c3aed" />
<Text style={[styles.title, { color: colors.textWhite }]}>
Connexion Client
</Text>
<Text
style={[styles.subtitle, { color: colors.textMuted }]}
>
Accédez à votre espace personnel
</Text>
</View>
{apiError ? (
<Text style={styles.apiError}> {apiError}</Text>
) : null}
<View style={styles.inputGroup}>
<Text
style={[styles.label, { color: colors.textSecondary }]}
>
Username
</Text>
<View
style={[
styles.inputWrapper,
{
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
]}
>
<Feather
name="user"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{ color: colors.textWhite },
errors.username && styles.inputError,
]}
placeholder="Votre username"
placeholderTextColor={colors.textMuted}
value={formData.username}
onChangeText={(value) =>
handleChange("username", value)
}
editable={!isLoading}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{errors.username && (
<Text style={styles.errorText}>{errors.username}</Text>
)}
</View>
<View style={styles.inputGroup}>
<Text
style={[styles.label, { color: colors.textSecondary }]}
>
Mot de passe
</Text>
<View
style={[
styles.inputWrapper,
{
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
]}
>
<Feather
name="lock"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{ color: colors.textWhite },
errors.password && styles.inputError,
]}
placeholder="••••••"
placeholderTextColor={colors.textMuted}
secureTextEntry={!showPassword}
value={formData.password}
onChangeText={(value) =>
handleChange("password", value)
}
editable={!isLoading}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.eyeButton}
onPress={() => setShowPassword(!showPassword)}
>
<Feather
name={showPassword ? "eye-off" : "eye"}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
{errors.password && (
<Text style={styles.errorText}>{errors.password}</Text>
)}
</View>
<TouchableOpacity
style={[styles.submitButton, isLoading && { opacity: 0.6 }]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.submitText}>Se connecter</Text>
)}
</TouchableOpacity>
<View style={styles.signup}>
<Text
style={[styles.signupText, { color: colors.textMuted }]}
>
Pas encore de compte ?{" "}
<Text
style={styles.signupLink}
onPress={() => navigation.navigate("register")}
>
Créer un compte
</Text>
</Text>
</View>
</View>
</View>
);
};
export default LoginClient;
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
card: { width: "100%", maxWidth: 400, borderRadius: 16, padding: 24 },
header: { alignItems: "center", marginBottom: 24 },
title: { fontSize: 24, fontWeight: "bold", marginTop: 8 },
subtitle: { fontSize: 14, marginTop: 4, textAlign: "center" },
apiError: {
backgroundColor: "#fee2e2",
color: "#991b1b",
padding: 12,
borderRadius: 6,
marginBottom: 16,
textAlign: "center",
},
inputGroup: { marginBottom: 16 },
label: { marginBottom: 4, fontWeight: "500" },
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
},
icon: { marginRight: 8 },
input: { flex: 1, height: 40 },
inputError: { borderColor: "#ef4444" },
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
eyeButton: { padding: 4 },
submitButton: {
backgroundColor: "#7c3aed",
borderRadius: 8,
padding: 12,
alignItems: "center",
marginTop: 8,
},
submitText: { color: "white", fontWeight: "600", fontSize: 16 },
signup: { marginTop: 16, alignItems: "center" },
signupText: {},
signupLink: { color: "#a78bfa", fontWeight: "600" },
});
+328
View File
@@ -0,0 +1,328 @@
import React, { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
ActivityIndicator,
} from "react-native";
import { Feather, FontAwesome } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../../navigation/types";
import { registerUser } from "../../api/api";
import { useAuth } from "../../auth/AuthContext";
import { useTheme } from "../../context/ThemeContext";
type RegisterScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
"register"
>;
const RegisterScreen = () => {
const navigation = useNavigation<RegisterScreenNavigationProp>();
const { loginClient } = useAuth();
const { colors } = useTheme();
const [formData, setFormData] = useState({
nom: "",
prenom: "",
telephone: "",
username: "",
password: "",
});
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [apiError, setApiError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleChange = (name: string, value: string) => {
setFormData((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => ({ ...prev, [name]: undefined }));
if (apiError) setApiError("");
};
const validateForm = () => {
const newErrors: typeof errors = {};
if (!formData.nom.trim()) newErrors.nom = "Le nom est requis";
if (!formData.prenom.trim()) newErrors.prenom = "Le prénom est requis";
const cleanPhone = formData.telephone.replace(/\s/g, "");
if (!formData.telephone.trim())
newErrors.telephone = "Le numéro de téléphone est requis";
else if (!/^[0-9+]{10,15}$/.test(cleanPhone))
newErrors.telephone =
"Numéro de téléphone invalide (10-15 chiffres)";
if (!formData.username.trim())
newErrors.username = "Le nom d'utilisateur est requis";
else if (formData.username.length < 3)
newErrors.username = "Au moins 3 caractères requis";
if (!formData.password)
newErrors.password = "Le mot de passe est requis";
else if (formData.password.length < 8)
newErrors.password = "Au moins 8 caractères requis";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleRegister = async () => {
if (!validateForm()) return;
setIsLoading(true);
setApiError("");
try {
const result = await registerUser(
formData.username,
formData.password,
formData.nom,
formData.prenom,
formData.telephone,
);
if (result.success && result.access_token) {
await loginClient(result.access_token);
} else {
const message =
result.message || "Erreur lors de l'inscription";
setApiError(message);
setErrors({ username: message });
}
} catch (err: any) {
setApiError(err.message || "Erreur serveur");
} finally {
setIsLoading(false);
}
};
return (
<ScrollView
contentContainerStyle={[
styles.container,
{ backgroundColor: colors.bgSecondary },
]}
>
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
<View style={styles.iconContainer}>
<FontAwesome
name="shopping-bag"
size={48}
color="#7c3aed"
/>
</View>
<Text style={[styles.title, { color: colors.textWhite }]}>
Créer un compte
</Text>
{apiError ? (
<Text style={styles.apiError}>{apiError}</Text>
) : null}
{/* Nom */}
<View style={styles.inputGroup}>
<Feather
name="user"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{
color: colors.textWhite,
borderColor: colors.border,
},
errors.nom && styles.inputError,
]}
placeholder="Nom"
placeholderTextColor={colors.textMuted}
value={formData.nom}
onChangeText={(value) => handleChange("nom", value)}
/>
{errors.nom && (
<Text style={styles.errorText}>{errors.nom}</Text>
)}
</View>
{/* Prénom */}
<View style={styles.inputGroup}>
<Feather
name="user"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{
color: colors.textWhite,
borderColor: colors.border,
},
errors.prenom && styles.inputError,
]}
placeholder="Prénom"
placeholderTextColor={colors.textMuted}
value={formData.prenom}
onChangeText={(value) => handleChange("prenom", value)}
/>
{errors.prenom && (
<Text style={styles.errorText}>{errors.prenom}</Text>
)}
</View>
{/* Téléphone */}
<View style={styles.inputGroup}>
<Feather
name="phone"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{
color: colors.textWhite,
borderColor: colors.border,
},
errors.telephone && styles.inputError,
]}
placeholder="Téléphone"
placeholderTextColor={colors.textMuted}
value={formData.telephone}
keyboardType="phone-pad"
onChangeText={(value) =>
handleChange("telephone", value)
}
/>
{errors.telephone && (
<Text style={styles.errorText}>{errors.telephone}</Text>
)}
</View>
{/* Username */}
<View style={styles.inputGroup}>
<Feather
name="user"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{
color: colors.textWhite,
borderColor: colors.border,
},
errors.username && styles.inputError,
]}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={formData.username}
onChangeText={(value) =>
handleChange("username", value)
}
/>
{errors.username && (
<Text style={styles.errorText}>{errors.username}</Text>
)}
</View>
{/* Password */}
<View style={styles.inputGroup}>
<Feather
name="lock"
size={20}
color={colors.textMuted}
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{
color: colors.textWhite,
borderColor: colors.border,
},
errors.password && styles.inputError,
]}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
secureTextEntry={!showPassword}
value={formData.password}
onChangeText={(value) =>
handleChange("password", value)
}
/>
<TouchableOpacity
style={styles.eyeButton}
onPress={() => setShowPassword(!showPassword)}
>
<Feather
name={showPassword ? "eye-off" : "eye"}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
{errors.password && (
<Text style={styles.errorText}>{errors.password}</Text>
)}
</View>
<TouchableOpacity
style={[styles.submitButton, isLoading && { opacity: 0.6 }]}
onPress={handleRegister}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.submitText}>Créer mon compte</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("login")}>
<Text style={[styles.loginText, { color: "#a78bfa" }]}>
Vous avez déjà un compte ? Se connecter
</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
};
export default RegisterScreen;
const styles = StyleSheet.create({
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
card: { width: "100%", maxWidth: 400, borderRadius: 16, padding: 24 },
iconContainer: { alignItems: "center", marginBottom: 16 },
title: {
fontSize: 24,
fontWeight: "bold",
marginBottom: 24,
textAlign: "center",
},
inputGroup: { marginBottom: 16, position: "relative" },
icon: { position: "absolute", left: 12, top: 12 },
input: { height: 40, paddingLeft: 40, borderWidth: 1, borderRadius: 8 },
inputError: { borderColor: "#ef4444" },
eyeButton: { position: "absolute", right: 12, top: 8 },
errorText: { color: "#f87171", marginTop: 4 },
apiError: { color: "#f87171", textAlign: "center", marginBottom: 12 },
submitButton: {
backgroundColor: "#7c3aed",
borderRadius: 8,
padding: 12,
alignItems: "center",
marginTop: 8,
},
submitText: { color: "white", fontWeight: "600" },
loginText: { textAlign: "center", marginTop: 16 },
});
@@ -0,0 +1,95 @@
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../../navigation/types";
import { useTheme } from "../../context/ThemeContext";
type Nav = NativeStackNavigationProp<RootStackParamList, "role">;
export default function RoleSelectScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
return (
<View
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
>
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
<Ionicons name="people-outline" size={48} color="#7c3aed" />
<Text style={[styles.title, { color: colors.textWhite }]}>
Choisir un role
</Text>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
Selectionnez votre type de compte
</Text>
<TouchableOpacity
style={[
styles.roleBtn,
{
backgroundColor: colors.bgInput,
borderColor: colors.border,
},
]}
onPress={() => navigation.navigate("login")}
>
<Ionicons name="person-outline" size={24} color="#7c3aed" />
<View style={styles.roleBtnText}>
<Text
style={[
styles.roleTitle,
{ color: colors.textWhite },
]}
>
Client
</Text>
<Text
style={[
styles.roleDesc,
{ color: colors.textMuted },
]}
>
Commander des produits
</Text>
</View>
<Ionicons
name="chevron-forward"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
card: {
width: "100%",
maxWidth: 400,
borderRadius: 16,
padding: 24,
alignItems: "center",
},
title: { fontSize: 24, fontWeight: "bold", marginTop: 12 },
subtitle: { fontSize: 14, marginTop: 4, marginBottom: 24 },
roleBtn: {
flexDirection: "row",
alignItems: "center",
borderRadius: 12,
padding: 16,
width: "100%",
borderWidth: 1,
},
roleBtnText: { flex: 1, marginLeft: 12 },
roleTitle: { fontSize: 16, fontWeight: "600" },
roleDesc: { fontSize: 12, marginTop: 2 },
});
+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>
);
}
+142
View File
@@ -0,0 +1,142 @@
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { Platform } from "react-native";
import apiClient from "../api/client";
const V1 = "http://172.20.167.237:8080/api/v1";
// Configuration du comportement des notifications en foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
// Créer le channel Android pour les commandes
export async function setupNotificationChannel(): Promise<void> {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("orders", {
name: "Commandes",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#7C3AED",
sound: "default",
enableVibrate: true,
showBadge: true,
});
}
}
// Enregistrer le device pour les push notifications et retourner le token
export async function registerForPushNotificationsAsync(): Promise<
string | null
> {
if (!Device.isDevice) {
console.log(
"⚠️ Push notifications ne fonctionnent pas sur un émulateur",
);
return null;
}
// Vérifier/demander les permissions
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
console.log("❌ Permission push notifications refusée");
return null;
}
// Récupérer le projectId Expo
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
console.log(
"⚠️ projectId non trouvé - push notifications désactivées",
);
return null;
}
try {
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
const pushToken = tokenData.data;
console.log("📱 Push token:", pushToken);
return pushToken;
} catch (error) {
console.error("❌ Erreur récupération push token:", error);
return null;
}
}
// Envoyer le push token au backend
export async function sendPushTokenToBackend(
pushToken: string,
): Promise<boolean> {
try {
const { data } = await apiClient.post(`${V1}/push-token`, {
push_token: pushToken,
platform: Platform.OS,
});
console.log("✅ Push token enregistré sur le backend");
return data.success === true;
} catch (error) {
console.error("❌ Erreur envoi push token au backend:", error);
return false;
}
}
// Supprimer le push token du backend (au logout)
export async function removePushTokenFromBackend(
pushToken: string,
): Promise<void> {
try {
await apiClient.delete(`${V1}/push-token`, {
data: { push_token: pushToken },
});
console.log("✅ Push token supprimé du backend");
} catch (error) {
console.error("❌ Erreur suppression push token:", error);
}
}
// Listeners pour les notifications
export function addNotificationReceivedListener(
callback: (notification: Notifications.Notification) => void,
): Notifications.EventSubscription {
return Notifications.addNotificationReceivedListener(callback);
}
export function addNotificationResponseListener(
callback: (response: Notifications.NotificationResponse) => void,
): Notifications.EventSubscription {
return Notifications.addNotificationResponseReceivedListener(callback);
}
// Mettre à jour le badge
export async function setBadgeCount(count: number): Promise<void> {
try {
await Notifications.setBadgeCountAsync(count);
} catch {
// Silencieux - certains appareils ne supportent pas les badges
}
}
// Récupérer la dernière notification qui a ouvert l'app
export async function getLastNotificationResponse(): Promise<Notifications.NotificationResponse | null> {
return await Notifications.getLastNotificationResponseAsync();
}
+94
View File
@@ -0,0 +1,94 @@
export const darkColors = {
// Backgrounds
bgPrimary: "#0a0a0a",
bgSecondary: "#1a1a1a",
bgCard: "#1e1e1e",
bgInput: "#2a2a2a",
bgModal: "rgba(0,0,0,0.95)",
// Text
textPrimary: "rgba(255, 255, 255, 0.87)",
textSecondary: "rgba(255, 255, 255, 0.6)",
textMuted: "rgba(255, 255, 255, 0.4)",
textWhite: "#ffffff",
// Accent
accent: "#7c3aed",
accentDark: "#6d28d9",
accentLight: "#8b5cf6",
// Status
success: "#4ade80",
successDark: "#22c55e",
successDarker: "#16a34a",
danger: "#ef4444",
dangerDark: "#dc2626",
warning: "#f59e0b",
info: "#3b82f6",
// Category colors
categoryWeedHash: "#10b981",
categoryTous: "#9333ea",
categoryZipette: "#f5f5f0",
categoryGros: "#3dc2f7",
// Borders
border: "#333333",
borderLight: "#222222",
borderSubtle: "rgba(255, 255, 255, 0.1)",
// Misc
overlay: "rgba(0, 0, 0, 0.5)",
transparent: "transparent",
white: "#ffffff",
black: "#000000",
} as const;
export const lightColors: Colors = {
// Backgrounds
bgPrimary: "#f2f2f7",
bgSecondary: "#ffffff",
bgCard: "#ffffff",
bgInput: "#e5e5ea",
bgModal: "rgba(255,255,255,0.95)",
// Text
textPrimary: "rgba(0, 0, 0, 0.87)",
textSecondary: "rgba(0, 0, 0, 0.6)",
textMuted: "rgba(0, 0, 0, 0.4)",
textWhite: "#000000",
// Accent
accent: "#7c3aed",
accentDark: "#6d28d9",
accentLight: "#8b5cf6",
// Status
success: "#16a34a",
successDark: "#15803d",
successDarker: "#166534",
danger: "#dc2626",
dangerDark: "#b91c1c",
warning: "#d97706",
info: "#2563eb",
// Category colors
categoryWeedHash: "#10b981",
categoryTous: "#9333ea",
categoryZipette: "#1a1a1a",
categoryGros: "#0ea5e9",
// Borders
border: "#d1d5db",
borderLight: "#e5e7eb",
borderSubtle: "rgba(0, 0, 0, 0.08)",
// Misc
overlay: "rgba(0, 0, 0, 0.3)",
transparent: "transparent",
white: "#ffffff",
black: "#000000",
};
export type Colors = { [K in keyof typeof darkColors]: string };
export type ColorName = keyof Colors;
+5
View File
@@ -0,0 +1,5 @@
export { darkColors, lightColors, darkColors as colors } from "./colors";
export type { Colors } from "./colors";
export { spacing, borderRadius } from "./spacing";
export { fontSize, fontWeight, fontFamily } from "./typography";
export { shadows } from "./shadows";
+40
View File
@@ -0,0 +1,40 @@
import { Platform } from 'react-native';
export const shadows = {
sm: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
},
android: {
elevation: 2,
},
default: {},
}),
md: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
android: {
elevation: 4,
},
default: {},
}),
lg: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
},
android: {
elevation: 8,
},
default: {},
}),
} as const;
+17
View File
@@ -0,0 +1,17 @@
export const spacing = {
xs: 4,
s: 8,
m: 12,
l: 16,
xl: 24,
xxl: 32,
xxxl: 48,
} as const;
export const borderRadius = {
sm: 8,
md: 12,
lg: 16,
xl: 25,
full: 9999,
} as const;
+24
View File
@@ -0,0 +1,24 @@
import { Platform } from 'react-native';
export const fontFamily = Platform.select({
ios: 'System',
android: 'Roboto',
default: 'System',
});
export const fontSize = {
xs: 11,
sm: 13,
md: 15,
lg: 17,
xl: 20,
xxl: 28,
title: 34,
} as const;
export const fontWeight = {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
};
+46
View File
@@ -0,0 +1,46 @@
import type { Colors } from "../theme/colors";
export const STATUS_LABELS: Record<string, string> = {
pending: "En attente",
assigned: "Assignée",
support: "Support",
preparing: "En préparation",
ready: "Prêt",
en_route: "En route",
arrived: "Arrivée",
livre: "Livrée",
delivered: "Livré",
approved: "Terminée",
cancelled: "Annulé",
available: "Disponible",
busy: "Occupé",
offline: "Hors ligne",
};
export const getStatusColors = (colors: Colors): Record<string, string> => ({
pending: colors.warning,
assigned: colors.info,
support: colors.warning,
preparing: colors.info,
ready: colors.accent,
en_route: colors.info,
arrived: colors.accent,
livre: colors.success,
delivered: colors.success,
approved: colors.successDark,
cancelled: colors.danger,
available: colors.success,
busy: colors.warning,
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;
};