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
+18 -2
View File
@@ -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 };
}
};
+8
View File
@@ -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 {
+3 -2
View File
@@ -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>
);
}
+1
View File
@@ -30,6 +30,7 @@ export type ClientStackParamList = {
ProductDetail: { productId: number };
Checkout: undefined;
OrderDetails: { orderId: number };
Parrainage: undefined;
};
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 {
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>
)}