chore: add parrainage

This commit is contained in:
2026-03-08 13:57:25 +01:00
parent 51c65d1179
commit fee1972435
20 changed files with 1356 additions and 348 deletions
+9
View File
@@ -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",
{ {
+68
View File
@@ -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 }> => {
+29
View File
@@ -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`);
+1
View File
@@ -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 {
+353 -164
View File
@@ -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,
},
});
+299 -112
View File
@@ -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>
+108 -10
View File
@@ -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
View File
@@ -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 };
} }
}; };
+8
View File
@@ -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 {
+3 -2
View File
@@ -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>
); );
} }
+1
View File
@@ -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 = {
+86 -2
View File
@@ -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>
)} )}