chore: add parrainage
This commit is contained in:
@@ -36,6 +36,15 @@
|
||||
"plugins": [
|
||||
"expo-font",
|
||||
"expo-location",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#ffffff",
|
||||
"androidMode": "default",
|
||||
"androidCollapsedTitle": "#{unread_notifications} nouvelles commandes"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
|
||||
@@ -471,6 +471,44 @@ export const deleteClientAdmin = async (clientId: number) => {
|
||||
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
|
||||
// ============================================
|
||||
@@ -676,6 +714,7 @@ export interface AppSettings {
|
||||
points_separated: boolean;
|
||||
points_weed_tiers: PointsTier[];
|
||||
points_zipette_tiers: PointsTier[];
|
||||
referral_enabled: boolean;
|
||||
}
|
||||
|
||||
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 (
|
||||
settings: AppSettings,
|
||||
): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
|
||||
|
||||
@@ -351,6 +351,35 @@ export interface PublicSettings {
|
||||
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> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(`http://5.181.0.112/api/v1/app-settings`);
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ClientResponse {
|
||||
amende: number;
|
||||
cancellations_count: number;
|
||||
last_penalty_reason?: string;
|
||||
referral_balance?: number;
|
||||
}
|
||||
|
||||
export interface CommandResponse {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Modal,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
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 { useAuth } from "../auth/AuthContext";
|
||||
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 type { AdminTabParamList, AdminStackParamList } from "./types";
|
||||
|
||||
@@ -30,176 +42,288 @@ function AdminTabs() {
|
||||
const { colors, isDark, toggleTheme } = useTheme();
|
||||
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 () => {
|
||||
await logoutAdmin();
|
||||
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 (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||
headerTintColor: colors.textWhite,
|
||||
headerRight: () => (
|
||||
<>
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||
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
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.l,
|
||||
}}
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: colors.bgSecondary },
|
||||
]}
|
||||
>
|
||||
<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 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.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>
|
||||
),
|
||||
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>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,3 +355,68 @@ export default function AdminNavigator() {
|
||||
</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 { TouchableOpacity, View } from "react-native";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Modal,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
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 type { CabineTabParamList } from "./types";
|
||||
|
||||
@@ -20,123 +32,298 @@ export default function CabineNavigator() {
|
||||
const { logout } = useAuth();
|
||||
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 () => {
|
||||
await logoutAdmin();
|
||||
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 (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||
headerTintColor: colors.textWhite,
|
||||
headerRight: () => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.l,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={toggleTheme}
|
||||
style={{ marginRight: spacing.m }}
|
||||
<>
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||
headerTintColor: colors.textWhite,
|
||||
headerRight: () => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.l,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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={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>
|
||||
),
|
||||
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>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
getAvailableDeliveryPersons,
|
||||
getCommandCountByStatus,
|
||||
registerAdminPushToken,
|
||||
unregisterAdminPushToken,
|
||||
} from "../../api/api_admin";
|
||||
import { getExpoPushToken } from "../../utils/pushTokenUtils";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
|
||||
interface StatCard {
|
||||
@@ -33,6 +36,14 @@ export default function DashboardScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 () => {
|
||||
try {
|
||||
const [allCmd, pending, enRoute, completed, clients, livreurs] =
|
||||
|
||||
@@ -149,6 +149,7 @@ export default function SettingsScreen() {
|
||||
points_separated: true,
|
||||
points_weed_tiers: [],
|
||||
points_zipette_tiers: [],
|
||||
referral_enabled: true,
|
||||
});
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
@@ -350,6 +351,28 @@ export default function SettingsScreen() {
|
||||
</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 */}
|
||||
<View style={s.section}>
|
||||
<Text style={s.sectionTitle}>Système de points</Text>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
deleteClientAdmin,
|
||||
createClientByAdmin,
|
||||
createUserByAdmin,
|
||||
creditClientReferral,
|
||||
} from "../../api/api_admin";
|
||||
import type { ClientResponse } from "../../api/types";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
@@ -155,6 +156,15 @@ export default function UsersScreen() {
|
||||
const [createTel, setCreateTel] = useState("");
|
||||
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();
|
||||
|
||||
// --------------------------------------------------
|
||||
@@ -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
|
||||
// --------------------------------------------------
|
||||
@@ -603,16 +647,28 @@ export default function UsersScreen() {
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", gap: spacing.xs }}>
|
||||
{isClient && (
|
||||
<TouchableOpacity
|
||||
style={styles.editIconBtn}
|
||||
onPress={() => openEditClient(item.clientData!)}
|
||||
>
|
||||
<Ionicons
|
||||
name="create-outline"
|
||||
size={20}
|
||||
color={colors.info}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.editIconBtn}
|
||||
onPress={() => openReferralModal(item.clientData!)}
|
||||
>
|
||||
<Ionicons
|
||||
name="gift-outline"
|
||||
size={20}
|
||||
color={colors.success}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.editIconBtn}
|
||||
onPress={() => openEditClient(item.clientData!)}
|
||||
>
|
||||
<Ionicons
|
||||
name="create-outline"
|
||||
size={20}
|
||||
color={colors.info}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
{(item.role === "livreur" ||
|
||||
item.role === "cabine") && (
|
||||
@@ -704,6 +760,19 @@ export default function UsersScreen() {
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Annul.</Text>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -977,6 +1046,35 @@ export default function UsersScreen() {
|
||||
confirmText={alert.confirmText}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,21 @@ import { useTheme } from "../../context/ThemeContext";
|
||||
import { shadows } from "../../theme/shadows";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
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";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { username } = useAuth();
|
||||
|
||||
// Enregistrement push token cabine au montage
|
||||
useEffect(() => {
|
||||
getExpoPushToken().then((token) => {
|
||||
if (token) registerCabinePushToken(token);
|
||||
});
|
||||
return () => { unregisterCabinePushToken(); };
|
||||
}, []);
|
||||
const [stats, setStats] = useState<
|
||||
Array<{
|
||||
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,
|
||||
CancelCommandResponse,
|
||||
PenaltiesResponse,
|
||||
ReferralBalanceResponse,
|
||||
} from "./api_types";
|
||||
import { getToken } from "../auth/tokenStorage";
|
||||
import { extractUsernameFromToken } from "../auth/jwtUtils";
|
||||
@@ -290,6 +291,7 @@ export const checkoutCart = async (
|
||||
nom?: string,
|
||||
prenom?: string,
|
||||
telephone?: string,
|
||||
use_referral_balance?: boolean,
|
||||
): Promise<CheckoutCartResponse> => {
|
||||
const jwtUsername = await getJwtUsername();
|
||||
if (!jwtUsername) return { success: false, message: "Session invalide" };
|
||||
@@ -299,9 +301,10 @@ export const checkoutCart = async (
|
||||
message: "Veuillez saisir une adresse de livraison",
|
||||
};
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: jwtUsername,
|
||||
delivery_address,
|
||||
use_referral_balance: use_referral_balance ?? false,
|
||||
};
|
||||
if (nom) payload.nom = nom;
|
||||
if (prenom) payload.prenom = prenom;
|
||||
@@ -315,6 +318,8 @@ export const checkoutCart = async (
|
||||
command: data.command,
|
||||
assigned_to: data.assigned_to,
|
||||
queue_info: data.queue_info,
|
||||
referral_used: data.referral_used,
|
||||
referral_balance: data.referral_balance,
|
||||
};
|
||||
} catch (error: any) {
|
||||
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 (
|
||||
commandId: number,
|
||||
reqData?: { rating?: number; comment?: string },
|
||||
@@ -713,6 +727,7 @@ export interface PublicSettings {
|
||||
show_amende_score: boolean;
|
||||
points_enabled: boolean;
|
||||
points_separated: boolean;
|
||||
referral_enabled: boolean;
|
||||
}
|
||||
|
||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
@@ -723,9 +738,10 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
show_amende_score: data.show_amende_score ?? true,
|
||||
points_enabled: data.points_enabled ?? true,
|
||||
points_separated: data.points_separated ?? true,
|
||||
referral_enabled: data.referral_enabled ?? true,
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
|
||||
export interface ReferralBalanceResponse {
|
||||
success: boolean;
|
||||
balance: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutCartResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -755,6 +761,8 @@ export interface CheckoutCartResponse {
|
||||
status: string;
|
||||
estimated_wait: string;
|
||||
};
|
||||
referral_used?: number;
|
||||
referral_balance?: number;
|
||||
}
|
||||
|
||||
export interface ConfirmReceptionResponse {
|
||||
|
||||
@@ -32,12 +32,13 @@ interface ProductCardProps {
|
||||
media?: Array<{ url: string; type: string }>;
|
||||
};
|
||||
onPress: () => void;
|
||||
categoryColor?: string;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, onPress }: ProductCardProps) {
|
||||
export default function ProductCard({ product, onPress, categoryColor }: ProductCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const { addToCart } = useCart();
|
||||
const catColor = getCategoryColor(product.category, colors);
|
||||
const catColor = categoryColor ?? getCategoryColor(product.category, colors);
|
||||
const isSoldOut = product.stock <= 0;
|
||||
const firstPrice = product.prices?.[0]?.price ?? null;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import OrderHistoryScreen from "../screens/client/OrderHistoryScreen";
|
||||
import ProductDetailScreen from "../screens/client/ProductDetailScreen";
|
||||
import CheckoutScreen from "../screens/client/CheckoutScreen";
|
||||
import OrderDetailsScreen from "../screens/client/OrderDetailsScreen";
|
||||
import ParrainageScreen from "../screens/client/ParrainageScreen";
|
||||
|
||||
const Tab = createBottomTabNavigator<ClientTabParamList>();
|
||||
const Stack = createNativeStackNavigator<ClientStackParamList>();
|
||||
@@ -350,6 +351,11 @@ export default function ClientNavigator() {
|
||||
component={OrderDetailsScreen}
|
||||
options={{ title: "Detail commande" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Parrainage"
|
||||
component={ParrainageScreen}
|
||||
options={{ title: "Parrainage" }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ClientStackParamList = {
|
||||
ProductDetail: { productId: number };
|
||||
Checkout: undefined;
|
||||
OrderDetails: { orderId: number };
|
||||
Parrainage: undefined;
|
||||
};
|
||||
|
||||
export type DeliveryTabParamList = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCart } from "../../context/CartContext";
|
||||
import { checkoutCart } from "../../api/api";
|
||||
import { checkoutCart, getReferralBalance } from "../../api/api";
|
||||
import type { ClientStackParamList } from "../../navigation/types";
|
||||
import TextInput from "../../components/ui/TextInput";
|
||||
import Button from "../../components/ui/Button";
|
||||
@@ -36,6 +38,14 @@ export default function CheckoutScreen() {
|
||||
const [confirmationData, setConfirmationData] = useState<any>(null);
|
||||
const [invalidAddressModal, setInvalidAddressModal] = useState(false);
|
||||
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 () => {
|
||||
if (!nom.trim()) {
|
||||
@@ -67,6 +77,7 @@ export default function CheckoutScreen() {
|
||||
nom.trim(),
|
||||
prenom.trim(),
|
||||
telephone.trim(),
|
||||
useReferral && referralBalance > 0,
|
||||
);
|
||||
if (res.success) {
|
||||
setConfirmationData(res);
|
||||
@@ -158,6 +169,36 @@ export default function CheckoutScreen() {
|
||||
fontSize: fontSize.sm,
|
||||
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 },
|
||||
invalidAddrIconContainer: { alignItems: "center" },
|
||||
invalidAddrIconCircle: {
|
||||
@@ -372,6 +413,34 @@ export default function CheckoutScreen() {
|
||||
/>
|
||||
</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 &&
|
||||
nom.trim() &&
|
||||
prenom.trim() &&
|
||||
@@ -461,6 +530,21 @@ export default function CheckoutScreen() {
|
||||
Votre commande #{confirmationData?.command_id} a ete
|
||||
creee avec succes.
|
||||
</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 && (
|
||||
<View style={styles.confirmInfo}>
|
||||
<Ionicons
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getMyCompletedOrders,
|
||||
getMyPenalties,
|
||||
getPublicSettings,
|
||||
getReferralBalance,
|
||||
formatOrderDate,
|
||||
formatPrice,
|
||||
} from "../../api/api";
|
||||
@@ -44,16 +45,18 @@ export default function OrderHistoryScreen() {
|
||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||
const [stats, setStats] = useState<ClientStats | 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 [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [histRes, penRes, settings] = await Promise.all([
|
||||
const [histRes, penRes, settings, refRes] = await Promise.all([
|
||||
getMyCompletedOrders(),
|
||||
getMyPenalties(),
|
||||
getPublicSettings(),
|
||||
getReferralBalance(),
|
||||
]);
|
||||
if (histRes.success) {
|
||||
setOrders(histRes.commands || []);
|
||||
@@ -62,6 +65,9 @@ export default function OrderHistoryScreen() {
|
||||
if (penRes.success) {
|
||||
setPenalties(penRes.data || null);
|
||||
}
|
||||
if (refRes.success) {
|
||||
setReferralBalance(refRes.balance);
|
||||
}
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -118,28 +124,6 @@ export default function OrderHistoryScreen() {
|
||||
fontSize: fontSize.xs,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
penaltyBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.s,
|
||||
padding: spacing.m,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
penaltyWarning: {
|
||||
backgroundColor: colors.warning + "22",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning + "44",
|
||||
},
|
||||
penaltyCritical: {
|
||||
backgroundColor: colors.danger + "22",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.danger + "44",
|
||||
},
|
||||
penaltyText: {
|
||||
fontSize: fontSize.sm,
|
||||
fontWeight: fontWeight.semibold,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fontSize.sm,
|
||||
@@ -148,6 +132,24 @@ export default function OrderHistoryScreen() {
|
||||
letterSpacing: 1,
|
||||
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: {
|
||||
alignItems: "center",
|
||||
paddingTop: spacing.xxxl,
|
||||
@@ -303,42 +305,63 @@ export default function OrderHistoryScreen() {
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</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
|
||||
{appSettings.show_amende_score && (
|
||||
<View
|
||||
style={[
|
||||
styles.penaltyText,
|
||||
{
|
||||
color:
|
||||
styles.statCard,
|
||||
shadows.sm,
|
||||
penaltyCount > 0 && {
|
||||
borderWidth: 1,
|
||||
borderColor:
|
||||
penaltyCount >= 3
|
||||
? colors.danger
|
||||
: colors.warning,
|
||||
? colors.danger + "88"
|
||||
: colors.warning + "88",
|
||||
backgroundColor:
|
||||
penaltyCount >= 3
|
||||
? colors.danger + "18"
|
||||
: colors.warning + "18",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{penaltyCount} penalite
|
||||
{penaltyCount > 1 ? "s" : ""}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Ionicons
|
||||
name={penaltyCount > 0 ? "warning" : "shield-checkmark-outline"}
|
||||
size={24}
|
||||
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 && (
|
||||
<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,
|
||||
})
|
||||
}
|
||||
categoryColor={
|
||||
categories.find(
|
||||
(c) => c.name.toLowerCase() === item.category?.toLowerCase(),
|
||||
)?.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user