chore: build
This commit is contained in:
@@ -70,10 +70,9 @@ function UserAccueil() {
|
||||
};
|
||||
|
||||
const getProductPrice = (product: Product): number => {
|
||||
if (!product.prices || product.prices.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return product.prices[0]?.price || 0;
|
||||
const activePrices = product.prices?.filter(p => p.active_price !== false);
|
||||
if (!activePrices || activePrices.length === 0) return 0;
|
||||
return activePrices[0].price;
|
||||
};
|
||||
const hasProductVideo = (product: Product): boolean => {
|
||||
if (!product.media || product.media.length === 0) {
|
||||
@@ -210,7 +209,7 @@ function UserAccueil() {
|
||||
image={getProductImage(product)}
|
||||
stock={product.stock}
|
||||
category={product.category}
|
||||
prices={product.prices}
|
||||
prices={product.prices?.filter(p => p.active_price !== false)}
|
||||
hasVideo={hasProductVideo(product)}
|
||||
videoUrl={getProductVideoUrl(product)}
|
||||
categoryColor={
|
||||
@@ -220,6 +219,7 @@ function UserAccueil() {
|
||||
product.category?.toLowerCase(),
|
||||
)?.color
|
||||
}
|
||||
coming_soon={product.coming_soon}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CartItemWithMedia {
|
||||
image: string;
|
||||
hasVideo: boolean;
|
||||
videoUrl?: string;
|
||||
is_reward?: boolean;
|
||||
}
|
||||
|
||||
function Cart() {
|
||||
@@ -176,9 +177,22 @@ function Cart() {
|
||||
|
||||
{/* Infos */}
|
||||
<div className="cart-row-info">
|
||||
<p className="cart-row-name">{item.name_product}</p>
|
||||
<p className="cart-row-name">
|
||||
{item.name_product}
|
||||
{item.is_reward && (
|
||||
<span style={{ marginLeft: "6px", fontSize: "0.7rem", fontWeight: 700, color: "#f59e0b", background: "rgba(245,158,11,0.12)", borderRadius: "4px", padding: "1px 6px" }}>
|
||||
🎁 Récompense
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="cart-row-qty">{item.quantity}g</p>
|
||||
<p className="cart-row-price">{item.price.toFixed(2)} €</p>
|
||||
<p className="cart-row-price">
|
||||
{item.is_reward ? (
|
||||
<span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span>
|
||||
) : (
|
||||
`${item.price.toFixed(2)} €`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton supprimer */}
|
||||
|
||||
@@ -181,6 +181,15 @@
|
||||
margin-top: clamp(1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.summary-referral-deduction {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
|
||||
color: #16a34a;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.total-price {
|
||||
color: #6d28d9;
|
||||
font-size: clamp(1.2rem, 5vw, 1.5rem);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCart } from '../../context/useCart';
|
||||
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings, getMyProfile, getCryptoPaymentStatus, getProductById, getMediaUrl } from '../../api/api';
|
||||
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings, getMyProfile, getCryptoPaymentStatus, getProductById, getMediaUrl, getTelegramStatus } from '../../api/api';
|
||||
import type { CheckoutData, CryptoPaymentStatus } from '../../api/api';
|
||||
import type { Product } from '../../api/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
@@ -80,6 +80,9 @@ function Checkout() {
|
||||
const [referralEnabled, setReferralEnabled] = useState(false);
|
||||
const [useReferral, setUseReferral] = useState(false);
|
||||
|
||||
// Telegram
|
||||
const [telegramLinked, setTelegramLinked] = useState(false);
|
||||
|
||||
// Crypto
|
||||
const [cryptoEnabled, setCryptoEnabled] = useState(false);
|
||||
const [cryptoOnly, setCryptoOnly] = useState(false);
|
||||
@@ -129,6 +132,10 @@ function Checkout() {
|
||||
if (res.client.prenom) setFirstName(res.client.prenom);
|
||||
}
|
||||
});
|
||||
|
||||
getTelegramStatus().then((res) => {
|
||||
if (res.linked) setTelegramLinked(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Charger settings publics (parrainage + crypto)
|
||||
@@ -348,13 +355,13 @@ function Checkout() {
|
||||
// ✅ Préparer les données pour le modal
|
||||
setConfirmationData({
|
||||
command_id,
|
||||
client_order_number: (response as Record<string, unknown>).client_order_number as number | undefined,
|
||||
client_order_number: response.client_order_number,
|
||||
assigned_to,
|
||||
queue_info,
|
||||
delivery_address: delivery_address || address,
|
||||
arrivalTime,
|
||||
total: frontendTotal,
|
||||
referral_used: (response as Record<string, unknown>).referral_used as number | undefined,
|
||||
referral_used: response.referral_used,
|
||||
clientInfo: {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
@@ -417,8 +424,19 @@ function Checkout() {
|
||||
</div>
|
||||
<div className="summary-total">
|
||||
<span>Total:</span>
|
||||
<span className="total-price">{total.toFixed(2)} €</span>
|
||||
<span className="total-price">
|
||||
{(useReferral && referralBalance > 0
|
||||
? Math.max(0, total - referralBalance)
|
||||
: total
|
||||
).toFixed(2)} €
|
||||
</span>
|
||||
</div>
|
||||
{useReferral && referralBalance > 0 && (
|
||||
<div className="summary-referral-deduction">
|
||||
<span>Dont crédit parrainage :</span>
|
||||
<span>-{Math.min(referralBalance, total).toFixed(2)} €</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulaire */}
|
||||
@@ -567,16 +585,18 @@ function Checkout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="checkout-telegram-note">
|
||||
<i className="fab fa-telegram" />
|
||||
<div>
|
||||
<strong>Compte Telegram requis</strong>
|
||||
<p>
|
||||
Votre commande ne pourra être validée que si votre compte Telegram est lié.
|
||||
Rendez-vous dans votre <a href="/user/profil">profil</a> pour le lier avant de confirmer.
|
||||
</p>
|
||||
{!telegramLinked && (
|
||||
<div className="checkout-telegram-note">
|
||||
<i className="fab fa-telegram" />
|
||||
<div>
|
||||
<strong>Compte Telegram requis</strong>
|
||||
<p>
|
||||
Votre commande ne pourra être validée que si votre compte Telegram est lié.
|
||||
Rendez-vous dans votre <a href="/user/profil">profil</a> pour le lier avant de confirmer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
@@ -787,7 +807,9 @@ function Checkout() {
|
||||
<div className="confirmation-total-label">
|
||||
<i className="fas fa-euro-sign"></i> TOTAL
|
||||
</div>
|
||||
<div className="confirmation-total-amount">{confirmationData.total.toFixed(2)} €</div>
|
||||
<div className="confirmation-total-amount">
|
||||
{(confirmationData.total - (confirmationData.referral_used ?? 0)).toFixed(2)} €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -151,6 +151,154 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RÉCOMPENSES PAR PALIER
|
||||
============================================ */
|
||||
|
||||
.rewards-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.rewards-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #f59e0b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
|
||||
.rewards-section-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rewards-section-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
margin: 0 0 0.875rem 0;
|
||||
}
|
||||
|
||||
.rewards-pools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.reward-pool-card {
|
||||
background: rgba(245, 158, 11, 0.07);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.reward-pool-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-pool-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reward-pool-pts {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.reward-eligible-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-eligible-cat {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.reward-progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.reward-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.reward-pool-info {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-available {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reward-remaining {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reward-feedback {
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
margin: 0.25rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.reward-feedback-success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.reward-feedback-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.reward-claim-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.reward-claim-btn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.reward-claim-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SECTION TITLE
|
||||
============================================ */
|
||||
@@ -261,6 +409,13 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-card-referral {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: #10b981;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.order-card-chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -1,278 +1,519 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import './ConsultationHistorique.css';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navbar from "../../components/Navbar";
|
||||
import "./ConsultationHistorique.css";
|
||||
import {
|
||||
getMyCompletedOrders,
|
||||
formatPrice,
|
||||
getOrderAge,
|
||||
getMyPenalties,
|
||||
isUserAuthenticated,
|
||||
getPublicSettings,
|
||||
getReferralBalance,
|
||||
} from '../../api/api';
|
||||
import type { PublicSettings } from '../../api/api';
|
||||
import type { CompletedOrder, ClientStats, PenaltyInfo } from "../../api/api_types";
|
||||
getMyCompletedOrders,
|
||||
formatPrice,
|
||||
getOrderAge,
|
||||
getMyPenalties,
|
||||
isUserAuthenticated,
|
||||
getPublicSettings,
|
||||
getReferralBalance,
|
||||
getMyPointsRewards,
|
||||
claimMyReward,
|
||||
} from "../../api/api";
|
||||
import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api";
|
||||
import type {
|
||||
CompletedOrder,
|
||||
ClientStats,
|
||||
PenaltyInfo,
|
||||
} from "../../api/api_types";
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCannabis,
|
||||
faPills,
|
||||
faFlask,
|
||||
faMortarPestle,
|
||||
faStar,
|
||||
faTrophy,
|
||||
faExclamationTriangle,
|
||||
faShieldAlt,
|
||||
faGift,
|
||||
faReceipt,
|
||||
faMapMarkerAlt,
|
||||
faClock,
|
||||
faBicycle,
|
||||
faChevronRight,
|
||||
faCheckCircle,
|
||||
faHistory,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
faCannabis,
|
||||
faPills,
|
||||
faFlask,
|
||||
faMortarPestle,
|
||||
faStar,
|
||||
faTrophy,
|
||||
faExclamationTriangle,
|
||||
faShieldAlt,
|
||||
faGift,
|
||||
faReceipt,
|
||||
faMapMarkerAlt,
|
||||
faClock,
|
||||
faBicycle,
|
||||
faChevronRight,
|
||||
faCheckCircle,
|
||||
faHistory,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
function ConsultationHistorique() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||
const [clientStats, setClientStats] = 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: true, referral_amount: 0, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, crypto_only: false, nowpayments_currencies: [] });
|
||||
const [referralBalance, setReferralBalance] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated()) navigate('/login/client', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isUserAuthenticated()) navigate('/login/client', { replace: true });
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
fetchPenalties();
|
||||
getPublicSettings().then((s) => {
|
||||
setAppSettings(s);
|
||||
if (s.referral_enabled) {
|
||||
getReferralBalance().then((r) => { if (r.success) setReferralBalance(r.balance); });
|
||||
}
|
||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||
const [clientStats, setClientStats] = 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: true,
|
||||
referral_amount: 0,
|
||||
pool_names: ["Pool 1", "Pool 2"],
|
||||
crypto_payment_enabled: false,
|
||||
crypto_only: false,
|
||||
nowpayments_currencies: [],
|
||||
shop_name: "Milieu-Nantais",
|
||||
two_fa_enabled: false,
|
||||
contact_telegram: "",
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const [referralBalance, setReferralBalance] = useState<number>(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 [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!isUserAuthenticated()) { navigate('/login/client', { replace: true }); return; }
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getMyCompletedOrders();
|
||||
if (result.success) {
|
||||
setOrders(result.commands);
|
||||
setClientStats(result.client_stats || null);
|
||||
} else {
|
||||
setError(result.message || 'Erreur lors du chargement');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated())
|
||||
navigate("/login/client", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isUserAuthenticated())
|
||||
navigate("/login/client", { replace: true });
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
fetchPenalties();
|
||||
getPublicSettings().then((s) => {
|
||||
setAppSettings(s);
|
||||
if (s.referral_enabled) {
|
||||
getReferralBalance().then((r) => {
|
||||
if (r.success) setReferralBalance(r.balance);
|
||||
});
|
||||
}
|
||||
});
|
||||
getMyPointsRewards().then((r) => {
|
||||
if (r.success && r.enabled) setPointsRewards(r);
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getMyCompletedOrders();
|
||||
if (result.success) {
|
||||
setOrders(result.commands);
|
||||
setClientStats(result.client_stats || null);
|
||||
} else {
|
||||
setError(result.message || "Erreur lors du chargement");
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur de connexion");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPenalties = async () => {
|
||||
if (!isUserAuthenticated()) return;
|
||||
try {
|
||||
const result = await getMyPenalties();
|
||||
if (result.success && result.data) setPenalties(result.data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const viewOrderDetails = (order: CompletedOrder) => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/user/commande/${order.client_order_number ?? order.id}`, {
|
||||
state: { commandId: order.id },
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
<p className="loading-text">
|
||||
Chargement de l'historique...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPenalties = async () => {
|
||||
if (!isUserAuthenticated()) return;
|
||||
try {
|
||||
const result = await getMyPenalties();
|
||||
if (result.success && result.data) setPenalties(result.data);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
const viewOrderDetails = (order: CompletedOrder) => {
|
||||
if (!isUserAuthenticated()) { navigate('/login/client', { replace: true }); return; }
|
||||
navigate(`/user/commande/${order.client_order_number ?? order.id}`, {
|
||||
state: { commandId: order.id },
|
||||
});
|
||||
};
|
||||
const poolNames = clientStats?.pool_names?.length
|
||||
? clientStats.pool_names
|
||||
: appSettings.pool_names;
|
||||
const poolPoints = clientStats?.pool_points ?? [clientStats?.points ?? 0];
|
||||
const totalPoints = poolPoints.reduce((s, v) => s + (v || 0), 0);
|
||||
const penaltyCount =
|
||||
penalties?.total_penalty || clientStats?.penalties || 0;
|
||||
const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar];
|
||||
const poolIconColors = [
|
||||
"#10b981",
|
||||
"#e879f9",
|
||||
"#fb923c",
|
||||
"#38bdf8",
|
||||
"#7c3aed",
|
||||
];
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"><div className="spinner"></div></div>
|
||||
<p className="loading-text">Chargement de l'historique...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
<h1 className="history-title">Historique des commandes</h1>
|
||||
|
||||
const poolNames = clientStats?.pool_names?.length ? clientStats.pool_names : appSettings.pool_names;
|
||||
const poolPoints = clientStats?.pool_points ?? [clientStats?.points ?? 0];
|
||||
const totalPoints = poolPoints.reduce((s, v) => s + (v || 0), 0);
|
||||
const penaltyCount = penalties?.total_penalty || clientStats?.penalties || 0;
|
||||
const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar];
|
||||
const poolIconColors = ['#10b981', '#e879f9', '#fb923c', '#38bdf8', '#7c3aed'];
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
{/* Total commandes */}
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total-orders">
|
||||
<FontAwesomeIcon icon={faReceipt} />
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{clientStats?.total_commands ?? orders.length}
|
||||
</p>
|
||||
<p className="stat-label">Commandes</p>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
{/* Points */}
|
||||
{appSettings.points_enabled &&
|
||||
(poolNames.length <= 1 ? (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{poolPoints[0] || 0}
|
||||
</p>
|
||||
<p className="stat-label">
|
||||
Pts {poolNames[0] ?? "Points"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{poolNames.map((name, i) => (
|
||||
<div key={i} className="stat-card2">
|
||||
<div
|
||||
className="stat-icon"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${poolIconColors[i] ?? "#7c3aed"}cc, ${poolIconColors[i] ?? "#7c3aed"})`,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={poolIcons[i] ?? faStar}
|
||||
/>
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{poolPoints[i] || 0}
|
||||
</p>
|
||||
<p className="stat-label">Pts {name}</p>
|
||||
</div>
|
||||
))}
|
||||
{totalPoints > 0 && (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{totalPoints}
|
||||
</p>
|
||||
<p className="stat-label">
|
||||
Total Points
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
<h1 className="history-title">Historique des commandes</h1>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
|
||||
{/* Total commandes */}
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total-orders">
|
||||
<FontAwesomeIcon icon={faReceipt} />
|
||||
</div>
|
||||
<p className="stat-value">{clientStats?.total_commands ?? orders.length}</p>
|
||||
<p className="stat-label">Commandes</p>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
{appSettings.points_enabled && (
|
||||
poolNames.length <= 1 ? (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
{/* Score amendes */}
|
||||
{appSettings.show_amende_score && (
|
||||
<div
|
||||
className={`stat-card2${penaltyCount >= 3 ? " stat-card-danger" : penaltyCount > 0 ? " stat-card-warning" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`stat-icon ${penaltyCount >= 3 ? "icon-penalty-critical" : penaltyCount > 0 ? "icon-penalty-warning" : "icon-penalty-ok"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
penaltyCount > 0
|
||||
? faExclamationTriangle
|
||||
: faShieldAlt
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`stat-value ${penaltyCount >= 3 ? "value-danger" : penaltyCount > 0 ? "value-warning" : ""}`}
|
||||
>
|
||||
{penaltyCount}
|
||||
</p>
|
||||
<p className="stat-label">Score amendes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="stat-value">{poolPoints[0] || 0}</p>
|
||||
<p className="stat-label">Pts {poolNames[0] ?? 'Points'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{poolNames.map((name, i) => (
|
||||
<div key={i} className="stat-card2">
|
||||
<div className="stat-icon" style={{ background: `linear-gradient(135deg, ${poolIconColors[i] ?? '#7c3aed'}cc, ${poolIconColors[i] ?? '#7c3aed'})` }}>
|
||||
<FontAwesomeIcon icon={poolIcons[i] ?? faStar} />
|
||||
|
||||
{/* Section récompenses par palier */}
|
||||
{pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && (
|
||||
<div className="rewards-section">
|
||||
<p className="rewards-section-title">
|
||||
<FontAwesomeIcon icon={faTrophy} className="rewards-section-icon" />
|
||||
Récompenses
|
||||
</p>
|
||||
{pointsRewards.reward.description && (
|
||||
<p className="rewards-section-desc">{pointsRewards.reward.description}</p>
|
||||
)}
|
||||
{(pointsRewards.reward.reward_items ?? []).filter((it: RewardItemConfig) => it.product_name).length > 0 && (
|
||||
<div className="reward-eligible-cats">
|
||||
{(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => (
|
||||
<span key={idx} className="reward-eligible-cat">
|
||||
<FontAwesomeIcon icon={faGift} style={{ marginRight: 4 }} />
|
||||
{it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? ` — ${it.price}€` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rewards-pools">
|
||||
{pointsRewards.pools.map((pool) => {
|
||||
const threshold = pointsRewards.reward!.threshold;
|
||||
const progress = Math.min(100, Math.round((pool.points % threshold) / threshold * 100));
|
||||
const remaining = threshold - (pool.points % threshold);
|
||||
const isClaiming = claimingPool === pool.key;
|
||||
const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null;
|
||||
return (
|
||||
<div key={pool.key} className="reward-pool-card">
|
||||
<div className="reward-pool-header">
|
||||
<span className="reward-pool-name">{pool.name}</span>
|
||||
<span className="reward-pool-pts">{pool.points} pts</span>
|
||||
</div>
|
||||
{pool.eligible_configs.length > 0 && (
|
||||
<div className="reward-eligible-cats">
|
||||
{pool.eligible_configs.flatMap((cfg) =>
|
||||
cfg.all_products
|
||||
? [<span key={cfg.category} className="reward-eligible-cat">
|
||||
{cfg.category}
|
||||
</span>]
|
||||
: (cfg.product_names ?? []).map((name) => (
|
||||
<span key={`${cfg.category}-${name}`} className="reward-eligible-cat">
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="reward-progress-bar">
|
||||
<div className="reward-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="reward-pool-info">
|
||||
{pool.rewards_available > 0 ? (
|
||||
<span className="reward-available">{pool.rewards_available} récompense{pool.rewards_available > 1 ? "s" : ""} disponible{pool.rewards_available > 1 ? "s" : ""}</span>
|
||||
) : (
|
||||
<span className="reward-remaining">Encore {remaining} pts pour une récompense</span>
|
||||
)}
|
||||
</div>
|
||||
{feedback && (
|
||||
<p className={`reward-feedback reward-feedback-${feedback.type}`}>{feedback.text}</p>
|
||||
)}
|
||||
{pool.rewards_available > 0 && (
|
||||
<button
|
||||
className="reward-claim-btn"
|
||||
onClick={() => handleClaim(pool.key)}
|
||||
disabled={isClaiming}
|
||||
>
|
||||
{isClaiming ? "..." : "Réclamer ma récompense"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="stat-value">{poolPoints[i] || 0}</p>
|
||||
<p className="stat-label">Pts {name}</p>
|
||||
</div>
|
||||
))}
|
||||
{totalPoints > 0 && (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
</div>
|
||||
<p className="stat-value">{totalPoints}</p>
|
||||
<p className="stat-label">Total Points</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Score amendes */}
|
||||
{appSettings.show_amende_score && (
|
||||
<div className={`stat-card2${penaltyCount >= 3 ? ' stat-card-danger' : penaltyCount > 0 ? ' stat-card-warning' : ''}`}>
|
||||
<div className={`stat-icon ${penaltyCount >= 3 ? 'icon-penalty-critical' : penaltyCount > 0 ? 'icon-penalty-warning' : 'icon-penalty-ok'}`}>
|
||||
<FontAwesomeIcon icon={penaltyCount > 0 ? faExclamationTriangle : faShieldAlt} />
|
||||
</div>
|
||||
<p className={`stat-value ${penaltyCount >= 3 ? 'value-danger' : penaltyCount > 0 ? 'value-warning' : ''}`}>{penaltyCount}</p>
|
||||
<p className="stat-label">Score amendes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton parrainage */}
|
||||
{appSettings.referral_enabled && (
|
||||
<div className="referral-btn-row" onClick={() => navigate('/user/parrainage')}>
|
||||
<FontAwesomeIcon icon={faGift} className="referral-btn-icon" />
|
||||
<span className="referral-btn-text">
|
||||
Parrainage{referralBalance > 0 ? ` — ${referralBalance.toFixed(2)} €` : ''}
|
||||
</span>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="referral-btn-chevron" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="empty-history">
|
||||
<FontAwesomeIcon icon={faHistory} className="empty-icon" />
|
||||
<h2>Aucun historique</h2>
|
||||
<p>Vos commandes terminées apparaîtront ici</p>
|
||||
<button className="browse-button" onClick={() => navigate('/user/accueil')}>
|
||||
Découvrir nos produits
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="section-title">Historique des commandes</p>
|
||||
<div className="orders-list">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="order-card"
|
||||
onClick={() => viewOrderDetails(order)}
|
||||
>
|
||||
<div className="order-card-header">
|
||||
<span className="order-card-number">
|
||||
Commande #{(order.client_order_number ?? 0).toString().padStart(4, '0')}
|
||||
</span>
|
||||
<span className="order-card-badge">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ marginRight: '0.35rem' }} />
|
||||
Livrée
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="order-card-row-icon" />
|
||||
<span className="order-card-row-text">
|
||||
{order.adresse ? (order.adresse.length > 50 ? order.adresse.substring(0, 50) + '…' : order.adresse) : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon icon={faClock} className="order-card-row-icon" />
|
||||
<span className="order-card-row-text">
|
||||
{formatDate(order.created_at)} · {getOrderAge(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.livreur_assign && (
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon icon={faBicycle} className="order-card-row-icon" />
|
||||
<span className="order-card-row-text">{order.livreur_assign}</span>
|
||||
{/* Bouton parrainage */}
|
||||
{appSettings.referral_enabled && (
|
||||
<div
|
||||
className="referral-btn-row"
|
||||
onClick={() => navigate("/user/parrainage")}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faGift}
|
||||
className="referral-btn-icon"
|
||||
/>
|
||||
<span className="referral-btn-text">
|
||||
Parrainage
|
||||
{referralBalance > 0
|
||||
? ` — ${referralBalance.toFixed(2)} €`
|
||||
: ""}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="referral-btn-chevron"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="order-card-footer">
|
||||
<span className="order-card-total">{formatPrice(order.total_prix || 0)}</span>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="order-card-chevron" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationTriangle}
|
||||
size="2x"
|
||||
/>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="empty-history">
|
||||
<FontAwesomeIcon
|
||||
icon={faHistory}
|
||||
className="empty-icon"
|
||||
/>
|
||||
<h2>Aucun historique</h2>
|
||||
<p>Vos commandes terminées apparaîtront ici</p>
|
||||
<button
|
||||
className="browse-button"
|
||||
onClick={() => navigate("/user/accueil")}
|
||||
>
|
||||
Découvrir nos produits
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="section-title">
|
||||
Historique des commandes
|
||||
</p>
|
||||
<div className="orders-list">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="order-card"
|
||||
onClick={() => viewOrderDetails(order)}
|
||||
>
|
||||
<div className="order-card-header">
|
||||
<span className="order-card-number">
|
||||
Commande #
|
||||
{(order.client_order_number ?? 0)
|
||||
.toString()
|
||||
.padStart(4, "0")}
|
||||
</span>
|
||||
<span className="order-card-badge">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
style={{
|
||||
marginRight: "0.35rem",
|
||||
}}
|
||||
/>
|
||||
Livrée
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon
|
||||
icon={faMapMarkerAlt}
|
||||
className="order-card-row-icon"
|
||||
/>
|
||||
<span className="order-card-row-text">
|
||||
{order.adresse
|
||||
? order.adresse.length > 50
|
||||
? order.adresse.substring(
|
||||
0,
|
||||
50,
|
||||
) + "…"
|
||||
: order.adresse
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className="order-card-row-icon"
|
||||
/>
|
||||
<span className="order-card-row-text">
|
||||
{formatDate(order.created_at)} ·{" "}
|
||||
{getOrderAge(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.livreur_assign && (
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon
|
||||
icon={faBicycle}
|
||||
className="order-card-row-icon"
|
||||
/>
|
||||
<span className="order-card-row-text">
|
||||
{order.livreur_assign}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="order-card-footer">
|
||||
<span className="order-card-total">
|
||||
{formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
|
||||
{(order.referral_used || 0) > 0 && (
|
||||
<span className="order-card-referral">
|
||||
{" "}— dont {formatPrice(order.referral_used ?? 0)} parrainage
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="order-card-chevron"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsultationHistorique;
|
||||
|
||||
@@ -60,7 +60,9 @@ function OrderDetails() {
|
||||
const location = useLocation();
|
||||
// commandId = ID global pour l'API (passé en state depuis l'historique)
|
||||
// fallback sur orderId si navigation directe via URL
|
||||
const commandId = (location.state as { commandId?: number } | null)?.commandId ?? parseInt(orderId ?? "0");
|
||||
const commandId =
|
||||
(location.state as { commandId?: number } | null)?.commandId ??
|
||||
parseInt(orderId ?? "0");
|
||||
|
||||
const [order, setOrder] = useState<OrderDetailsData | null>(null);
|
||||
const [enrichedProducts, setEnrichedProducts] = useState<
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getReferralBalance, isUserAuthenticated, getPublicSettings } from '../.
|
||||
import type { PublicSettings } from '../../api/api';
|
||||
import './Parrainage.css';
|
||||
|
||||
const TELEGRAM_URL = 'https://t.me/';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
@@ -140,21 +139,23 @@ export default function Parrainage() {
|
||||
</div>
|
||||
|
||||
{/* Bouton Telegram */}
|
||||
<div className="parrainage-cta">
|
||||
<p className="cta-text">
|
||||
Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre
|
||||
filleul.
|
||||
</p>
|
||||
<a
|
||||
href={TELEGRAM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="telegram-btn"
|
||||
>
|
||||
<i className="fab fa-telegram telegram-icon"></i>
|
||||
Contacter sur Telegram
|
||||
</a>
|
||||
</div>
|
||||
{settings?.contact_telegram && (
|
||||
<div className="parrainage-cta">
|
||||
<p className="cta-text">
|
||||
Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre
|
||||
filleul.
|
||||
</p>
|
||||
<a
|
||||
href={`https://t.me/${settings.contact_telegram}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="telegram-btn"
|
||||
>
|
||||
<i className="fab fa-telegram telegram-icon"></i>
|
||||
Contacter @{settings.contact_telegram}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -133,6 +133,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Coming Soon Badge */
|
||||
.coming-soon-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-15deg);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: rgba(34, 197, 94, 0.95);
|
||||
border: 4px solid rgba(34, 197, 94, 0.95);
|
||||
padding: clamp(1rem, 4vw, 1.5rem) clamp(2.5rem, 8vw, 4rem);
|
||||
font-size: clamp(2rem, 8vw, 3.5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
box-shadow:
|
||||
0 0 40px rgba(34, 197, 94, 0.6),
|
||||
0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.9),
|
||||
0 0 20px rgba(34, 197, 94, 0.3);
|
||||
z-index: 10;
|
||||
animation: pulseGreen 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) rotate(-15deg) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) rotate(-15deg) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.product-info-section {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getProductById, getCategories, isUserAuthenticated } from "../../api/api";
|
||||
import {
|
||||
getProductById,
|
||||
getCategories,
|
||||
isUserAuthenticated,
|
||||
} from "../../api/api";
|
||||
import type { Product } from "../../api/api";
|
||||
import { useCart } from "../../context/useCart";
|
||||
import Navbar from "../../components/Navbar";
|
||||
@@ -86,12 +90,25 @@ function ProductDetail() {
|
||||
const fixedProduct = {
|
||||
...response.data,
|
||||
prices:
|
||||
response.data.prices?.map(
|
||||
(p: { quantity: number; price: number }) => ({
|
||||
quantity: parseFloat(String(p.quantity)),
|
||||
price: parseFloat(String(p.price)),
|
||||
}),
|
||||
) || [],
|
||||
response.data.prices
|
||||
?.filter(
|
||||
(p: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}) => p.active_price !== false,
|
||||
)
|
||||
.map(
|
||||
(p: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}) => ({
|
||||
quantity: parseFloat(String(p.quantity)),
|
||||
price: parseFloat(String(p.price)),
|
||||
active_price: p.active_price,
|
||||
}),
|
||||
) || [],
|
||||
};
|
||||
|
||||
setProduct(fixedProduct);
|
||||
@@ -104,14 +121,20 @@ function ProductDetail() {
|
||||
|
||||
// Couleur de la catégorie depuis la DB
|
||||
const matched = categories.find(
|
||||
(c) => c.name.toLowerCase() === (response.data.category || "").toLowerCase(),
|
||||
(c) =>
|
||||
c.name.toLowerCase() ===
|
||||
(response.data.category || "").toLowerCase(),
|
||||
);
|
||||
if (matched?.color) setCatColor(matched.color);
|
||||
} else {
|
||||
setError(response.message || "Produit non trouvé");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Erreur lors du chargement du produit");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Erreur lors du chargement du produit",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -197,6 +220,7 @@ function ProductDetail() {
|
||||
}
|
||||
|
||||
const isOutOfStock = product.stock === 0;
|
||||
const isComingSoon = product.coming_soon === true;
|
||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||
|
||||
// Convertir la couleur hex en valeurs RGB pour les CSS rgba()
|
||||
@@ -213,7 +237,8 @@ function ProductDetail() {
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const catTextColor = (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||
const catTextColor =
|
||||
(r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -231,20 +256,38 @@ function ProductDetail() {
|
||||
|
||||
<div
|
||||
className="product-detail-container"
|
||||
style={{ "--cat-color": catColor, "--cat-color-rgb": catColorRgb, "--cat-text-color": catTextColor } as React.CSSProperties}
|
||||
style={
|
||||
{
|
||||
"--cat-color": catColor,
|
||||
"--cat-color-rgb": catColorRgb,
|
||||
"--cat-text-color": catTextColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<button onClick={() => navigate(-1)} className="back-button">
|
||||
← Retour
|
||||
</button>
|
||||
|
||||
<div className="product-detail-content">
|
||||
<div className={`product-image-section ${isOutOfStock ? "out-of-stock" : ""}`}>
|
||||
<div
|
||||
className={`product-image-section ${isOutOfStock ? "out-of-stock" : ""}`}
|
||||
>
|
||||
<img
|
||||
src={product.media?.find(m => m.type === "image")?.url || ""}
|
||||
src={
|
||||
product.media?.find((m) => m.type === "image")
|
||||
?.url || ""
|
||||
}
|
||||
alt={product.name}
|
||||
className="product-detail-image"
|
||||
/>
|
||||
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>}
|
||||
{isOutOfStock && (
|
||||
<div className="sold-out-badge">SOLD OUT</div>
|
||||
)}
|
||||
{isComingSoon && (
|
||||
<div className="coming-soon-badge">
|
||||
COMMING SOON
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="product-info-section">
|
||||
@@ -253,7 +296,8 @@ function ProductDetail() {
|
||||
{selectedPrice > 0 && (
|
||||
<p className="product-detail-price">
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -291,7 +335,8 @@ function ProductDetail() {
|
||||
key={p.quantity}
|
||||
value={p.quantity}
|
||||
>
|
||||
{p.quantity}{product.unit || "g"} -{" "}
|
||||
{p.quantity}
|
||||
{product.unit || "g"} -{" "}
|
||||
{p.price.toFixed(2)} €
|
||||
</option>
|
||||
))}
|
||||
@@ -301,13 +346,19 @@ function ProductDetail() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`add-to-cart-button ${isOutOfStock || selectedGrams === null ? "disabled" : ""}`}
|
||||
className={`add-to-cart-button ${isOutOfStock || isComingSoon || selectedGrams === null ? "disabled" : ""}`}
|
||||
onClick={handleAddToCart}
|
||||
disabled={isOutOfStock || selectedGrams === null}
|
||||
disabled={
|
||||
isOutOfStock ||
|
||||
isComingSoon ||
|
||||
selectedGrams === null
|
||||
}
|
||||
>
|
||||
{isOutOfStock
|
||||
? "Rupture de stock"
|
||||
: "Ajouter au panier"}
|
||||
: isComingSoon
|
||||
? "Bientôt disponible"
|
||||
: "Ajouter au panier"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +179,79 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Toggle 2FA */
|
||||
.profile-2fa-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.4rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-2fa-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.profile-2fa-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.profile-toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.25s;
|
||||
}
|
||||
|
||||
.profile-toggle--on {
|
||||
background: #6366f155;
|
||||
border: 1px solid #6366f1;
|
||||
}
|
||||
|
||||
.profile-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
transition: transform 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.profile-toggle--on .profile-toggle-thumb {
|
||||
transform: translateX(22px);
|
||||
background: #6366f1;
|
||||
}
|
||||
|
||||
.profile-2fa-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #6366f133;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.profile-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -284,3 +357,27 @@
|
||||
border: 1px solid rgba(37, 99, 235, 0.27);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.profile-modal-btn--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.profile-modal-icon--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.profile-modal-icon--success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.profile-modal-btn--success {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
@@ -1,433 +1,514 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navbar from "../../components/Navbar";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import { isUserAuthenticated, extractUsernameFromToken, getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, get2FAStatus, toggle2FA, getPublicSettings } from '../../api/api';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
isUserAuthenticated,
|
||||
extractUsernameFromToken,
|
||||
getMyProfile,
|
||||
updateMyProfile,
|
||||
getTelegramStatus,
|
||||
generateTelegramLinkToken,
|
||||
unlinkTelegram,
|
||||
} from "../../api/api";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faUser,
|
||||
faMapMarkerAlt,
|
||||
faPhone,
|
||||
faCommentDots,
|
||||
faSave,
|
||||
faCheckCircle,
|
||||
faExclamationTriangle,
|
||||
faPaperPlane,
|
||||
faUnlink,
|
||||
faTimes,
|
||||
faLock,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import "./ProfilePage.css";
|
||||
faUser, faMapMarkerAlt, faPhone, faCommentDots,
|
||||
faSave, faCheckCircle, faExclamationTriangle, faPaperPlane, faUnlink, faTimes, faLock, faShieldAlt,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import './ProfilePage.css';
|
||||
|
||||
const STORAGE_ADDRESS = "profile_default_address";
|
||||
const STORAGE_PHONE = "profile_default_phone";
|
||||
const STORAGE_SIGNAL = "profile_signal_pseudo";
|
||||
const STORAGE_ADDRESS = 'profile_default_address';
|
||||
const STORAGE_PHONE = 'profile_default_phone';
|
||||
const STORAGE_SIGNAL = 'profile_signal_pseudo';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Données compte (backend)
|
||||
const [nom, setNom] = useState("");
|
||||
const [prenom, setPrenom] = useState("");
|
||||
const [telephone, setTelephone] = useState("");
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
// Données compte (backend)
|
||||
const [nom, setNom] = useState('');
|
||||
const [prenom, setPrenom] = useState('');
|
||||
const [telephone, setTelephone] = useState('');
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
|
||||
// Données locales (localStorage)
|
||||
const [defaultAddress, setDefaultAddress] = useState(
|
||||
() => localStorage.getItem(STORAGE_ADDRESS) ?? "",
|
||||
);
|
||||
const [defaultPhone, setDefaultPhone] = useState(
|
||||
() => localStorage.getItem(STORAGE_PHONE) ?? "",
|
||||
);
|
||||
const [signalPseudo, setSignalPseudo] = useState(
|
||||
() => localStorage.getItem(STORAGE_SIGNAL) ?? "",
|
||||
);
|
||||
// Données locales (localStorage)
|
||||
const [defaultAddress, setDefaultAddress] = useState(() => localStorage.getItem(STORAGE_ADDRESS) ?? '');
|
||||
const [defaultPhone, setDefaultPhone] = useState(() => localStorage.getItem(STORAGE_PHONE) ?? '');
|
||||
const [signalPseudo, setSignalPseudo] = useState(() => localStorage.getItem(STORAGE_SIGNAL) ?? '');
|
||||
|
||||
// Feedback
|
||||
const [savingContact, setSavingContact] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [savingContact, setSavingContact] = useState(false);
|
||||
|
||||
// Telegram
|
||||
const [tgLinked, setTgLinked] = useState(false);
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
const [tgLoading, setTgLoading] = useState(false);
|
||||
// Modals succès / erreur (pattern identique à l'app mobile)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [successTitle, setSuccessTitle] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Modal confirmation infos par défaut
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
// Telegram
|
||||
const [tgLinked, setTgLinked] = useState(false);
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
const [tgLoading, setTgLoading] = useState(false);
|
||||
|
||||
const username = extractUsernameFromToken() ?? "";
|
||||
// 2FA
|
||||
const [twoFAEnabled, setTwoFAEnabled] = useState(false);
|
||||
const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false);
|
||||
const [twoFALoading, setTwoFALoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
// Statut Telegram
|
||||
getTelegramStatus().then((s) => {
|
||||
setTgLinked(s.linked);
|
||||
setTgEnabled(s.enabled);
|
||||
});
|
||||
// Modals confirmation
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [showConfirmAddressModal, setShowConfirmAddressModal] = useState(false);
|
||||
const [showConfirmContactModal, setShowConfirmContactModal] = useState(false);
|
||||
const [showUnlinkModal, setShowUnlinkModal] = useState(false);
|
||||
const [showUnlinkSuccessModal, setShowUnlinkSuccessModal] = useState(false);
|
||||
|
||||
// Charger depuis backend
|
||||
getMyProfile().then((res) => {
|
||||
if (res.success && res.client) {
|
||||
setNom(res.client.nom ?? "");
|
||||
setPrenom(res.client.prenom ?? "");
|
||||
setTelephone(res.client.telephone ?? "");
|
||||
// Initialiser le téléphone par défaut si pas encore défini
|
||||
if (
|
||||
!localStorage.getItem(STORAGE_PHONE) &&
|
||||
res.client.telephone
|
||||
) {
|
||||
setDefaultPhone(res.client.telephone);
|
||||
}
|
||||
}
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [navigate]);
|
||||
const username = extractUsernameFromToken() ?? '';
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setErrorMsg("");
|
||||
setTimeout(() => setSuccessMsg(""), 3000);
|
||||
};
|
||||
const showError = (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
setSuccessMsg("");
|
||||
setTimeout(() => setErrorMsg(""), 4000);
|
||||
};
|
||||
|
||||
const saveLocal = () => {
|
||||
localStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||
localStorage.setItem(STORAGE_PHONE, defaultPhone.trim());
|
||||
localStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim());
|
||||
setShowSaveModal(false);
|
||||
showSuccess("Informations par défaut enregistrées");
|
||||
};
|
||||
|
||||
const handleLinkTelegram = async () => {
|
||||
setTgLoading(true);
|
||||
|
||||
// ✅ Ouvrir AVANT le await
|
||||
const newWindow = window.open("", "_blank");
|
||||
|
||||
const res = await generateTelegramLinkToken();
|
||||
setTgLoading(false);
|
||||
|
||||
if (res.error || !res.link_url) {
|
||||
newWindow?.close();
|
||||
showError(res.error || "Service Telegram non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
if (isIOS) {
|
||||
const botUsername = res.link_url.split("t.me/")[1]?.split("?")[0];
|
||||
const token = new URL(res.link_url).searchParams.get("start");
|
||||
newWindow!.location.href = `tg://resolve?domain=${botUsername}&start=${token}`;
|
||||
setTimeout(() => {
|
||||
newWindow!.location.href = res.link_url!;
|
||||
}, 1500);
|
||||
} else {
|
||||
newWindow!.location.href = res.link_url;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkTelegram = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Délier votre compte Telegram ? Vous ne recevrez plus de notifications.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await unlinkTelegram();
|
||||
setTgLinked(false);
|
||||
showSuccess("Compte Telegram délié");
|
||||
};
|
||||
|
||||
const saveContact = async () => {
|
||||
setSavingContact(true);
|
||||
const res = await updateMyProfile({
|
||||
nom: nom.trim(),
|
||||
prenom: prenom.trim(),
|
||||
telephone: telephone.trim(),
|
||||
});
|
||||
setSavingContact(false);
|
||||
if (res.success) {
|
||||
showSuccess("Profil mis à jour");
|
||||
} else {
|
||||
showError(res.message ?? "Erreur lors de la mise à jour");
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingProfile) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-loading">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate('/login/client', { replace: true });
|
||||
return;
|
||||
}
|
||||
// Statut Telegram
|
||||
getTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
|
||||
|
||||
// Statut 2FA
|
||||
Promise.all([get2FAStatus(), getPublicSettings()]).then(([status, pub]) => {
|
||||
setTwoFAEnabled(status.two_fa_enabled);
|
||||
setTwoFAAdminEnabled(pub.two_fa_enabled);
|
||||
});
|
||||
|
||||
// Charger depuis backend
|
||||
getMyProfile().then((res) => {
|
||||
if (res.success && res.client) {
|
||||
setNom(res.client.nom ?? '');
|
||||
setPrenom(res.client.prenom ?? '');
|
||||
setTelephone(res.client.telephone ?? '');
|
||||
// Initialiser le téléphone par défaut si pas encore défini
|
||||
if (!localStorage.getItem(STORAGE_PHONE) && res.client.telephone) {
|
||||
setDefaultPhone(res.client.telephone);
|
||||
}
|
||||
}
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const showSuccess = (title: string, msg: string) => {
|
||||
setSuccessTitle(title); setSuccessMsg(msg); setShowSuccessModal(true);
|
||||
};
|
||||
const showError = (msg: string) => {
|
||||
setErrorMsg(msg); setShowErrorModal(true);
|
||||
};
|
||||
|
||||
const saveAddress = () => {
|
||||
localStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||
setShowConfirmAddressModal(false);
|
||||
showSuccess('Adresse enregistrée', 'Votre adresse par défaut a été sauvegardée et sera pré-remplie à votre prochaine commande.');
|
||||
};
|
||||
|
||||
const saveLocal = () => {
|
||||
localStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||
localStorage.setItem(STORAGE_PHONE, defaultPhone.trim());
|
||||
localStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim());
|
||||
setShowSaveModal(false);
|
||||
showSuccess('Infos enregistrées', 'Adresse, téléphone et pseudo Signal sauvegardés. Ils seront pré-remplis à votre prochaine commande.');
|
||||
};
|
||||
|
||||
const handleLinkTelegram = async () => {
|
||||
setTgLoading(true);
|
||||
const res = await generateTelegramLinkToken();
|
||||
setTgLoading(false);
|
||||
if (res.error || !res.link_url) {
|
||||
showError(res.error || 'Service Telegram non disponible');
|
||||
return;
|
||||
}
|
||||
window.open(res.link_url, '_blank');
|
||||
};
|
||||
|
||||
const handleUnlinkTelegram = () => {
|
||||
setShowUnlinkModal(true);
|
||||
};
|
||||
|
||||
const confirmUnlinkTelegram = async () => {
|
||||
setShowUnlinkModal(false);
|
||||
await unlinkTelegram();
|
||||
setTgLinked(false);
|
||||
setTwoFAEnabled(false);
|
||||
setShowUnlinkSuccessModal(true);
|
||||
};
|
||||
|
||||
const handleToggle2FA = async () => {
|
||||
const newVal = !twoFAEnabled;
|
||||
setTwoFALoading(true);
|
||||
const res = await toggle2FA(newVal);
|
||||
setTwoFALoading(false);
|
||||
if (res.success) {
|
||||
setTwoFAEnabled(newVal);
|
||||
showSuccess(newVal ? '2FA activée' : '2FA désactivée', newVal ? 'Un code vous sera envoyé sur Telegram à chaque connexion.' : 'La double authentification a été désactivée.');
|
||||
} else {
|
||||
showError(res.error || 'Erreur lors de la modification');
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
showSuccess('Profil mis à jour', 'Vos informations de compte ont été enregistrées avec succès.');
|
||||
} else {
|
||||
showError(res.message ?? 'Erreur lors de la mise à jour du profil.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingProfile) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-title">Mon Profil</h1>
|
||||
<p className="profile-username">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMsg && (
|
||||
<div className="profile-alert profile-alert--success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> {successMsg}
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<div className="profile-alert profile-alert--error">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />{" "}
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section compte */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="profile-card-icon"
|
||||
/>
|
||||
Mon compte
|
||||
</h2>
|
||||
<div className="profile-fields">
|
||||
<div className="profile-row">
|
||||
<div className="profile-group">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prenom}
|
||||
onChange={(e) => setPrenom(e.target.value)}
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nom}
|
||||
onChange={(e) => setNom(e.target.value)}
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone (compte)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={telephone}
|
||||
onChange={(e) => setTelephone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="profile-btn"
|
||||
onClick={saveContact}
|
||||
disabled={savingContact}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
{savingContact
|
||||
? " Enregistrement..."
|
||||
: " Enregistrer le compte"}
|
||||
</button>
|
||||
<button
|
||||
className="profile-btn profile-btn--secondary"
|
||||
onClick={() => navigate("/user/change-password")}
|
||||
style={{ marginTop: "0.6rem" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} /> Changer le mot de
|
||||
passe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section adresse par défaut */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faMapMarkerAlt}
|
||||
className="profile-card-icon profile-card-icon--address"
|
||||
/>
|
||||
Adresse par défaut
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Sera pré-remplie dans le formulaire de commande. Vous
|
||||
pourrez la modifier si vous n'êtes pas à cette adresse.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defaultAddress}
|
||||
onChange={(e) => setDefaultAddress(e.target.value)}
|
||||
placeholder="Numéro, rue, ville, code postal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section contact commande */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faPhone}
|
||||
className="profile-card-icon profile-card-icon--phone"
|
||||
/>
|
||||
Contact livraison
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Numéro utilisé par le livreur lors de la livraison. Peut
|
||||
être différent du numéro de votre compte.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone par défaut</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={defaultPhone}
|
||||
onChange={(e) => setDefaultPhone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="profile-group"
|
||||
style={{ marginTop: "1rem" }}
|
||||
>
|
||||
<label>
|
||||
<FontAwesomeIcon
|
||||
icon={faCommentDots}
|
||||
style={{ marginRight: "0.4rem" }}
|
||||
/>
|
||||
Pseudo Signal (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signalPseudo}
|
||||
onChange={(e) => setSignalPseudo(e.target.value)}
|
||||
placeholder="@votre.pseudo.signal"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="profile-btn profile-btn--secondary"
|
||||
onClick={() => setShowSaveModal(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSave} /> Enregistrer les infos
|
||||
par défaut
|
||||
</button>
|
||||
</div>
|
||||
{/* Section Telegram */}
|
||||
{tgEnabled && (
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faPaperPlane}
|
||||
className="profile-card-icon profile-card-icon--telegram"
|
||||
/>
|
||||
Notifications Telegram
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Recevez vos notifications sur Telegram, même quand
|
||||
le site est fermé.
|
||||
</p>
|
||||
{tgLinked ? (
|
||||
<div className="profile-telegram-linked">
|
||||
<span className="profile-telegram-status">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />{" "}
|
||||
Compte Telegram lié
|
||||
</span>
|
||||
<button
|
||||
className="profile-btn profile-btn--danger"
|
||||
onClick={handleUnlinkTelegram}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUnlink} /> Délier
|
||||
Telegram
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="profile-btn profile-btn--telegram"
|
||||
onClick={handleLinkTelegram}
|
||||
disabled={tgLoading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPaperPlane} />
|
||||
{tgLoading
|
||||
? " Génération du lien..."
|
||||
: " Lier mon compte Telegram"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveModal && (
|
||||
<div
|
||||
className="profile-modal-overlay"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
<div
|
||||
className="profile-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="profile-modal-close"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">
|
||||
Enregistrer les infos par défaut ?
|
||||
</h3>
|
||||
<p className="profile-modal-body">
|
||||
Adresse, téléphone de livraison et pseudo Signal
|
||||
seront sauvegardés localement et pré-remplis lors de
|
||||
vos prochaines commandes.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button
|
||||
className="profile-modal-btn profile-modal-btn--cancel"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
className="profile-modal-btn profile-modal-btn--confirm"
|
||||
onClick={saveLocal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheckCircle} />{" "}
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-loading"><div className="spinner" /></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-title">Mon Profil</h1>
|
||||
<p className="profile-username">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section compte */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faUser} className="profile-card-icon" />
|
||||
Mon compte
|
||||
</h2>
|
||||
<div className="profile-fields">
|
||||
<div className="profile-row">
|
||||
<div className="profile-group">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prenom}
|
||||
onChange={(e) => setPrenom(e.target.value)}
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nom}
|
||||
onChange={(e) => setNom(e.target.value)}
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone (compte)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={telephone}
|
||||
onChange={(e) => setTelephone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="profile-btn" onClick={() => setShowConfirmContactModal(true)} disabled={savingContact}>
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
{savingContact ? ' Enregistrement...' : ' Enregistrer le compte'}
|
||||
</button>
|
||||
<button className="profile-btn profile-btn--secondary" onClick={() => navigate('/user/change-password')} style={{ marginTop: '0.6rem' }}>
|
||||
<FontAwesomeIcon icon={faLock} /> Changer le mot de passe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section adresse par défaut */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="profile-card-icon profile-card-icon--address" />
|
||||
Adresse par défaut
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Sera pré-remplie dans le formulaire de commande. Vous pourrez la modifier si vous n'êtes pas à cette adresse.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defaultAddress}
|
||||
onChange={(e) => setDefaultAddress(e.target.value)}
|
||||
placeholder="Numéro, rue, ville, code postal"
|
||||
/>
|
||||
</div>
|
||||
<button className="profile-btn profile-btn--secondary" onClick={() => setShowConfirmAddressModal(true)} style={{ marginTop: '0.8rem' }}>
|
||||
<FontAwesomeIcon icon={faSave} /> Enregistrer l'adresse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section contact commande */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faPhone} className="profile-card-icon profile-card-icon--phone" />
|
||||
Contact livraison
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Numéro utilisé par le livreur lors de la livraison. Peut être différent du numéro de votre compte.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone par défaut</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={defaultPhone}
|
||||
onChange={(e) => setDefaultPhone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-group" style={{ marginTop: '1rem' }}>
|
||||
<label>
|
||||
<FontAwesomeIcon icon={faCommentDots} style={{ marginRight: '0.4rem' }} />
|
||||
Pseudo Signal (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signalPseudo}
|
||||
onChange={(e) => setSignalPseudo(e.target.value)}
|
||||
placeholder="@votre.pseudo.signal"
|
||||
/>
|
||||
</div>
|
||||
<button className="profile-btn profile-btn--secondary" onClick={() => setShowSaveModal(true)}>
|
||||
<FontAwesomeIcon icon={faSave} /> Enregistrer les infos par défaut
|
||||
</button>
|
||||
</div>
|
||||
{/* Section Telegram */}
|
||||
{tgEnabled && (
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faPaperPlane} className="profile-card-icon profile-card-icon--telegram" />
|
||||
Notifications Telegram
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Recevez vos notifications sur Telegram, même quand le site est fermé.
|
||||
</p>
|
||||
{tgLinked ? (
|
||||
<div className="profile-telegram-linked">
|
||||
<span className="profile-telegram-status">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Compte Telegram lié
|
||||
</span>
|
||||
<button className="profile-btn profile-btn--danger" onClick={handleUnlinkTelegram}>
|
||||
<FontAwesomeIcon icon={faUnlink} /> Délier Telegram
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="profile-btn profile-btn--telegram" onClick={handleLinkTelegram} disabled={tgLoading}>
|
||||
<FontAwesomeIcon icon={faPaperPlane} />
|
||||
{tgLoading ? ' Génération du lien...' : ' Lier mon compte Telegram'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
|
||||
{twoFAAdminEnabled && tgLinked && (
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faShieldAlt} className="profile-card-icon" style={{ color: '#6366f1' }} />
|
||||
Double authentification (2FA)
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte.
|
||||
</p>
|
||||
<div className="profile-2fa-row">
|
||||
<div className="profile-2fa-label">
|
||||
<span>{twoFAEnabled ? 'Activée' : 'Désactivée'}</span>
|
||||
{twoFAEnabled && (
|
||||
<span className="profile-2fa-badge">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Protection active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{twoFALoading ? (
|
||||
<div className="profile-2fa-spinner" />
|
||||
) : (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={twoFAEnabled}
|
||||
className={`profile-toggle ${twoFAEnabled ? 'profile-toggle--on' : ''}`}
|
||||
onClick={handleToggle2FA}
|
||||
aria-label="Activer ou désactiver la double authentification"
|
||||
>
|
||||
<span className="profile-toggle-thumb" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowSaveModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowSaveModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Enregistrer les infos par défaut ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Adresse, téléphone de livraison et pseudo Signal seront sauvegardés localement et pré-remplis lors de vos prochaines commandes.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowSaveModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveLocal}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmAddressModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowConfirmAddressModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowConfirmAddressModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Enregistrer l'adresse ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Cette adresse sera sauvegardée localement et pré-remplie lors de vos prochaines commandes.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowConfirmAddressModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveAddress}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmContactModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowConfirmContactModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowConfirmContactModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Enregistrer le compte ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Vos informations (prénom, nom, téléphone) seront mises à jour sur votre compte.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowConfirmContactModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveContact} disabled={savingContact}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> {savingContact ? 'Enregistrement...' : 'Confirmer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnlinkModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowUnlinkModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowUnlinkModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--danger">
|
||||
<FontAwesomeIcon icon={faUnlink} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Délier Telegram ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Vous ne recevrez plus de notifications Telegram. La double authentification sera également désactivée.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowUnlinkModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--danger" onClick={confirmUnlinkTelegram}>
|
||||
<FontAwesomeIcon icon={faUnlink} /> Délier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal succès */}
|
||||
{showSuccessModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowSuccessModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowSuccessModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">{successTitle}</h3>
|
||||
<p className="profile-modal-body">{successMsg}</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm profile-modal-btn--success" onClick={() => setShowSuccessModal(false)}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal erreur */}
|
||||
{showErrorModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowErrorModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowErrorModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--danger">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Une erreur est survenue</h3>
|
||||
<p className="profile-modal-body">{errorMsg}</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--danger" onClick={() => setShowErrorModal(false)}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnlinkSuccessModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowUnlinkSuccessModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowUnlinkSuccessModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Compte délié</h3>
|
||||
<p className="profile-modal-body">
|
||||
Votre compte Telegram a été délié avec succès. Vous ne recevrez plus de notifications via Telegram.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={() => setShowUnlinkSuccessModal(false)}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,29 +73,18 @@ interface ToastMessage {
|
||||
* Somme des prix individuels (pas de multiplication)
|
||||
*/
|
||||
const getTotalAmount = (order: OrderWithTracking): number => {
|
||||
// 1. Priorité: champ total stocké en DB
|
||||
if (typeof order.total === "number" && order.total > 0) {
|
||||
return order.total;
|
||||
}
|
||||
|
||||
// 2. Fallback: total_prix
|
||||
if (typeof order.total_prix === "number" && order.total_prix > 0) {
|
||||
return order.total_prix;
|
||||
}
|
||||
|
||||
// 3. Calcul depuis items (comme dans Checkout: somme des prix)
|
||||
if (typeof order.total === "number" && order.total > 0) {
|
||||
return order.total;
|
||||
}
|
||||
if (order.items && order.items.length > 0) {
|
||||
const calculatedTotal = order.items.reduce((sum, item) => {
|
||||
return order.items.reduce((sum, item) => {
|
||||
const itemPrice = item.prix || item.price || 0;
|
||||
return sum + itemPrice; // ✅ Somme simple (pas de × quantity)
|
||||
return sum + itemPrice;
|
||||
}, 0);
|
||||
|
||||
console.log(
|
||||
`💰 [getTotalAmount] Commande ${order.id}: ${calculatedTotal.toFixed(2)}€`,
|
||||
);
|
||||
return calculatedTotal;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -336,20 +325,17 @@ function SuiviLivraison() {
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadOrders = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger les commandes
|
||||
if (!isUserAuthenticated()) {
|
||||
console.log("❌ [loadOrders] Non authentifié");
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getMyOrders();
|
||||
|
||||
if (response.success && response.commands) {
|
||||
if (response.success) {
|
||||
const ordersWithTracking = await Promise.all(
|
||||
response.commands.map(async (order: OrderDetail) => {
|
||||
(response.commands || []).map(async (order: OrderDetail) => {
|
||||
const normalizedOrder = {
|
||||
...order,
|
||||
total: getTotalAmount(order),
|
||||
@@ -361,18 +347,12 @@ function SuiviLivraison() {
|
||||
try {
|
||||
tracking = await getOrderTracking(order.id);
|
||||
} catch {
|
||||
console.warn(
|
||||
`Tracking non disponible pour commande ${order.id}`,
|
||||
);
|
||||
tracking = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
eta = await getOrderETA(order.id);
|
||||
} catch {
|
||||
console.warn(
|
||||
`ETA non disponible pour commande ${order.id}`,
|
||||
);
|
||||
eta = undefined;
|
||||
}
|
||||
|
||||
@@ -386,9 +366,6 @@ function SuiviLivraison() {
|
||||
|
||||
setOrders(ordersWithTracking);
|
||||
setError("");
|
||||
} else {
|
||||
setError("Impossible de charger les commandes");
|
||||
showToast("Impossible de charger les commandes", "error");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Erreur loadOrders:", err);
|
||||
@@ -945,14 +922,21 @@ function SuiviLivraison() {
|
||||
/>{" "}
|
||||
Montant total
|
||||
</h4>
|
||||
{(order.referral_used ?? 0) > 0 && (
|
||||
<p style={{ margin: "0 0 2px", fontSize: "0.85rem", color: "var(--text-muted)" }}>
|
||||
Brut : {(order.total_prix ?? 0).toFixed(2)} €
|
||||
</p>
|
||||
)}
|
||||
<p className="total-amount">
|
||||
<strong>
|
||||
{getTotalAmount(
|
||||
order,
|
||||
).toFixed(2)}{" "}
|
||||
€
|
||||
{Math.max(0, getTotalAmount(order) - (order.referral_used ?? 0)).toFixed(2)} €
|
||||
</strong>
|
||||
</p>
|
||||
{(order.referral_used ?? 0) > 0 && (
|
||||
<p style={{ margin: "4px 0 0", fontSize: "0.82rem", color: "#10b981", fontWeight: 500 }}>
|
||||
— dont {(order.referral_used!).toFixed(2)} € parrainage déduit
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
|
||||
Reference in New Issue
Block a user