Files
projet_gestion_commande/frontend-prep/src/pages/User/ConsultationHistorique.tsx
T
Xor290 42ed11bc22
Backend - Build & Lint / build (push) Canceled after 21m42s
Frontend Web - Build & Lint / build (push) Successful in 13m5s
chore: build
2026-08-30 15:24:17 +02:00

802 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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 {
faCannabis,
faPills,
faFlask,
faMortarPestle,
faStar,
faTrophy,
faExclamationTriangle,
faShieldAlt,
faGift,
faReceipt,
faMapMarkerAlt,
faClock,
faBicycle,
faChevronRight,
faCheckCircle,
faHistory,
faTimes,
faCheck,
} from "@fortawesome/free-solid-svg-icons";
function ConsultationHistorique() {
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: [],
shop_name: "Milieu-Nantais",
two_fa_enabled: false,
contact_telegram: "",
client_color_primary: "",
client_color_secondary: "",
client_color_success: "",
client_color_danger: "",
client_color_warning: "",
client_title_gradient_from: "",
client_title_gradient_to: "",
});
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>("");
// ── Modal de sélection du produit récompense ──
const [rewardModalPool, setRewardModalPool] =
useState<PointsPoolInfo | null>(null);
const [selectedProductId, setSelectedProductId] = useState<number | null>(
null,
);
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",
});
};
// Ouvre la modal de choix si plusieurs produits sont éligibles,
// sinon réclame directement (1 seul produit configuré ou aucun item).
const openRewardModal = (pool: PointsPoolInfo) => {
const items = pool.eligible_reward_items ?? [];
if (items.length > 1) {
setSelectedProductId(items[0]?.product_id ?? null);
setRewardModalPool(pool);
} else {
handleClaim(pool.key);
}
};
const closeRewardModal = () => {
setRewardModalPool(null);
setSelectedProductId(null);
};
const confirmRewardChoice = async () => {
if (!rewardModalPool) return;
const poolKey = rewardModalPool.key;
const productId = selectedProductId ?? undefined;
closeRewardModal();
await handleClaim(poolKey, productId);
};
const handleClaim = async (poolKey: string, productId?: number) => {
setClaimingPool(poolKey);
setClaimFeedback(null);
const res = await claimMyReward(poolKey, productId);
setClaimingPool(null);
if (res.success) {
const text =
res.product_added && res.product_names?.length
? `${res.product_names.join(", ")} ajouté${res.product_names.length > 1 ? "s" : ""} à 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 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",
];
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>
</>
);
}
return (
<>
<Navbar />
<div className="history-container">
<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} />
</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>
)}
</>
))}
{/* 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>
{/* 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.products ??
[]
).map(
(
p,
) => (
<span
key={`${cfg.category}-${p.product_id}`}
className="reward-eligible-cat"
>
{
p.product_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.type ===
"success" && (
<FontAwesomeIcon
icon={faGift}
style={{
marginRight: 6,
}}
/>
)}
{feedback.text}
</p>
)}
{pool.rewards_available > 0 && (
<button
className="reward-claim-btn"
onClick={() =>
openRewardModal(pool)
}
disabled={isClaiming}
>
{isClaiming
? "..."
: "Réclamer ma récompense"}
</button>
)}
</div>
);
})}
</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>
</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>
{/* ============================================ */}
{/* MODAL — CHOIX DU PRODUIT RÉCOMPENSE */}
{/* ============================================ */}
{rewardModalPool && pointsRewards?.reward && (
<div
className="reward-modal-overlay"
onClick={closeRewardModal}
>
<div
className="reward-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="reward-modal-header">
<div className="reward-modal-header-title">
<FontAwesomeIcon
icon={faGift}
className="reward-modal-icon"
/>
<h2>Choisissez votre récompense</h2>
</div>
<button
className="reward-modal-close"
onClick={closeRewardModal}
aria-label="Fermer"
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
<p className="reward-modal-subtitle">
{rewardModalPool.name} {" "}
{rewardModalPool.rewards_available} récompense
{rewardModalPool.rewards_available > 1
? "s"
: ""}{" "}
disponible
{rewardModalPool.rewards_available > 1 ? "s" : ""}
</p>
<div className="reward-modal-products">
{(rewardModalPool.eligible_reward_items ?? []).map(
(item) => {
const isSelected =
selectedProductId === item.product_id;
return (
<button
key={item.product_id}
type="button"
className={`reward-product-card ${isSelected ? "selected" : ""}`}
onClick={() =>
setSelectedProductId(
item.product_id,
)
}
>
<div className="reward-product-radio">
{isSelected && (
<FontAwesomeIcon
icon={faCheck}
/>
)}
</div>
<div className="reward-product-info">
<span className="reward-product-name">
{item.product_name}
</span>
<span className="reward-product-meta">
{item.quantity > 0 &&
item.quantity !== 1
? `×${item.quantity}`
: ""}
{item.type ===
"half_price_product" &&
item.price > 0
? ` · ${item.price.toFixed(2)} €`
: ""}
</span>
</div>
<span className="reward-product-free">
{item.type ===
"half_price_product"
? "-50%"
: "Offert"}
</span>
</button>
);
},
)}
</div>
<div className="reward-modal-actions">
<button
className="reward-modal-btn reward-modal-btn-cancel"
onClick={closeRewardModal}
>
Annuler
</button>
<button
className="reward-modal-btn reward-modal-btn-confirm"
onClick={confirmRewardChoice}
disabled={selectedProductId === null}
>
<FontAwesomeIcon icon={faGift} />
Confirmer
</button>
</div>
</div>
</div>
)}
</>
);
}
export default ConsultationHistorique;