chore: build

This commit is contained in:
2026-06-14 18:09:44 +02:00
parent 3a0f725159
commit 6d4e0862ff
45 changed files with 3745 additions and 873 deletions
@@ -60,7 +60,7 @@ export default function ChangePasswordScreen() {
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.bgPrimary }]}
behavior="height"
behavior="padding"
>
<ScrollView
contentContainerStyle={styles.scrollContent}
+80 -93
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import {
View,
Text,
@@ -85,25 +85,89 @@ const LoginClient = () => {
if (apiError) setApiError("");
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgSecondary },
scrollContent: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
card: {
width: "100%",
maxWidth: 400,
borderRadius: 16,
padding: 24,
backgroundColor: colors.bgPrimary,
},
header: { alignItems: "center", marginBottom: 24 },
title: {
fontSize: 24,
fontWeight: "bold",
marginTop: 8,
color: colors.textPrimary,
},
subtitle: {
fontSize: 14,
marginTop: 4,
textAlign: "center",
color: colors.textMuted,
},
apiError: {
backgroundColor: "#fee2e2",
color: "#991b1b",
padding: 12,
borderRadius: 6,
marginBottom: 16,
textAlign: "center",
},
inputGroup: { marginBottom: 16 },
label: { marginBottom: 4, fontWeight: "500", color: colors.textSecondary },
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
icon: { marginRight: 8 },
input: { flex: 1, height: 40, color: colors.textPrimary },
inputError: { borderColor: "#ef4444" },
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
eyeButton: { padding: 4 },
submitButton: {
backgroundColor: colors.accent,
borderRadius: 8,
padding: 12,
alignItems: "center",
marginTop: 8,
},
submitText: { color: colors.white, fontWeight: "600", fontSize: 16 },
}),
[colors],
);
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.bgSecondary }]}
behavior="height"
style={styles.container}
behavior="padding"
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.card, { backgroundColor: colors.bgPrimary }]}>
<View style={styles.card}>
<View style={styles.header}>
<FontAwesome name="user-circle" size={48} color="#7c3aed" />
<Text style={[styles.title, { color: colors.textWhite }]}>
<FontAwesome name="user-circle" size={48} color={colors.accent} />
<Text style={styles.title}>
Connexion Client
</Text>
<Text
style={[styles.subtitle, { color: colors.textMuted }]}
>
<Text style={styles.subtitle}>
Accédez à votre espace personnel
</Text>
</View>
@@ -113,20 +177,10 @@ const LoginClient = () => {
) : null}
<View style={styles.inputGroup}>
<Text
style={[styles.label, { color: colors.textSecondary }]}
>
<Text style={styles.label}>
Username Telegram
</Text>
<View
style={[
styles.inputWrapper,
{
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
]}
>
<View style={styles.inputWrapper}>
<Feather
name="user"
size={20}
@@ -134,11 +188,7 @@ const LoginClient = () => {
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{ color: colors.textWhite },
errors.username && styles.inputError,
]}
style={[styles.input, errors.username && styles.inputError]}
placeholder="Votre username"
placeholderTextColor={colors.textMuted}
value={formData.username}
@@ -156,20 +206,10 @@ const LoginClient = () => {
</View>
<View style={styles.inputGroup}>
<Text
style={[styles.label, { color: colors.textSecondary }]}
>
<Text style={styles.label}>
Mot de passe
</Text>
<View
style={[
styles.inputWrapper,
{
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
]}
>
<View style={styles.inputWrapper}>
<Feather
name="lock"
size={20}
@@ -177,11 +217,7 @@ const LoginClient = () => {
style={styles.icon}
/>
<TextInput
style={[
styles.input,
{ color: colors.textWhite },
errors.password && styles.inputError,
]}
style={[styles.input, errors.password && styles.inputError]}
placeholder="••••••"
placeholderTextColor={colors.textMuted}
secureTextEntry={!showPassword}
@@ -215,7 +251,7 @@ const LoginClient = () => {
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="white" />
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.submitText}>Se connecter</Text>
)}
@@ -227,52 +263,3 @@ const LoginClient = () => {
};
export default LoginClient;
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
card: { width: "100%", maxWidth: 400, borderRadius: 16, padding: 24 },
header: { alignItems: "center", marginBottom: 24 },
title: { fontSize: 24, fontWeight: "bold", marginTop: 8 },
subtitle: { fontSize: 14, marginTop: 4, textAlign: "center" },
apiError: {
backgroundColor: "#fee2e2",
color: "#991b1b",
padding: 12,
borderRadius: 6,
marginBottom: 16,
textAlign: "center",
},
inputGroup: { marginBottom: 16 },
label: { marginBottom: 4, fontWeight: "500" },
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
},
icon: { marginRight: 8 },
input: { flex: 1, height: 40 },
inputError: { borderColor: "#ef4444" },
errorText: { color: "#ef4444", fontSize: 12, marginTop: 4 },
eyeButton: { padding: 4 },
submitButton: {
backgroundColor: "#7c3aed",
borderRadius: 8,
padding: 12,
alignItems: "center",
marginTop: 8,
},
submitText: { color: "white", fontWeight: "600", fontSize: 16 },
signup: { marginTop: 16, alignItems: "center" },
signupText: {},
signupLink: { color: "#a78bfa", fontWeight: "600" },
});
+39 -5
View File
@@ -21,6 +21,8 @@ import {
getReferralBalance,
getPublicSettings,
getCryptoPaymentStatus,
getTelegramStatus,
getMyProfile,
} from "../../api/api";
import type { CryptoPaymentStatus } from "../../api/api";
import type { ClientStackParamList } from "../../navigation/types";
@@ -57,6 +59,7 @@ export default function CheckoutScreen() {
// Notif telegram
const [telegramNotificationEnabled, setTelegramNotificationEnabled] =
useState(false);
const [telegramLinked, setTelegramLinked] = useState(false);
// Crypto
const [cryptoEnabled, setCryptoEnabled] = useState(false);
@@ -105,6 +108,18 @@ export default function CheckoutScreen() {
if (savedPhone) setTelephone(savedPhone);
});
getMyProfile().then((res) => {
if (res.success && res.client) {
if (res.client.nom) setNom((prev) => prev || res.client.nom);
if (res.client.prenom) setPrenom((prev) => prev || res.client.prenom);
if (res.client.telephone) setTelephone((prev) => prev || res.client.telephone);
}
});
getTelegramStatus().then((res) => {
if (res.linked) setTelegramLinked(true);
});
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
@@ -657,9 +672,12 @@ export default function CheckoutScreen() {
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
behavior="padding"
>
<ScrollView contentContainerStyle={styles.content}>
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Résumé commande */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
@@ -687,9 +705,22 @@ export default function CheckoutScreen() {
<View style={styles.summaryTotal}>
<Text style={styles.summaryTotalLabel}>Total</Text>
<Text style={styles.summaryTotalValue}>
{cartTotal.toFixed(2)}
{(useReferral && referralBalance > 0
? Math.max(0, cartTotal - referralBalance)
: cartTotal
).toFixed(2)}
</Text>
</View>
{useReferral && referralBalance > 0 && (
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm }}>
Dont crédit parrainage
</Text>
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>
-{Math.min(referralBalance, cartTotal).toFixed(2)}
</Text>
</View>
)}
</View>
</View>
@@ -1011,7 +1042,7 @@ export default function CheckoutScreen() {
)}
{/* Note Telegram */}
{telegramNotificationEnabled && (
{telegramNotificationEnabled && !telegramLinked && (
<View style={styles.telegramNote}>
<Ionicons
name="paper-plane-outline"
@@ -1166,7 +1197,10 @@ export default function CheckoutScreen() {
<View style={styles.recapTotalRow}>
<Text style={styles.recapTotalLabel}>Total</Text>
<Text style={styles.recapTotalValue}>
{cartTotal.toFixed(2)} €
{(useReferral && referralBalance > 0
? Math.max(0, cartTotal - referralBalance)
: cartTotal
).toFixed(2)} €
</Text>
</View>
@@ -315,10 +315,12 @@ export default function OrderDetailsScreen() {
const currentIdx = STATUS_INDEX[status] ?? -1;
const isCancelled = status === "cancelled";
const address = order.delivery_address || order.adresse || "N/A";
const total =
const rawTotal =
order.total ||
order.total_prix ||
products.reduce((s: number, p: any) => s + (p.prix || p.price || 0), 0);
const referralUsed: number = order.referral_used || 0;
const total = rawTotal - referralUsed;
return (
<ScrollView
@@ -524,6 +526,14 @@ export default function OrderDetailsScreen() {
</Card>
<Card>
{referralUsed > 0 && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Crédit parrainage</Text>
<Text style={[styles.totalValue, { color: colors.accent }]}>
-{formatPrice(referralUsed)}
</Text>
</View>
)}
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total</Text>
<Text style={styles.totalValue}>{formatPrice(total)}</Text>
@@ -15,10 +15,12 @@ import {
getMyPenalties,
getPublicSettings,
getReferralBalance,
getMyPointsRewards,
claimMyReward,
formatOrderDate,
formatPrice,
} from "../../api/api";
import type { PublicSettings } from "../../api/api";
import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api";
import type {
CompletedOrder,
ClientStats,
@@ -45,18 +47,26 @@ 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, referral_enabled: false, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [], crypto_only: false, telegram_notifications_enabled: false });
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: false, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [], crypto_only: false, telegram_notifications_enabled: false, two_fa_enabled: false, contact_telegram: "" });
const [referralBalance, setReferralBalance] = useState(0);
const [pointsRewards, setPointsRewards] = useState<{
enabled: boolean;
pools: PointsPoolInfo[];
reward: PointsRewardConfig | null;
} | null>(null);
const [claimingPool, setClaimingPool] = useState<string | null>(null);
const [claimFeedback, setClaimFeedback] = useState<{ pool: string; type: "success" | "error"; text: string } | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchData = useCallback(async () => {
try {
const [histRes, penRes, settings, refRes] = await Promise.all([
const [histRes, penRes, settings, refRes, rewardsRes] = await Promise.all([
getMyCompletedOrders(),
getMyPenalties(),
getPublicSettings(),
getReferralBalance(),
getMyPointsRewards(),
]);
if (histRes.success) {
setOrders(histRes.commands || []);
@@ -69,6 +79,9 @@ export default function OrderHistoryScreen() {
setReferralBalance(refRes.balance);
}
setAppSettings(settings);
if (rewardsRes.success && rewardsRes.enabled) {
setPointsRewards(rewardsRes);
}
} catch {
/* ignore */
} finally {
@@ -77,6 +90,22 @@ export default function OrderHistoryScreen() {
}
}, []);
const handleClaim = async (poolKey: string) => {
setClaimingPool(poolKey);
setClaimFeedback(null);
const res = await claimMyReward(poolKey);
setClaimingPool(null);
if (res.success) {
const text = res.product_added && res.product_name
? `🎁 ${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.`
: res.description || "Récompense réclamée !";
setClaimFeedback({ pool: poolKey, type: "success", text });
getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); });
} else {
setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" });
}
};
useFocusEffect(
useCallback(() => {
setLoading(true);
@@ -132,6 +161,107 @@ export default function OrderHistoryScreen() {
letterSpacing: 1,
marginBottom: spacing.m,
},
rewardsSection: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: "#f59e0b44",
padding: spacing.m,
marginBottom: spacing.l,
},
rewardsSectionTitle: {
color: "#f59e0b",
fontSize: fontSize.xs,
fontWeight: fontWeight.bold,
textTransform: "uppercase",
letterSpacing: 0.8,
},
rewardsSectionDesc: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontStyle: "italic",
marginBottom: spacing.m,
},
rewardPoolCard: {
backgroundColor: "rgba(245,158,11,0.07)",
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: "rgba(245,158,11,0.2)",
padding: spacing.m,
marginTop: spacing.s,
},
rewardPoolName: {
color: colors.textPrimary,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
},
rewardPoolPts: {
color: "#f59e0b",
fontSize: fontSize.sm,
fontWeight: fontWeight.bold,
},
rewardAmountBadge: {
backgroundColor: "rgba(16,185,129,0.15)",
borderRadius: borderRadius.sm,
paddingHorizontal: spacing.s,
paddingVertical: 2,
flexDirection: "row" as const,
},
rewardAmountText: {
color: "#10b981",
fontSize: fontSize.xs,
fontWeight: fontWeight.bold,
},
progressBarBg: {
height: 5,
backgroundColor: "rgba(245,158,11,0.15)",
borderRadius: 3,
overflow: "hidden",
marginVertical: spacing.xs,
},
progressBarFill: {
height: "100%",
backgroundColor: "#f59e0b",
borderRadius: 3,
},
rewardAvailable: {
color: "#10b981",
fontSize: fontSize.xs,
fontWeight: fontWeight.semibold,
marginBottom: spacing.s,
},
rewardRemaining: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginBottom: spacing.s,
},
feedbackSuccess: {
color: "#10b981",
fontSize: fontSize.xs,
fontStyle: "italic",
marginBottom: spacing.xs,
},
feedbackError: {
color: "#ef4444",
fontSize: fontSize.xs,
fontStyle: "italic",
marginBottom: spacing.xs,
},
claimBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
backgroundColor: "#f59e0b",
borderRadius: borderRadius.sm,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
},
claimBtnText: {
color: "#fff",
fontSize: fontSize.sm,
fontWeight: fontWeight.bold,
},
referralBtn: {
flexDirection: "row",
alignItems: "center",
@@ -342,6 +472,86 @@ export default function OrderHistoryScreen() {
)}
</View>
{/* Section récompenses */}
{pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && (
<View style={styles.rewardsSection}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginBottom: spacing.xs }}>
<Ionicons name="trophy-outline" size={14} color="#f59e0b" />
<Text style={styles.rewardsSectionTitle}>Récompenses</Text>
</View>
{pointsRewards.reward.description !== "" && (
<Text style={styles.rewardsSectionDesc}>{pointsRewards.reward.description}</Text>
)}
{(pointsRewards.reward.reward_items ?? []).filter((it) => it.price > 0 || it.product_name).length > 0 && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginBottom: spacing.s }}>
{(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => (
<View key={idx} style={[styles.rewardAmountBadge, { flexDirection: "row", alignItems: "center", gap: 4 }]}>
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
<Text style={styles.rewardAmountText}>
{it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? `${it.price}` : ""}
</Text>
</View>
))}
</View>
)}
{pointsRewards.pools.map((pool) => {
const threshold = pointsRewards.reward!.threshold;
const progress = Math.min(1, (pool.points % threshold) / threshold);
const remaining = threshold - (pool.points % threshold);
const isClaiming = claimingPool === pool.key;
const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null;
return (
<View key={pool.key} style={styles.rewardPoolCard}>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: spacing.xs }}>
<Text style={styles.rewardPoolName}>{pool.name}</Text>
<Text style={styles.rewardPoolPts}>{pool.points} pts</Text>
</View>
{pool.eligible_configs.length > 0 && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginBottom: spacing.xs }}>
{pool.eligible_configs.flatMap((cfg) =>
cfg.all_products
? [<View key={cfg.category} style={styles.rewardAmountBadge}>
<Text style={styles.rewardAmountText}>{cfg.category}</Text>
</View>]
: (cfg.product_names ?? []).map((name) => (
<View key={`${cfg.category}-${name}`} style={styles.rewardAmountBadge}>
<Text style={styles.rewardAmountText}>{name}</Text>
</View>
))
)}
</View>
)}
<View style={styles.progressBarBg}>
<View style={[styles.progressBarFill, { width: `${Math.round(progress * 100)}%` as any }]} />
</View>
<Text style={pool.rewards_available > 0 ? styles.rewardAvailable : styles.rewardRemaining}>
{pool.rewards_available > 0
? `${pool.rewards_available} récompense${pool.rewards_available > 1 ? "s" : ""} disponible${pool.rewards_available > 1 ? "s" : ""}`
: `Encore ${remaining} pts pour une récompense`}
</Text>
{feedback && (
<Text style={feedback.type === "success" ? styles.feedbackSuccess : styles.feedbackError}>
{feedback.text}
</Text>
)}
{pool.rewards_available > 0 && (
<TouchableOpacity
style={[styles.claimBtn, isClaiming && { opacity: 0.5 }]}
onPress={() => handleClaim(pool.key)}
disabled={isClaiming}
>
<Ionicons name="gift-outline" size={14} color="#fff" />
<Text style={styles.claimBtnText}>
{isClaiming ? "..." : "Réclamer ma récompense"}
</Text>
</TouchableOpacity>
)}
</View>
);
})}
</View>
)}
{/* Bouton parrainage — visible seulement si activé dans les settings */}
{appSettings.referral_enabled && <TouchableOpacity
style={styles.referralBtn}
@@ -432,7 +642,7 @@ export default function OrderHistoryScreen() {
)}
<View style={styles.orderFooter}>
<Text style={styles.orderTotal}>
{formatPrice(order.total_prix || 0)}
{formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
</Text>
<Ionicons
name="chevron-forward"
@@ -385,7 +385,8 @@ export default function OrderTrackingScreen() {
contentContainerStyle={styles.list}
renderItem={({ item: order }) => {
const expanded = expandedId === order.id;
const total = calculateOrderTotal(order);
const gross = calculateOrderTotal(order);
const total = Math.max(0, gross - (order.referral_used ?? 0));
const progress = STATUS_PROGRESS[order.status] || 0;
const track = tracking[order.id];
const eta = etas[order.id];
@@ -444,9 +445,16 @@ export default function OrderTrackingScreen() {
</Text>
</View>
<View style={styles.cardFooter}>
<Text style={styles.cardTotal}>
{formatPrice(total)}
</Text>
<View>
<Text style={styles.cardTotal}>
{formatPrice(total)}
</Text>
{(order.referral_used ?? 0) > 0 && (
<Text style={{ fontSize: 11, color: colors.success, marginTop: 2 }}>
dont -{(order.referral_used as number).toFixed(2)} parrainage
</Text>
)}
</View>
<Ionicons
name={
expanded
+25 -14
View File
@@ -5,27 +5,36 @@ import {
ScrollView,
StyleSheet,
Linking,
TouchableOpacity,
} 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";
import { getReferralBalance, getPublicSettings } from "../../api/api";
export default function ParrainageScreen() {
const { colors } = useTheme();
const [balance, setBalance] = useState<number>(0);
const [contactTelegram, setContactTelegram] = useState<string>("");
useEffect(() => {
getReferralBalance().then((res) => {
if (res.success) setBalance(res.balance);
});
getPublicSettings().then((settings) => {
if (settings.contact_telegram) {
setContactTelegram(settings.contact_telegram);
}
});
}, []);
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.",
desc: contactTelegram
? `Envoie un message à @${contactTelegram} en indiquant ton username et le username de la personne que tu as parrainée.`
: "Envoie-nous un message sur Telegram en indiquant ton username et le username de la personne que tu as parrainée.",
},
{
icon: "checkmark-circle-outline" as const,
@@ -152,6 +161,12 @@ export default function ParrainageScreen() {
[colors],
);
const handleTelegramPress = () => {
if (contactTelegram) {
Linking.openURL(`https://t.me/${contactTelegram}`);
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Solde actuel */}
@@ -190,18 +205,14 @@ export default function ParrainageScreen() {
</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>
{contactTelegram ? (
<TouchableOpacity style={styles.telegramBtn} onPress={handleTelegramPress}>
<Ionicons name="paper-plane-outline" size={20} color="#fff" />
<Text style={styles.telegramText}>
Contacter @{contactTelegram}
</Text>
</TouchableOpacity>
) : null}
</ScrollView>
);
}
@@ -56,10 +56,13 @@ export default function ProductDetailScreen() {
const fixedProduct = {
...p,
prices:
p.prices?.map((pr: any) => ({
quantity: parseFloat(String(pr.quantity)),
price: parseFloat(String(pr.price)),
})) || [],
p.prices
?.filter((pr: any) => pr.active_price !== false)
.map((pr: any) => ({
quantity: parseFloat(String(pr.quantity)),
price: parseFloat(String(pr.price)),
active_price: pr.active_price,
})) || [],
};
setProduct(fixedProduct);
if (fixedProduct.prices.length > 0) {
@@ -67,7 +70,9 @@ export default function ProductDetailScreen() {
setSelectedPrice(fixedProduct.prices[0].price);
}
const matched = categories.find(
(c) => c.name.toLowerCase() === (p.category || "").toLowerCase(),
(c) =>
c.name.toLowerCase() ===
(p.category || "").toLowerCase(),
);
if (matched?.color) setCatColor(matched.color);
} else {
@@ -108,11 +113,19 @@ export default function ProductDetailScreen() {
const catTextColor = (() => {
const h = catColor.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
return (r * 299 + g * 587 + b * 114) / 1000 > 128
? "#000000"
: "#ffffff";
})();
const overlayBg = isDark ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)";
@@ -206,6 +219,32 @@ export default function ProductDetailScreen() {
textShadowOffset: { width: 2, height: 2 },
textShadowRadius: 12,
},
comingSoonBadge: {
position: "absolute",
top: "50%",
left: "50%",
transform: [
{ translateX: -80 },
{ translateY: -30 },
{ rotate: "-15deg" },
],
backgroundColor: "rgba(0,0,0,0.8)",
borderWidth: 4,
borderColor: "rgba(34,197,94,0.95)",
paddingHorizontal: 40,
paddingVertical: 16,
elevation: 10,
},
comingSoonText: {
color: "rgba(34,197,94,0.95)",
fontSize: 28,
fontWeight: "900",
letterSpacing: 4,
textTransform: "uppercase",
textShadowColor: "rgba(0,0,0,0.9)",
textShadowOffset: { width: 2, height: 2 },
textShadowRadius: 12,
},
videoBtn: {
position: "absolute",
top: 16,
@@ -467,6 +506,7 @@ export default function ProductDetailScreen() {
}
const isOutOfStock = product.stock === 0;
const isComingSoon = product.coming_soon === true;
const hasValidPrices = product.prices && product.prices.length > 0;
const imageMedia = product.media?.find((m) => m.type === "image");
const videoMedia = product.media?.find((m) => m.type === "video");
@@ -514,6 +554,13 @@ export default function ProductDetailScreen() {
<Text style={styles.soldOutText}>SOLD OUT</Text>
</View>
)}
{isComingSoon && (
<View style={styles.comingSoonBadge}>
<Text style={styles.comingSoonText}>
COMMING SOON
</Text>
</View>
)}
{videoUri && !isOutOfStock && (
<TouchableOpacity
style={[
@@ -538,7 +585,8 @@ export default function ProductDetailScreen() {
<View style={styles.priceIndicator} />
<Text style={styles.priceText}>
{selectedPrice.toFixed(2)} {" "}
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
</Text>
</View>
)}
@@ -580,25 +628,34 @@ export default function ProductDetailScreen() {
<TouchableOpacity
style={[
styles.addToCartBtn,
(isOutOfStock || selectedGrams === null) &&
(isOutOfStock ||
isComingSoon ||
selectedGrams === null) &&
styles.addToCartBtnDisabled,
]}
onPress={handleAddToCart}
disabled={
isOutOfStock || selectedGrams === null || adding
isOutOfStock ||
isComingSoon ||
selectedGrams === null ||
adding
}
activeOpacity={0.7}
>
<Text
style={[
styles.addToCartText,
(isOutOfStock || selectedGrams === null) &&
(isOutOfStock ||
isComingSoon ||
selectedGrams === null) &&
styles.addToCartTextDisabled,
]}
>
{isOutOfStock
? "Rupture de stock"
: "Ajouter au panier"}
: isComingSoon
? "Bientôt disponible"
: "Ajouter au panier"}
</Text>
</TouchableOpacity>
</View>
@@ -654,7 +711,8 @@ export default function ProductDetailScreen() {
},
]}
>
{p.quantity}{product.unit || "g"}
{p.quantity}
{product.unit || "g"}
</Text>
<Text
style={[
+369 -31
View File
@@ -6,6 +6,7 @@ import {
ScrollView,
StyleSheet,
TouchableOpacity,
Switch,
Alert,
Modal,
KeyboardAvoidingView,
@@ -14,10 +15,10 @@ import {
Linking,
} from "react-native";
import { Feather } from "@expo/vector-icons";
import { useFocusEffect } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Ionicons } from "@expo/vector-icons";
import { getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, changePassword } from "../../api/api";
import { getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, changePassword, get2FAStatus, toggle2FA, getPublicSettings } from "../../api/api";
import TextInput from "../../components/ui/TextInput";
import Button from "../../components/ui/Button";
import { useTheme } from "../../context/ThemeContext";
@@ -29,6 +30,7 @@ const STORAGE_SIGNAL = "profile_signal_pseudo";
export default function ProfileScreen() {
const { colors } = useTheme();
const navigation = useNavigation<any>();
// Données compte (backend)
const [nom, setNom] = useState("");
@@ -49,11 +51,29 @@ export default function ProfileScreen() {
const [telegramEnabled, setTelegramEnabled] = useState(false);
const [telegramLoading, setTelegramLoading] = useState(false);
// Modal confirmation infos par défaut
const [showSaveModal, setShowSaveModal] = useState(false);
// 2FA
const [twoFAEnabled, setTwoFAEnabled] = useState(false);
const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false);
const [twoFALoading, setTwoFALoading] = useState(false);
// Modals confirmation sauvegarde
const [showSaveModal, setShowSaveModal] = useState(false);
const [showConfirmContactModal, setShowConfirmContactModal] = useState(false);
const [showConfirmAddressModal, setShowConfirmAddressModal] = useState(false);
// Modal succès / erreur générique
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successTitle, setSuccessTitle] = useState("");
const [successMsg, setSuccessMsg] = useState("");
const [showErrorModal, setShowErrorModal] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
// Modal changement de mot de passe
const [showPasswordModal, setShowPasswordModal] = useState(false);
// Modals Telegram unlink
const [showTelegramUnlinkModal, setShowTelegramUnlinkModal] = useState(false);
const [showTelegramSuccessModal, setShowTelegramSuccessModal] = useState(false);
const [currentPwd, setCurrentPwd] = useState("");
const [newPwd, setNewPwd] = useState("");
const [confirmPwd, setConfirmPwd] = useState("");
@@ -64,15 +84,19 @@ export default function ProfileScreen() {
const loadData = useCallback(async () => {
setLoadingProfile(true);
const [savedAddress, savedPhone, savedSignal, profileRes, tgStatus] = await Promise.all([
const [savedAddress, savedPhone, savedSignal, profileRes, tgStatus, twoFAStatus, pubSettings] = await Promise.all([
AsyncStorage.getItem(STORAGE_ADDRESS),
AsyncStorage.getItem(STORAGE_PHONE),
AsyncStorage.getItem(STORAGE_SIGNAL),
getMyProfile(),
getTelegramStatus(),
get2FAStatus(),
getPublicSettings(),
]);
setTelegramLinked(tgStatus.linked);
setTelegramEnabled(tgStatus.enabled);
setTwoFAEnabled(twoFAStatus.two_fa_enabled);
setTwoFAAdminEnabled(pubSettings.two_fa_enabled);
if (savedAddress !== null) setDefaultAddress(savedAddress);
if (savedPhone !== null) setDefaultPhone(savedPhone);
@@ -103,7 +127,17 @@ export default function ProfileScreen() {
AsyncStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim()),
]);
setShowSaveModal(false);
Alert.alert("Enregistré", "Informations par défaut sauvegardées");
setSuccessTitle("Informations sauvegardées");
setSuccessMsg("Téléphone et pseudo Signal seront pré-remplis lors de vos prochaines commandes.");
setShowSuccessModal(true);
};
const saveAddress = async () => {
setShowConfirmAddressModal(false);
await AsyncStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
setSuccessTitle("Adresse sauvegardée");
setSuccessMsg("Votre adresse par défaut sera pré-remplie à la prochaine commande.");
setShowSuccessModal(true);
};
const handleLinkTelegram = async () => {
@@ -124,35 +158,32 @@ export default function ProfileScreen() {
);
};
const handleUnlinkTelegram = async () => {
Alert.alert(
"Délier Telegram",
"Vous ne recevrez plus de notifications Telegram. Continuer ?",
[
{ text: "Annuler", style: "cancel" },
{
text: "Délier",
style: "destructive",
onPress: async () => {
await unlinkTelegram();
setTelegramLinked(false);
},
},
],
);
const handleUnlinkTelegram = () => {
setShowTelegramUnlinkModal(true);
};
const confirmUnlinkTelegram = async () => {
setShowTelegramUnlinkModal(false);
await unlinkTelegram();
setTelegramLinked(false);
setTwoFAEnabled(false);
setShowTelegramSuccessModal(true);
};
const handleChangePassword = async () => {
if (!currentPwd || !newPwd || !confirmPwd) {
Alert.alert("Erreur", "Veuillez remplir tous les champs");
setErrorMsg("Veuillez remplir tous les champs.");
setShowErrorModal(true);
return;
}
if (newPwd.length < 8) {
Alert.alert("Erreur", "Le nouveau mot de passe doit contenir au moins 8 caractères");
setErrorMsg("Le nouveau mot de passe doit contenir au moins 8 caractères.");
setShowErrorModal(true);
return;
}
if (newPwd !== confirmPwd) {
Alert.alert("Erreur", "Les nouveaux mots de passe ne correspondent pas");
setErrorMsg("Les nouveaux mots de passe ne correspondent pas.");
setShowErrorModal(true);
return;
}
setSavingPassword(true);
@@ -161,23 +192,41 @@ export default function ProfileScreen() {
if (result.success) {
setShowPasswordModal(false);
setCurrentPwd(""); setNewPwd(""); setConfirmPwd("");
Alert.alert("Succès", "Mot de passe modifié avec succès");
setSuccessTitle("Mot de passe modifié");
setSuccessMsg("Votre mot de passe a été changé avec succès.");
setShowSuccessModal(true);
} else {
Alert.alert("Erreur", result.message || "Erreur inattendue");
setErrorMsg(result.message || "Erreur inattendue lors du changement de mot de passe.");
setShowErrorModal(true);
}
} finally {
setSavingPassword(false);
}
};
const handleToggle2FA = async (value: boolean) => {
setTwoFALoading(true);
const res = await toggle2FA(value);
setTwoFALoading(false);
if (res.success) {
setTwoFAEnabled(value);
} else {
Alert.alert("Erreur", res.error || "Impossible de modifier la 2FA");
}
};
const saveContact = async () => {
setShowConfirmContactModal(false);
setSavingContact(true);
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
setSavingContact(false);
if (res.success) {
Alert.alert("Succès", res.message ?? "Profil mis à jour");
setSuccessTitle("Compte mis à jour");
setSuccessMsg(res.message ?? "Vos informations de compte ont été enregistrées avec succès.");
setShowSuccessModal(true);
} else {
Alert.alert("Erreur", res.message ?? "Erreur lors de la mise à jour");
setErrorMsg(res.message ?? "Erreur lors de la mise à jour du profil.");
setShowErrorModal(true);
}
};
@@ -327,6 +376,17 @@ export default function ProfileScreen() {
},
pwdInputIcon: { marginRight: spacing.s },
pwdInput: { flex: 1, fontSize: fontSize.sm },
twoFARow: {
flexDirection: "row" as const,
alignItems: "center" as const,
justifyContent: "space-between" as const,
paddingTop: spacing.xs,
},
twoFALabel: {
color: colors.textPrimary,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
},
}), [colors]);
if (loadingProfile) {
@@ -338,7 +398,7 @@ export default function ProfileScreen() {
}
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<KeyboardAvoidingView style={styles.container} behavior="padding">
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header */}
@@ -387,7 +447,7 @@ export default function ProfileScreen() {
</View>
<TouchableOpacity
style={styles.saveBtn}
onPress={saveContact}
onPress={() => setShowConfirmContactModal(true)}
disabled={savingContact}
>
<Ionicons name="save-outline" size={16} color="#fff" />
@@ -414,6 +474,15 @@ export default function ProfileScreen() {
multiline
numberOfLines={2}
/>
<TouchableOpacity
style={[styles.saveBtn, styles.saveBtnSecondary, { marginTop: spacing.m }]}
onPress={() => setShowConfirmAddressModal(true)}
>
<Ionicons name="save-outline" size={16} color={colors.accent} />
<Text style={[styles.saveBtnText, styles.saveBtnTextSecondary]}>
Enregistrer l'adresse
</Text>
</TouchableOpacity>
</View>
{/* Carte contact livraison */}
@@ -466,6 +535,21 @@ export default function ProfileScreen() {
</TouchableOpacity>
</View>
{/* Carte Parrainage */}
<View style={styles.card}>
<View style={styles.cardTitle}>
<Ionicons name="gift-outline" size={18} color="#8b5cf6" />
<Text style={styles.cardTitleText}>Parrainage</Text>
</View>
<TouchableOpacity
style={[styles.saveBtn, { backgroundColor: "#8b5cf622", borderWidth: 1, borderColor: "#8b5cf666" }]}
onPress={() => navigation.navigate("Parrainage")}
>
<Ionicons name="gift-outline" size={16} color="#8b5cf6" />
<Text style={[styles.saveBtnText, { color: "#8b5cf6" }]}>Voir mon parrainage</Text>
</TouchableOpacity>
</View>
{/* Carte Telegram */}
{telegramEnabled && (
<View style={styles.card}>
@@ -505,6 +589,42 @@ export default function ProfileScreen() {
</View>
)}
{/* Carte 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
{twoFAAdminEnabled && telegramLinked && (
<View style={styles.card}>
<View style={styles.cardTitle}>
<Ionicons name="shield-checkmark-outline" size={18} color="#6366f1" />
<Text style={styles.cardTitleText}>Double authentification (2FA)</Text>
</View>
<Text style={styles.hint}>
À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte.
</Text>
<View style={styles.twoFARow}>
<View style={{ flex: 1 }}>
<Text style={styles.twoFALabel}>
{twoFAEnabled ? "Activée" : "Désactivée"}
</Text>
{twoFAEnabled && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: 2 }}>
<Ionicons name="checkmark-circle" size={13} color="#10b981" />
<Text style={{ color: "#10b981", fontSize: fontSize.xs }}>Protection activée</Text>
</View>
)}
</View>
{twoFALoading ? (
<ActivityIndicator size="small" color="#6366f1" />
) : (
<Switch
value={twoFAEnabled}
onValueChange={handleToggle2FA}
trackColor={{ false: colors.border, true: "#6366f155" }}
thumbColor={twoFAEnabled ? "#6366f1" : colors.textMuted}
/>
)}
</View>
</View>
)}
</ScrollView>
<Modal
@@ -643,6 +763,224 @@ export default function ProfileScreen() {
</TouchableOpacity>
</Modal>
{/* Modal confirmation déliaison Telegram */}
<Modal
visible={showTelegramUnlinkModal}
transparent
animationType="fade"
onRequestClose={() => setShowTelegramUnlinkModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowTelegramUnlinkModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.modalBox}>
<View style={[styles.modalIconWrap, { borderColor: "#ef444433", backgroundColor: "#ef444411" }]}>
<Ionicons name="unlink-outline" size={24} color="#ef4444" />
</View>
<Text style={styles.modalTitle}>Délier Telegram ?</Text>
<Text style={styles.modalBody}>
Vous ne recevrez plus de notifications Telegram. La double authentification sera également désactivée.
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.modalBtnCancel}
onPress={() => setShowTelegramUnlinkModal(false)}
>
<Text style={styles.modalBtnCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtnConfirm, { backgroundColor: "#ef444422", borderColor: "#ef444466" }]}
onPress={confirmUnlinkTelegram}
>
<Text style={[styles.modalBtnConfirmText, { color: "#ef4444" }]}>Délier</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* Modal succès déliaison Telegram */}
<Modal
visible={showTelegramSuccessModal}
transparent
animationType="fade"
onRequestClose={() => setShowTelegramSuccessModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowTelegramSuccessModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.modalBox}>
<View style={[styles.modalIconWrap, { borderColor: "#10b98133", backgroundColor: "#10b98111" }]}>
<Ionicons name="checkmark-circle-outline" size={24} color="#10b981" />
</View>
<Text style={styles.modalTitle}>Compte délié</Text>
<Text style={styles.modalBody}>
Votre compte Telegram a é délié avec succès. Vous ne recevrez plus de notifications via Telegram.
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtnConfirm, { flex: 1, borderColor: "#10b98166" }]}
onPress={() => setShowTelegramSuccessModal(false)}
>
<Text style={[styles.modalBtnConfirmText, { color: "#10b981" }]}>Fermer</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* Modal confirmation — Enregistrer le compte */}
<Modal
visible={showConfirmContactModal}
transparent
animationType="fade"
onRequestClose={() => setShowConfirmContactModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowConfirmContactModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.modalBox}>
<View style={styles.modalIconWrap}>
<Ionicons name="person-outline" size={24} color={colors.accent} />
</View>
<Text style={styles.modalTitle}>Mettre à jour le compte ?</Text>
<Text style={styles.modalBody}>
Vos informations (prénom, nom, téléphone) seront enregistrées sur votre compte.
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.modalBtnCancel}
onPress={() => setShowConfirmContactModal(false)}
>
<Text style={styles.modalBtnCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.modalBtnConfirm}
onPress={saveContact}
>
<Text style={styles.modalBtnConfirmText}>Confirmer</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* Modal confirmation — Enregistrer l'adresse */}
<Modal
visible={showConfirmAddressModal}
transparent
animationType="fade"
onRequestClose={() => setShowConfirmAddressModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowConfirmAddressModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.modalBox}>
<View style={[styles.modalIconWrap, { borderColor: "#10b98133", backgroundColor: "#10b98111" }]}>
<Ionicons name="location-outline" size={24} color="#10b981" />
</View>
<Text style={styles.modalTitle}>Enregistrer l'adresse ?</Text>
<Text style={styles.modalBody}>
Cette adresse sera pré-remplie automatiquement lors de vos prochaines commandes.
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.modalBtnCancel}
onPress={() => setShowConfirmAddressModal(false)}
>
<Text style={styles.modalBtnCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtnConfirm, { borderColor: "#10b98166" }]}
onPress={saveAddress}
>
<Text style={[styles.modalBtnConfirmText, { color: "#10b981" }]}>Confirmer</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* Modal succès générique */}
<Modal
visible={showSuccessModal}
transparent
animationType="fade"
onRequestClose={() => setShowSuccessModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowSuccessModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.modalBox}>
<View style={[styles.modalIconWrap, { borderColor: "#10b98133", backgroundColor: "#10b98111" }]}>
<Ionicons name="checkmark-circle-outline" size={24} color="#10b981" />
</View>
<Text style={styles.modalTitle}>{successTitle}</Text>
<Text style={styles.modalBody}>{successMsg}</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtnConfirm, { flex: 1, borderColor: "#10b98166" }]}
onPress={() => setShowSuccessModal(false)}
>
<Text style={[styles.modalBtnConfirmText, { color: "#10b981" }]}>Fermer</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* Modal erreur générique */}
<Modal
visible={showErrorModal}
transparent
animationType="fade"
onRequestClose={() => setShowErrorModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowErrorModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.modalBox}>
<View style={[styles.modalIconWrap, { borderColor: "#ef444433", backgroundColor: "#ef444411" }]}>
<Ionicons name="alert-circle-outline" size={24} color="#ef4444" />
</View>
<Text style={styles.modalTitle}>Une erreur est survenue</Text>
<Text style={styles.modalBody}>{errorMsg}</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtnConfirm, { flex: 1, borderColor: "#ef444466", backgroundColor: "#ef444411" }]}
onPress={() => setShowErrorModal(false)}
>
<Text style={[styles.modalBtnConfirmText, { color: "#ef4444" }]}>Fermer</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</KeyboardAvoidingView>
);
}