chore: add parrainage
This commit is contained in:
@@ -36,6 +36,15 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-location",
|
"expo-location",
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"color": "#ffffff",
|
||||||
|
"androidMode": "default",
|
||||||
|
"androidCollapsedTitle": "#{unread_notifications} nouvelles commandes"
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -471,6 +471,44 @@ export const deleteClientAdmin = async (clientId: number) => {
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PARRAINAGE ADMIN
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const creditClientReferral = async (
|
||||||
|
username: string,
|
||||||
|
amount: number,
|
||||||
|
): Promise<{ success: boolean; balance?: number; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${V2}/admin/protected/client/${username}/referral/credit`,
|
||||||
|
{ amount },
|
||||||
|
);
|
||||||
|
return { success: true, balance: data.balance, message: data.message };
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.error || "Erreur crédit parrainage",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientReferralAdmin = async (
|
||||||
|
username: string,
|
||||||
|
): Promise<{ success: boolean; balance?: number; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(
|
||||||
|
`${V2}/admin/protected/client/${username}/referral`,
|
||||||
|
);
|
||||||
|
return { success: true, balance: data.balance };
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.error || "Erreur récupération",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ALERTES
|
// ALERTES
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -676,6 +714,7 @@ export interface AppSettings {
|
|||||||
points_separated: boolean;
|
points_separated: boolean;
|
||||||
points_weed_tiers: PointsTier[];
|
points_weed_tiers: PointsTier[];
|
||||||
points_zipette_tiers: PointsTier[];
|
points_zipette_tiers: PointsTier[];
|
||||||
|
referral_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSettings = async (): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
|
export const getSettings = async (): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
|
||||||
@@ -687,6 +726,35 @@ export const getSettings = async (): Promise<{ success: boolean; settings?: AppS
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const registerAdminPushToken = async (pushToken: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`${V2}/admin/protected/push-token`, { push_token: pushToken });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unregisterAdminPushToken = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`${V2}/admin/protected/push-token`);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AppNotification {
|
||||||
|
command_id: number;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAdminNotifications = async (): Promise<{ notifications: AppNotification[]; unread_count: number }> => {
|
||||||
|
const { data } = await apiClient.get(`${V2}/admin/protected/notifications`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAdminNotificationsRead = async (): Promise<void> => {
|
||||||
|
await apiClient.post(`${V2}/admin/protected/notifications/read`);
|
||||||
|
};
|
||||||
|
|
||||||
export const updateSettings = async (
|
export const updateSettings = async (
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
|
): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
|
||||||
|
|||||||
@@ -351,6 +351,35 @@ export interface PublicSettings {
|
|||||||
points_separated: boolean;
|
points_separated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const registerCabinePushToken = async (pushToken: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`${API}/push-token`, { push_token: pushToken });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unregisterCabinePushToken = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`${API}/push-token`);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AppNotification {
|
||||||
|
command_id: number;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCabineNotifications = async (): Promise<{ notifications: AppNotification[]; unread_count: number }> => {
|
||||||
|
const { data } = await apiClient.get(`${API}/notifications`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markCabineNotificationsRead = async (): Promise<void> => {
|
||||||
|
await apiClient.post(`${API}/notifications/read`);
|
||||||
|
};
|
||||||
|
|
||||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(`http://5.181.0.112/api/v1/app-settings`);
|
const { data } = await apiClient.get(`http://5.181.0.112/api/v1/app-settings`);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface ClientResponse {
|
|||||||
amende: number;
|
amende: number;
|
||||||
cancellations_count: number;
|
cancellations_count: number;
|
||||||
last_penalty_reason?: string;
|
last_penalty_reason?: string;
|
||||||
|
referral_balance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandResponse {
|
export interface CommandResponse {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
@@ -7,7 +14,12 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { logoutAdmin } from "../api/api_admin";
|
import {
|
||||||
|
logoutAdmin,
|
||||||
|
getAdminNotifications,
|
||||||
|
markAdminNotificationsRead,
|
||||||
|
} from "../api/api_admin";
|
||||||
|
import type { AppNotification } from "../api/api_admin";
|
||||||
import { fontSize, spacing } from "../theme";
|
import { fontSize, spacing } from "../theme";
|
||||||
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
||||||
|
|
||||||
@@ -30,176 +42,288 @@ function AdminTabs() {
|
|||||||
const { colors, isDark, toggleTheme } = useTheme();
|
const { colors, isDark, toggleTheme } = useTheme();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<AdminStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<AdminStackParamList>>();
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAdminNotifications();
|
||||||
|
setNotifications(res.notifications ?? []);
|
||||||
|
setUnreadCount(res.unread_count ?? 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
intervalRef.current = setInterval(fetchNotifications, 15000);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
const openModal = async () => {
|
||||||
|
setModalVisible(true);
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
try {
|
||||||
|
await markAdminNotificationsRead();
|
||||||
|
setUnreadCount(0);
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logoutAdmin();
|
await logoutAdmin();
|
||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTime = (iso: string) => {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
|
||||||
|
if (diff < 1) return "à l'instant";
|
||||||
|
if (diff < 60) return `il y a ${diff} min`;
|
||||||
|
const h = Math.floor(diff / 60);
|
||||||
|
if (h < 24) return `il y a ${h}h`;
|
||||||
|
return `il y a ${Math.floor(h / 24)}j`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<>
|
||||||
screenOptions={{
|
<Tab.Navigator
|
||||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
screenOptions={{
|
||||||
headerTintColor: colors.textWhite,
|
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||||
headerRight: () => (
|
headerTintColor: colors.textWhite,
|
||||||
|
headerRight: () => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: spacing.l,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openModal}
|
||||||
|
style={{ marginRight: spacing.m }}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Ionicons
|
||||||
|
name="notifications-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ backgroundColor: colors.error ?? "#e74c3c" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleTheme}
|
||||||
|
style={{ marginRight: spacing.m }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isDark ? "sunny-outline" : "moon-outline"}
|
||||||
|
size={22}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => navigation.navigate("Settings")}
|
||||||
|
style={{ marginRight: spacing.m }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="settings-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="Dashboard"
|
||||||
|
component={DashboardScreen}
|
||||||
|
options={{
|
||||||
|
title: "Dashboard",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="grid-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Orders"
|
||||||
|
component={OrdersScreen}
|
||||||
|
options={{
|
||||||
|
title: "Commandes",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="receipt-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Users"
|
||||||
|
component={UsersScreen}
|
||||||
|
options={{
|
||||||
|
title: "Clients",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="people-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Products"
|
||||||
|
component={ProductsScreen}
|
||||||
|
options={{
|
||||||
|
title: "Produits",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="cube-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Categories"
|
||||||
|
component={CategoriesScreen}
|
||||||
|
options={{
|
||||||
|
title: "Catégories",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="pricetag-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Delivery"
|
||||||
|
component={DeliveryScreen}
|
||||||
|
options={{
|
||||||
|
title: "Livreurs",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="bicycle-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Alerts"
|
||||||
|
component={AlertsScreen}
|
||||||
|
options={{
|
||||||
|
title: "Alertes",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="alert-circle-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Addresses"
|
||||||
|
component={AddressScreen}
|
||||||
|
options={{
|
||||||
|
title: "Adresses",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="map-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={modalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={[
|
||||||
flexDirection: "row",
|
styles.modalContainer,
|
||||||
alignItems: "center",
|
{ backgroundColor: colors.bgSecondary },
|
||||||
marginRight: spacing.l,
|
]}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<View style={styles.modalHeader}>
|
||||||
onPress={toggleTheme}
|
<Text
|
||||||
style={{ marginRight: spacing.m }}
|
style={[styles.modalTitle, { color: colors.textWhite }]}
|
||||||
>
|
>
|
||||||
<Ionicons
|
Notifications
|
||||||
name={isDark ? "sunny-outline" : "moon-outline"}
|
</Text>
|
||||||
size={22}
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
color={colors.textSecondary}
|
<Ionicons
|
||||||
/>
|
name="close"
|
||||||
</TouchableOpacity>
|
size={24}
|
||||||
<TouchableOpacity
|
color={colors.textSecondary}
|
||||||
onPress={() => navigation.navigate("Settings")}
|
/>
|
||||||
style={{ marginRight: spacing.m }}
|
</TouchableOpacity>
|
||||||
>
|
</View>
|
||||||
<Ionicons
|
<ScrollView style={styles.notifList}>
|
||||||
name="settings-outline"
|
{notifications.length === 0 ? (
|
||||||
size={22}
|
<Text
|
||||||
color={colors.textSecondary}
|
style={[
|
||||||
/>
|
styles.emptyText,
|
||||||
</TouchableOpacity>
|
{ color: colors.textMuted },
|
||||||
<TouchableOpacity onPress={handleLogout}>
|
]}
|
||||||
<Ionicons
|
>
|
||||||
name="log-out-outline"
|
Aucune notification
|
||||||
size={24}
|
</Text>
|
||||||
color={colors.textSecondary}
|
) : (
|
||||||
/>
|
notifications.map((n, i) => (
|
||||||
</TouchableOpacity>
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.notifItem,
|
||||||
|
{
|
||||||
|
borderLeftColor: n.read
|
||||||
|
? colors.border
|
||||||
|
: colors.accent,
|
||||||
|
backgroundColor: n.read
|
||||||
|
? "transparent"
|
||||||
|
: colors.bgPrimary ?? colors.bgSecondary,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.notifMessage,
|
||||||
|
{ color: colors.textWhite },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{n.message}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.notifTime,
|
||||||
|
{ color: colors.textMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatTime(n.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
),
|
</View>
|
||||||
tabBarStyle: {
|
</Modal>
|
||||||
backgroundColor: colors.bgSecondary,
|
</>
|
||||||
borderTopColor: colors.border,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
},
|
|
||||||
tabBarActiveTintColor: colors.accent,
|
|
||||||
tabBarInactiveTintColor: colors.textMuted,
|
|
||||||
tabBarLabelStyle: { fontSize: fontSize.xs },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Dashboard"
|
|
||||||
component={DashboardScreen}
|
|
||||||
options={{
|
|
||||||
title: "Dashboard",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="grid-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Orders"
|
|
||||||
component={OrdersScreen}
|
|
||||||
options={{
|
|
||||||
title: "Commandes",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="receipt-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Users"
|
|
||||||
component={UsersScreen}
|
|
||||||
options={{
|
|
||||||
title: "Clients",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="people-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Products"
|
|
||||||
component={ProductsScreen}
|
|
||||||
options={{
|
|
||||||
title: "Produits",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="cube-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Categories"
|
|
||||||
component={CategoriesScreen}
|
|
||||||
options={{
|
|
||||||
title: "Catégories",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="pricetag-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Delivery"
|
|
||||||
component={DeliveryScreen}
|
|
||||||
options={{
|
|
||||||
title: "Livreurs",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="bicycle-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Alerts"
|
|
||||||
component={AlertsScreen}
|
|
||||||
options={{
|
|
||||||
title: "Alertes",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="alert-circle-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Addresses"
|
|
||||||
component={AddressScreen}
|
|
||||||
options={{
|
|
||||||
title: "Adresses",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="map-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tab.Navigator>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,3 +355,68 @@ export default function AdminNavigator() {
|
|||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -4,
|
||||||
|
right: -6,
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
notifList: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
notifItem: {
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
notifMessage: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
notifTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 32,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { logoutAdmin } from "../api/api_admin";
|
import { logoutAdmin } from "../api/api_admin";
|
||||||
|
import {
|
||||||
|
getCabineNotifications,
|
||||||
|
markCabineNotificationsRead,
|
||||||
|
} from "../api/api_cabine";
|
||||||
|
import type { AppNotification } from "../api/api_cabine";
|
||||||
import { fontSize, spacing } from "../theme";
|
import { fontSize, spacing } from "../theme";
|
||||||
import type { CabineTabParamList } from "./types";
|
import type { CabineTabParamList } from "./types";
|
||||||
|
|
||||||
@@ -20,123 +32,298 @@ export default function CabineNavigator() {
|
|||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { colors, isDark, toggleTheme } = useTheme();
|
const { colors, isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCabineNotifications();
|
||||||
|
setNotifications(res.notifications ?? []);
|
||||||
|
setUnreadCount(res.unread_count ?? 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
intervalRef.current = setInterval(fetchNotifications, 15000);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
const openModal = async () => {
|
||||||
|
setModalVisible(true);
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
try {
|
||||||
|
await markCabineNotificationsRead();
|
||||||
|
setUnreadCount(0);
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logoutAdmin();
|
await logoutAdmin();
|
||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTime = (iso: string) => {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
|
||||||
|
if (diff < 1) return "à l'instant";
|
||||||
|
if (diff < 60) return `il y a ${diff} min`;
|
||||||
|
const h = Math.floor(diff / 60);
|
||||||
|
if (h < 24) return `il y a ${h}h`;
|
||||||
|
return `il y a ${Math.floor(h / 24)}j`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<>
|
||||||
screenOptions={{
|
<Tab.Navigator
|
||||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
screenOptions={{
|
||||||
headerTintColor: colors.textWhite,
|
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||||
headerRight: () => (
|
headerTintColor: colors.textWhite,
|
||||||
<View
|
headerRight: () => (
|
||||||
style={{
|
<View
|
||||||
flexDirection: "row",
|
style={{
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
marginRight: spacing.l,
|
alignItems: "center",
|
||||||
}}
|
marginRight: spacing.l,
|
||||||
>
|
}}
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleTheme}
|
|
||||||
style={{ marginRight: spacing.m }}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<TouchableOpacity
|
||||||
name={isDark ? "sunny-outline" : "moon-outline"}
|
onPress={openModal}
|
||||||
size={22}
|
style={{ marginRight: spacing.m }}
|
||||||
color={colors.textSecondary}
|
>
|
||||||
/>
|
<View>
|
||||||
</TouchableOpacity>
|
<Ionicons
|
||||||
<TouchableOpacity onPress={handleLogout}>
|
name="notifications-outline"
|
||||||
<Ionicons
|
size={22}
|
||||||
name="log-out-outline"
|
color={colors.textSecondary}
|
||||||
size={24}
|
/>
|
||||||
color={colors.textSecondary}
|
{unreadCount > 0 && (
|
||||||
/>
|
<View
|
||||||
</TouchableOpacity>
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ backgroundColor: colors.error ?? "#e74c3c" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleTheme}
|
||||||
|
style={{ marginRight: spacing.m }}
|
||||||
|
>
|
||||||
|
<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.info,
|
||||||
|
tabBarInactiveTintColor: colors.textMuted,
|
||||||
|
tabBarLabelStyle: { fontSize: fontSize.xs },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Dashboard"
|
||||||
|
component={DashboardScreen}
|
||||||
|
options={{
|
||||||
|
title: "Dashboard",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="grid-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Orders"
|
||||||
|
component={OrdersScreen}
|
||||||
|
options={{
|
||||||
|
title: "Commandes",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="receipt-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Delivery"
|
||||||
|
component={DeliveryScreen}
|
||||||
|
options={{
|
||||||
|
title: "Livreurs",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="bicycle-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Users"
|
||||||
|
component={UsersScreen}
|
||||||
|
options={{
|
||||||
|
title: "Clients",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="people-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Alerts"
|
||||||
|
component={AlertsScreen}
|
||||||
|
options={{
|
||||||
|
title: "Alertes",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="alert-circle-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={modalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modalContainer,
|
||||||
|
{ backgroundColor: colors.bgSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={[styles.modalTitle, { color: colors.textWhite }]}>
|
||||||
|
Notifications
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
|
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={styles.notifList}>
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<Text style={[styles.emptyText, { color: colors.textMuted }]}>
|
||||||
|
Aucune notification
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
notifications.map((n, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.notifItem,
|
||||||
|
{
|
||||||
|
borderLeftColor: n.read
|
||||||
|
? colors.border
|
||||||
|
: colors.info,
|
||||||
|
backgroundColor: n.read
|
||||||
|
? "transparent"
|
||||||
|
: colors.bgPrimary ?? colors.bgSecondary,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.notifMessage,
|
||||||
|
{ color: colors.textWhite },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{n.message}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.notifTime, { color: colors.textMuted }]}
|
||||||
|
>
|
||||||
|
{formatTime(n.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
),
|
</View>
|
||||||
tabBarStyle: {
|
</Modal>
|
||||||
backgroundColor: colors.bgSecondary,
|
</>
|
||||||
borderTopColor: colors.border,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
},
|
|
||||||
tabBarActiveTintColor: colors.info,
|
|
||||||
tabBarInactiveTintColor: colors.textMuted,
|
|
||||||
tabBarLabelStyle: { fontSize: fontSize.xs },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Dashboard"
|
|
||||||
component={DashboardScreen}
|
|
||||||
options={{
|
|
||||||
title: "Dashboard",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="grid-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Orders"
|
|
||||||
component={OrdersScreen}
|
|
||||||
options={{
|
|
||||||
title: "Commandes",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="receipt-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Delivery"
|
|
||||||
component={DeliveryScreen}
|
|
||||||
options={{
|
|
||||||
title: "Livreurs",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="bicycle-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Users"
|
|
||||||
component={UsersScreen}
|
|
||||||
options={{
|
|
||||||
title: "Clients",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="people-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Alerts"
|
|
||||||
component={AlertsScreen}
|
|
||||||
options={{
|
|
||||||
title: "Alertes",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="alert-circle-outline"
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tab.Navigator>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -4,
|
||||||
|
right: -6,
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
notifList: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
notifItem: {
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
notifMessage: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
notifTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 32,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
getAllClients,
|
getAllClients,
|
||||||
getAvailableDeliveryPersons,
|
getAvailableDeliveryPersons,
|
||||||
getCommandCountByStatus,
|
getCommandCountByStatus,
|
||||||
|
registerAdminPushToken,
|
||||||
|
unregisterAdminPushToken,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
|
import { getExpoPushToken } from "../../utils/pushTokenUtils";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
|
||||||
interface StatCard {
|
interface StatCard {
|
||||||
@@ -33,6 +36,14 @@ export default function DashboardScreen() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Enregistrement push token admin au montage
|
||||||
|
useEffect(() => {
|
||||||
|
getExpoPushToken().then((token) => {
|
||||||
|
if (token) registerAdminPushToken(token);
|
||||||
|
});
|
||||||
|
return () => { unregisterAdminPushToken(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStats = useCallback(async () => {
|
const loadStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [allCmd, pending, enRoute, completed, clients, livreurs] =
|
const [allCmd, pending, enRoute, completed, clients, livreurs] =
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export default function SettingsScreen() {
|
|||||||
points_separated: true,
|
points_separated: true,
|
||||||
points_weed_tiers: [],
|
points_weed_tiers: [],
|
||||||
points_zipette_tiers: [],
|
points_zipette_tiers: [],
|
||||||
|
referral_enabled: true,
|
||||||
});
|
});
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
|
||||||
@@ -350,6 +351,28 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Parrainage */}
|
||||||
|
<View style={s.section}>
|
||||||
|
<Text style={s.sectionTitle}>Parrainage</Text>
|
||||||
|
<View style={[s.row, s.rowFirst]}>
|
||||||
|
<View style={s.rowLeft}>
|
||||||
|
<Text style={s.rowLabel}>Parrainage activé</Text>
|
||||||
|
<Text style={s.rowDesc}>
|
||||||
|
Les clients peuvent voir et utiliser leur solde parrainage au checkout.{"\n"}
|
||||||
|
Désactivé : le solde reste en base mais ne peut plus être utilisé.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.referral_enabled}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setSettings((prev) => ({ ...prev, referral_enabled: v }))
|
||||||
|
}
|
||||||
|
trackColor={{ false: colors.border, true: colors.accent }}
|
||||||
|
thumbColor="#fff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Système de points */}
|
{/* Système de points */}
|
||||||
<View style={s.section}>
|
<View style={s.section}>
|
||||||
<Text style={s.sectionTitle}>Système de points</Text>
|
<Text style={s.sectionTitle}>Système de points</Text>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
deleteClientAdmin,
|
deleteClientAdmin,
|
||||||
createClientByAdmin,
|
createClientByAdmin,
|
||||||
createUserByAdmin,
|
createUserByAdmin,
|
||||||
|
creditClientReferral,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
import type { ClientResponse } from "../../api/types";
|
import type { ClientResponse } from "../../api/types";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
@@ -155,6 +156,15 @@ export default function UsersScreen() {
|
|||||||
const [createTel, setCreateTel] = useState("");
|
const [createTel, setCreateTel] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Referral credit modal
|
||||||
|
const [referralModal, setReferralModal] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
username: string;
|
||||||
|
currentBalance: number;
|
||||||
|
}>({ visible: false, username: "", currentBalance: 0 });
|
||||||
|
const [referralAmount, setReferralAmount] = useState("");
|
||||||
|
const [referralLoading, setReferralLoading] = useState(false);
|
||||||
|
|
||||||
const { alert, showError, showConfirm, hideAlert } = useAlert();
|
const { alert, showError, showConfirm, hideAlert } = useAlert();
|
||||||
|
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
@@ -318,6 +328,40 @@ export default function UsersScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------
|
||||||
|
// Referral credit
|
||||||
|
// --------------------------------------------------
|
||||||
|
const openReferralModal = (client: ClientResponse) => {
|
||||||
|
setReferralAmount("");
|
||||||
|
setReferralModal({
|
||||||
|
visible: true,
|
||||||
|
username: client.username,
|
||||||
|
currentBalance: client.referral_balance ?? 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreditReferral = async () => {
|
||||||
|
const amount = parseFloat(referralAmount);
|
||||||
|
if (isNaN(amount) || amount <= 0) {
|
||||||
|
showError("Erreur", "Entrez un montant valide");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReferralLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await creditClientReferral(referralModal.username, amount);
|
||||||
|
if (res.success) {
|
||||||
|
setReferralModal((prev) => ({ ...prev, visible: false }));
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
showError("Erreur", res.message || "Echec du crédit");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showError("Erreur", e.message);
|
||||||
|
} finally {
|
||||||
|
setReferralLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
// Create user
|
// Create user
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
@@ -603,16 +647,28 @@ export default function UsersScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={{ flexDirection: "row", gap: spacing.xs }}>
|
<View style={{ flexDirection: "row", gap: spacing.xs }}>
|
||||||
{isClient && (
|
{isClient && (
|
||||||
<TouchableOpacity
|
<>
|
||||||
style={styles.editIconBtn}
|
<TouchableOpacity
|
||||||
onPress={() => openEditClient(item.clientData!)}
|
style={styles.editIconBtn}
|
||||||
>
|
onPress={() => openReferralModal(item.clientData!)}
|
||||||
<Ionicons
|
>
|
||||||
name="create-outline"
|
<Ionicons
|
||||||
size={20}
|
name="gift-outline"
|
||||||
color={colors.info}
|
size={20}
|
||||||
/>
|
color={colors.success}
|
||||||
</TouchableOpacity>
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.editIconBtn}
|
||||||
|
onPress={() => openEditClient(item.clientData!)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="create-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.info}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{(item.role === "livreur" ||
|
{(item.role === "livreur" ||
|
||||||
item.role === "cabine") && (
|
item.role === "cabine") && (
|
||||||
@@ -704,6 +760,19 @@ export default function UsersScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.statLabel}>Annul.</Text>
|
<Text style={styles.statLabel}>Annul.</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{(item.clientData.referral_balance ?? 0) > 0 && (
|
||||||
|
<View style={styles.stat}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statValue,
|
||||||
|
{ color: colors.success },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(item.clientData.referral_balance ?? 0).toFixed(0)}€
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statLabel}>Parrain</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -977,6 +1046,35 @@ export default function UsersScreen() {
|
|||||||
confirmText={alert.confirmText}
|
confirmText={alert.confirmText}
|
||||||
cancelText={alert.cancelText}
|
cancelText={alert.cancelText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal crédit parrainage */}
|
||||||
|
<Modal
|
||||||
|
visible={referralModal.visible}
|
||||||
|
onClose={() => setReferralModal((prev) => ({ ...prev, visible: false }))}
|
||||||
|
title={`Crédit parrainage — ${referralModal.username}`}
|
||||||
|
icon="gift-outline"
|
||||||
|
iconColor={colors.success}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.textSecondary, fontSize: fontSize.sm, marginBottom: spacing.s }}>
|
||||||
|
Solde actuel : {referralModal.currentBalance.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Montant à créditer (ex: 10)"
|
||||||
|
value={referralAmount}
|
||||||
|
onChangeText={setReferralAmount}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
icon={<Ionicons name="cash-outline" size={18} color={colors.textMuted} />}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title={referralLoading ? "Chargement..." : "Créditer"}
|
||||||
|
onPress={handleCreditReferral}
|
||||||
|
disabled={referralLoading}
|
||||||
|
variant="success"
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.m }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,21 @@ import { useTheme } from "../../context/ThemeContext";
|
|||||||
import { shadows } from "../../theme/shadows";
|
import { shadows } from "../../theme/shadows";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { getAllCommands } from "../../api/api_admin";
|
import { getAllCommands } from "../../api/api_admin";
|
||||||
import { getAllDeliveryPersonsWithDetails } from "../../api/api_cabine";
|
import { getAllDeliveryPersonsWithDetails, registerCabinePushToken, unregisterCabinePushToken } from "../../api/api_cabine";
|
||||||
|
import { getExpoPushToken } from "../../utils/pushTokenUtils";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { username } = useAuth();
|
const { username } = useAuth();
|
||||||
|
|
||||||
|
// Enregistrement push token cabine au montage
|
||||||
|
useEffect(() => {
|
||||||
|
getExpoPushToken().then((token) => {
|
||||||
|
if (token) registerCabinePushToken(token);
|
||||||
|
});
|
||||||
|
return () => { unregisterCabinePushToken(); };
|
||||||
|
}, []);
|
||||||
const [stats, setStats] = useState<
|
const [stats, setStats] = useState<
|
||||||
Array<{
|
Array<{
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import * as Device from "expo-device";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande les permissions et retourne le token Expo push.
|
||||||
|
* Retourne null si non supporté (simulateur, refus de permission).
|
||||||
|
*/
|
||||||
|
export async function getExpoPushToken(): Promise<string | null> {
|
||||||
|
if (!Device.isDevice) return null;
|
||||||
|
|
||||||
|
const { status: existing } = await Notifications.getPermissionsAsync();
|
||||||
|
let finalStatus = existing;
|
||||||
|
|
||||||
|
if (existing !== "granted") {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
finalStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== "granted") return null;
|
||||||
|
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
await Notifications.setNotificationChannelAsync("orders", {
|
||||||
|
name: "Nouvelles commandes",
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
sound: "default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await Notifications.getExpoPushTokenAsync();
|
||||||
|
return tokenData.data;
|
||||||
|
}
|
||||||
+18
-2
@@ -7,6 +7,7 @@ import type {
|
|||||||
TrackingResponse,
|
TrackingResponse,
|
||||||
CancelCommandResponse,
|
CancelCommandResponse,
|
||||||
PenaltiesResponse,
|
PenaltiesResponse,
|
||||||
|
ReferralBalanceResponse,
|
||||||
} from "./api_types";
|
} from "./api_types";
|
||||||
import { getToken } from "../auth/tokenStorage";
|
import { getToken } from "../auth/tokenStorage";
|
||||||
import { extractUsernameFromToken } from "../auth/jwtUtils";
|
import { extractUsernameFromToken } from "../auth/jwtUtils";
|
||||||
@@ -290,6 +291,7 @@ export const checkoutCart = async (
|
|||||||
nom?: string,
|
nom?: string,
|
||||||
prenom?: string,
|
prenom?: string,
|
||||||
telephone?: string,
|
telephone?: string,
|
||||||
|
use_referral_balance?: boolean,
|
||||||
): Promise<CheckoutCartResponse> => {
|
): Promise<CheckoutCartResponse> => {
|
||||||
const jwtUsername = await getJwtUsername();
|
const jwtUsername = await getJwtUsername();
|
||||||
if (!jwtUsername) return { success: false, message: "Session invalide" };
|
if (!jwtUsername) return { success: false, message: "Session invalide" };
|
||||||
@@ -299,9 +301,10 @@ export const checkoutCart = async (
|
|||||||
message: "Veuillez saisir une adresse de livraison",
|
message: "Veuillez saisir une adresse de livraison",
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, string> = {
|
const payload: Record<string, unknown> = {
|
||||||
username: jwtUsername,
|
username: jwtUsername,
|
||||||
delivery_address,
|
delivery_address,
|
||||||
|
use_referral_balance: use_referral_balance ?? false,
|
||||||
};
|
};
|
||||||
if (nom) payload.nom = nom;
|
if (nom) payload.nom = nom;
|
||||||
if (prenom) payload.prenom = prenom;
|
if (prenom) payload.prenom = prenom;
|
||||||
@@ -315,6 +318,8 @@ export const checkoutCart = async (
|
|||||||
command: data.command,
|
command: data.command,
|
||||||
assigned_to: data.assigned_to,
|
assigned_to: data.assigned_to,
|
||||||
queue_info: data.queue_info,
|
queue_info: data.queue_info,
|
||||||
|
referral_used: data.referral_used,
|
||||||
|
referral_balance: data.referral_balance,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errMsg: string = error.response?.data?.error || "Erreur serveur";
|
const errMsg: string = error.response?.data?.error || "Erreur serveur";
|
||||||
@@ -334,6 +339,15 @@ export const checkoutCart = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getReferralBalance = async (): Promise<ReferralBalanceResponse> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`${V1}/referral/balance`);
|
||||||
|
return { success: true, balance: data.balance ?? 0 };
|
||||||
|
} catch {
|
||||||
|
return { success: false, balance: 0, message: "Erreur récupération solde" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const approveDelivery = async (
|
export const approveDelivery = async (
|
||||||
commandId: number,
|
commandId: number,
|
||||||
reqData?: { rating?: number; comment?: string },
|
reqData?: { rating?: number; comment?: string },
|
||||||
@@ -713,6 +727,7 @@ export interface PublicSettings {
|
|||||||
show_amende_score: boolean;
|
show_amende_score: boolean;
|
||||||
points_enabled: boolean;
|
points_enabled: boolean;
|
||||||
points_separated: boolean;
|
points_separated: boolean;
|
||||||
|
referral_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||||
@@ -723,9 +738,10 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
|||||||
show_amende_score: data.show_amende_score ?? true,
|
show_amende_score: data.show_amende_score ?? true,
|
||||||
points_enabled: data.points_enabled ?? true,
|
points_enabled: data.points_enabled ?? true,
|
||||||
points_separated: data.points_separated ?? true,
|
points_separated: data.points_separated ?? true,
|
||||||
|
referral_enabled: data.referral_enabled ?? true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true };
|
return { penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -731,6 +731,12 @@ export interface CheckoutCartData {
|
|||||||
delivery_address: string;
|
delivery_address: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReferralBalanceResponse {
|
||||||
|
success: boolean;
|
||||||
|
balance: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CheckoutCartResponse {
|
export interface CheckoutCartResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -755,6 +761,8 @@ export interface CheckoutCartResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
estimated_wait: string;
|
estimated_wait: string;
|
||||||
};
|
};
|
||||||
|
referral_used?: number;
|
||||||
|
referral_balance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmReceptionResponse {
|
export interface ConfirmReceptionResponse {
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ interface ProductCardProps {
|
|||||||
media?: Array<{ url: string; type: string }>;
|
media?: Array<{ url: string; type: string }>;
|
||||||
};
|
};
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
categoryColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product, onPress }: ProductCardProps) {
|
export default function ProductCard({ product, onPress, categoryColor }: ProductCardProps) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { addToCart } = useCart();
|
const { addToCart } = useCart();
|
||||||
const catColor = getCategoryColor(product.category, colors);
|
const catColor = categoryColor ?? getCategoryColor(product.category, colors);
|
||||||
const isSoldOut = product.stock <= 0;
|
const isSoldOut = product.stock <= 0;
|
||||||
const firstPrice = product.prices?.[0]?.price ?? null;
|
const firstPrice = product.prices?.[0]?.price ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import OrderHistoryScreen from "../screens/client/OrderHistoryScreen";
|
|||||||
import ProductDetailScreen from "../screens/client/ProductDetailScreen";
|
import ProductDetailScreen from "../screens/client/ProductDetailScreen";
|
||||||
import CheckoutScreen from "../screens/client/CheckoutScreen";
|
import CheckoutScreen from "../screens/client/CheckoutScreen";
|
||||||
import OrderDetailsScreen from "../screens/client/OrderDetailsScreen";
|
import OrderDetailsScreen from "../screens/client/OrderDetailsScreen";
|
||||||
|
import ParrainageScreen from "../screens/client/ParrainageScreen";
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator<ClientTabParamList>();
|
const Tab = createBottomTabNavigator<ClientTabParamList>();
|
||||||
const Stack = createNativeStackNavigator<ClientStackParamList>();
|
const Stack = createNativeStackNavigator<ClientStackParamList>();
|
||||||
@@ -350,6 +351,11 @@ export default function ClientNavigator() {
|
|||||||
component={OrderDetailsScreen}
|
component={OrderDetailsScreen}
|
||||||
options={{ title: "Detail commande" }}
|
options={{ title: "Detail commande" }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Parrainage"
|
||||||
|
component={ParrainageScreen}
|
||||||
|
options={{ title: "Parrainage" }}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type ClientStackParamList = {
|
|||||||
ProductDetail: { productId: number };
|
ProductDetail: { productId: number };
|
||||||
Checkout: undefined;
|
Checkout: undefined;
|
||||||
OrderDetails: { orderId: number };
|
OrderDetails: { orderId: number };
|
||||||
|
Parrainage: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeliveryTabParamList = {
|
export type DeliveryTabParamList = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useCart } from "../../context/CartContext";
|
import { useCart } from "../../context/CartContext";
|
||||||
import { checkoutCart } from "../../api/api";
|
import { checkoutCart, getReferralBalance } from "../../api/api";
|
||||||
import type { ClientStackParamList } from "../../navigation/types";
|
import type { ClientStackParamList } from "../../navigation/types";
|
||||||
import TextInput from "../../components/ui/TextInput";
|
import TextInput from "../../components/ui/TextInput";
|
||||||
import Button from "../../components/ui/Button";
|
import Button from "../../components/ui/Button";
|
||||||
@@ -36,6 +38,14 @@ export default function CheckoutScreen() {
|
|||||||
const [confirmationData, setConfirmationData] = useState<any>(null);
|
const [confirmationData, setConfirmationData] = useState<any>(null);
|
||||||
const [invalidAddressModal, setInvalidAddressModal] = useState(false);
|
const [invalidAddressModal, setInvalidAddressModal] = useState(false);
|
||||||
const [suggestedAddress, setSuggestedAddress] = useState("");
|
const [suggestedAddress, setSuggestedAddress] = useState("");
|
||||||
|
const [referralBalance, setReferralBalance] = useState(0);
|
||||||
|
const [useReferral, setUseReferral] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getReferralBalance().then((res) => {
|
||||||
|
if (res.success && res.balance > 0) setReferralBalance(res.balance);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = async () => {
|
||||||
if (!nom.trim()) {
|
if (!nom.trim()) {
|
||||||
@@ -67,6 +77,7 @@ export default function CheckoutScreen() {
|
|||||||
nom.trim(),
|
nom.trim(),
|
||||||
prenom.trim(),
|
prenom.trim(),
|
||||||
telephone.trim(),
|
telephone.trim(),
|
||||||
|
useReferral && referralBalance > 0,
|
||||||
);
|
);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setConfirmationData(res);
|
setConfirmationData(res);
|
||||||
@@ -158,6 +169,36 @@ export default function CheckoutScreen() {
|
|||||||
fontSize: fontSize.sm,
|
fontSize: fontSize.sm,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
referralCard: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accent + "44",
|
||||||
|
padding: spacing.l,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
referralRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
referralLeft: { flexDirection: "row", alignItems: "center", gap: spacing.s, flex: 1 },
|
||||||
|
referralTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
},
|
||||||
|
referralAmount: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
referralHint: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.xs,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
invalidAddrContent: { gap: spacing.m },
|
invalidAddrContent: { gap: spacing.m },
|
||||||
invalidAddrIconContainer: { alignItems: "center" },
|
invalidAddrIconContainer: { alignItems: "center" },
|
||||||
invalidAddrIconCircle: {
|
invalidAddrIconCircle: {
|
||||||
@@ -372,6 +413,34 @@ export default function CheckoutScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Section parrainage — visible uniquement si solde > 0 */}
|
||||||
|
{referralBalance > 0 && (
|
||||||
|
<View style={styles.referralCard}>
|
||||||
|
<View style={styles.referralRow}>
|
||||||
|
<View style={styles.referralLeft}>
|
||||||
|
<Ionicons name="gift-outline" size={22} color={colors.accent} />
|
||||||
|
<View>
|
||||||
|
<Text style={styles.referralTitle}>Credit parrainage</Text>
|
||||||
|
<Text style={styles.referralAmount}>
|
||||||
|
{referralBalance.toFixed(2)} € disponibles
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={useReferral}
|
||||||
|
onValueChange={setUseReferral}
|
||||||
|
trackColor={{ false: colors.border, true: colors.accent + "66" }}
|
||||||
|
thumbColor={useReferral ? colors.accent : colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{useReferral && (
|
||||||
|
<Text style={styles.referralHint}>
|
||||||
|
Le credit sera deduit de ta commande. Tu dois quand meme atteindre le minimum de ta zone + le credit utilise.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{error &&
|
{error &&
|
||||||
nom.trim() &&
|
nom.trim() &&
|
||||||
prenom.trim() &&
|
prenom.trim() &&
|
||||||
@@ -461,6 +530,21 @@ export default function CheckoutScreen() {
|
|||||||
Votre commande #{confirmationData?.command_id} a ete
|
Votre commande #{confirmationData?.command_id} a ete
|
||||||
creee avec succes.
|
creee avec succes.
|
||||||
</Text>
|
</Text>
|
||||||
|
{confirmationData?.referral_used > 0 && (
|
||||||
|
<View style={styles.confirmInfo}>
|
||||||
|
<Ionicons
|
||||||
|
name="gift-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
<Text style={styles.confirmInfoText}>
|
||||||
|
{confirmationData.referral_used.toFixed(2)} € de credit parrainage utilises
|
||||||
|
{confirmationData.referral_balance !== undefined
|
||||||
|
? ` — Solde restant : ${confirmationData.referral_balance.toFixed(2)} €`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{confirmationData?.assigned_to && (
|
{confirmationData?.assigned_to && (
|
||||||
<View style={styles.confirmInfo}>
|
<View style={styles.confirmInfo}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getMyCompletedOrders,
|
getMyCompletedOrders,
|
||||||
getMyPenalties,
|
getMyPenalties,
|
||||||
getPublicSettings,
|
getPublicSettings,
|
||||||
|
getReferralBalance,
|
||||||
formatOrderDate,
|
formatOrderDate,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
} from "../../api/api";
|
} from "../../api/api";
|
||||||
@@ -44,16 +45,18 @@ export default function OrderHistoryScreen() {
|
|||||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||||
const [stats, setStats] = useState<ClientStats | null>(null);
|
const [stats, setStats] = useState<ClientStats | null>(null);
|
||||||
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
|
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
|
||||||
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true });
|
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true });
|
||||||
|
const [referralBalance, setReferralBalance] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [histRes, penRes, settings] = await Promise.all([
|
const [histRes, penRes, settings, refRes] = await Promise.all([
|
||||||
getMyCompletedOrders(),
|
getMyCompletedOrders(),
|
||||||
getMyPenalties(),
|
getMyPenalties(),
|
||||||
getPublicSettings(),
|
getPublicSettings(),
|
||||||
|
getReferralBalance(),
|
||||||
]);
|
]);
|
||||||
if (histRes.success) {
|
if (histRes.success) {
|
||||||
setOrders(histRes.commands || []);
|
setOrders(histRes.commands || []);
|
||||||
@@ -62,6 +65,9 @@ export default function OrderHistoryScreen() {
|
|||||||
if (penRes.success) {
|
if (penRes.success) {
|
||||||
setPenalties(penRes.data || null);
|
setPenalties(penRes.data || null);
|
||||||
}
|
}
|
||||||
|
if (refRes.success) {
|
||||||
|
setReferralBalance(refRes.balance);
|
||||||
|
}
|
||||||
setAppSettings(settings);
|
setAppSettings(settings);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -118,28 +124,6 @@ export default function OrderHistoryScreen() {
|
|||||||
fontSize: fontSize.xs,
|
fontSize: fontSize.xs,
|
||||||
marginTop: spacing.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: {
|
sectionTitle: {
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
fontSize: fontSize.sm,
|
fontSize: fontSize.sm,
|
||||||
@@ -148,6 +132,24 @@ export default function OrderHistoryScreen() {
|
|||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: spacing.m,
|
marginBottom: spacing.m,
|
||||||
},
|
},
|
||||||
|
referralBtn: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accent + "44",
|
||||||
|
paddingVertical: spacing.m,
|
||||||
|
paddingHorizontal: spacing.l,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
referralBtnText: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingTop: spacing.xxxl,
|
paddingTop: spacing.xxxl,
|
||||||
@@ -303,42 +305,63 @@ export default function OrderHistoryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</View>
|
{appSettings.show_amende_score && (
|
||||||
|
<View
|
||||||
{appSettings.show_amende_score && 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={[
|
style={[
|
||||||
styles.penaltyText,
|
styles.statCard,
|
||||||
{
|
shadows.sm,
|
||||||
color:
|
penaltyCount > 0 && {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor:
|
||||||
penaltyCount >= 3
|
penaltyCount >= 3
|
||||||
? colors.danger
|
? colors.danger + "88"
|
||||||
: colors.warning,
|
: colors.warning + "88",
|
||||||
|
backgroundColor:
|
||||||
|
penaltyCount >= 3
|
||||||
|
? colors.danger + "18"
|
||||||
|
: colors.warning + "18",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{penaltyCount} penalite
|
<Ionicons
|
||||||
{penaltyCount > 1 ? "s" : ""}
|
name={penaltyCount > 0 ? "warning" : "shield-checkmark-outline"}
|
||||||
</Text>
|
size={24}
|
||||||
</View>
|
color={
|
||||||
)}
|
penaltyCount >= 3
|
||||||
|
? colors.danger
|
||||||
|
: penaltyCount > 0
|
||||||
|
? colors.warning
|
||||||
|
: colors.textMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statValue,
|
||||||
|
penaltyCount >= 3 && { color: colors.danger },
|
||||||
|
penaltyCount > 0 && penaltyCount < 3 && { color: colors.warning },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{penaltyCount}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statLabel}>
|
||||||
|
Score amendes
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bouton parrainage — visible seulement si activé dans les settings */}
|
||||||
|
{appSettings.referral_enabled && <TouchableOpacity
|
||||||
|
style={styles.referralBtn}
|
||||||
|
onPress={() => navigation.navigate("Parrainage")}
|
||||||
|
>
|
||||||
|
<Ionicons name="gift-outline" size={18} color={colors.accent} />
|
||||||
|
<Text style={styles.referralBtnText}>
|
||||||
|
Parrainage
|
||||||
|
{referralBalance > 0 ? ` — ${referralBalance.toFixed(2)} €` : ""}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>}
|
||||||
|
|
||||||
{orders.length > 0 && (
|
{orders.length > 0 && (
|
||||||
<Text style={styles.sectionTitle}>
|
<Text style={styles.sectionTitle}>
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Linking,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
|
||||||
|
import { getReferralBalance } from "../../api/api";
|
||||||
|
|
||||||
|
export default function ParrainageScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [balance, setBalance] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getReferralBalance().then((res) => {
|
||||||
|
if (res.success) setBalance(res.balance);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
icon: "chatbubble-ellipses-outline" as const,
|
||||||
|
title: "1. Contacte-nous sur Telegram",
|
||||||
|
desc: "Envoie un message à @milieu_nantais en indiquant ton username et le username de la personne que tu as parrainée.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "checkmark-circle-outline" as const,
|
||||||
|
title: "2. Validation par l'admin",
|
||||||
|
desc: "L'admin vérifie le parrainage et crédite manuellement un solde sur ton compte.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "wallet-outline" as const,
|
||||||
|
title: "3. Crédit disponible",
|
||||||
|
desc: "Le solde apparaît dans ton profil et au moment du paiement. Tu choisis de l'utiliser ou de le cumuler.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "cart-outline" as const,
|
||||||
|
title: "4. Utilisation à la commande",
|
||||||
|
desc: "Au checkout, active l'option \"Utiliser mon crédit parrainage\". Le montant sera déduit de ta commande.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||||
|
content: { padding: spacing.xl, paddingBottom: spacing.xxxl },
|
||||||
|
balanceCard: {
|
||||||
|
backgroundColor: colors.accent + "18",
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accent + "44",
|
||||||
|
padding: spacing.xl,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
balanceLabel: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
},
|
||||||
|
balanceAmount: {
|
||||||
|
color: colors.accent,
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.lg,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
stepCard: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.borderLight,
|
||||||
|
padding: spacing.l,
|
||||||
|
marginBottom: spacing.m,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.m,
|
||||||
|
},
|
||||||
|
stepIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.accent + "18",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
stepContent: { flex: 1 },
|
||||||
|
stepTitle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
stepDesc: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
ruleCard: {
|
||||||
|
backgroundColor: colors.warning + "12",
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning + "44",
|
||||||
|
padding: spacing.l,
|
||||||
|
marginTop: spacing.m,
|
||||||
|
marginBottom: spacing.l,
|
||||||
|
},
|
||||||
|
ruleTitle: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
},
|
||||||
|
ruleText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
ruleExample: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: fontSize.sm,
|
||||||
|
fontWeight: fontWeight.semibold,
|
||||||
|
marginTop: spacing.s,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
telegramBtn: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.s,
|
||||||
|
backgroundColor: "#229ED9",
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.l,
|
||||||
|
marginTop: spacing.l,
|
||||||
|
},
|
||||||
|
telegramText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: fontSize.md,
|
||||||
|
fontWeight: fontWeight.bold,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Solde actuel */}
|
||||||
|
<View style={styles.balanceCard}>
|
||||||
|
<Ionicons name="gift-outline" size={32} color={colors.accent} />
|
||||||
|
<Text style={styles.balanceLabel}>Mon solde parrainage</Text>
|
||||||
|
<Text style={styles.balanceAmount}>{balance.toFixed(2)} €</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Comment ça marche */}
|
||||||
|
<Text style={styles.sectionTitle}>Comment ca marche ?</Text>
|
||||||
|
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<View key={i} style={styles.stepCard}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Ionicons name={step.icon} size={20} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepContent}>
|
||||||
|
<Text style={styles.stepTitle}>{step.title}</Text>
|
||||||
|
<Text style={styles.stepDesc}>{step.desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Règle minimum de zone */}
|
||||||
|
<View style={styles.ruleCard}>
|
||||||
|
<Text style={styles.ruleTitle}>⚠️ Règle importante</Text>
|
||||||
|
<Text style={styles.ruleText}>
|
||||||
|
Même avec du crédit parrainage, tu dois toujours payer au minimum le seuil de ta zone de livraison.
|
||||||
|
Le crédit est déduit en plus du montant minimum.
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.ruleExample}>
|
||||||
|
Exemple : crédit 50 € + zone 50 € = commande de 100 € minimum.
|
||||||
|
Tu paies 50 € et le reste est couvert par ton crédit.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bouton Telegram */}
|
||||||
|
<View
|
||||||
|
style={styles.telegramBtn}
|
||||||
|
// Utiliser TouchableOpacity si besoin d'interaction
|
||||||
|
>
|
||||||
|
<Ionicons name="paper-plane-outline" size={20} color="#fff" />
|
||||||
|
<Text
|
||||||
|
style={styles.telegramText}
|
||||||
|
onPress={() => Linking.openURL("https://t.me/milieu_nantais")}
|
||||||
|
>
|
||||||
|
Contacter @milieu_nantais
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -285,6 +285,11 @@ export default function ProductsScreen() {
|
|||||||
productId: item.id,
|
productId: item.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
categoryColor={
|
||||||
|
categories.find(
|
||||||
|
(c) => c.name.toLowerCase() === item.category?.toLowerCase(),
|
||||||
|
)?.color
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user