chore: build

This commit is contained in:
2026-06-14 17:50:35 +02:00
parent 3c92a0371f
commit 3a0f725159
93 changed files with 5311 additions and 4224 deletions
-15
View File
@@ -62,7 +62,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -874,7 +873,6 @@
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
@@ -1463,7 +1461,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1473,7 +1470,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1530,7 +1526,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -1769,7 +1764,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1869,7 +1863,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -2108,7 +2101,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -2677,7 +2669,6 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.2.tgz",
"integrity": "sha512-CPjtWygL+f7naL+sGHoC2JQR0DG7u+9ik6WdkjjVmz2uy0kBC2l+aKfdi3ZzUR7VKSQJ6Mc/CeCN+6iVNah+ww==",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.0",
"@mapbox/geojson-types": "^1.0.2",
@@ -2887,7 +2878,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2960,7 +2950,6 @@
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2969,7 +2958,6 @@
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3228,7 +3216,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3319,7 +3306,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -3460,7 +3446,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+198 -23
View File
@@ -1,9 +1,3 @@
// ============================================
// api/api.ts - VERSION CORRIGÉE
// ============================================
// ✅ AuthResponse inclut access_token
// ✅ loginUser et registerUser retournent AuthResponse
// ✅ sessionStorage (pas localStorage)
const API_URL = "/api/v1";
const BACKEND_URL = "";
export function getMediaUrl(url: string): string {
@@ -28,19 +22,15 @@ import type {
CancelCommandResponse,
PenaltiesResponse,
} from "./api_types";
// ============================================
// 🔐 TYPES - AUTHRESPONSE COMPLETE
// ============================================
/**
* ✅ TYPE CORRECT - Inclut access_token!
*/
export interface AuthResponse {
success: boolean;
message?: string;
access_token?: string; // ✅ CRITICAL!
access_token?: string;
token_type?: string;
expires_in?: number;
requires_2fa?: boolean;
session_token?: string;
user?: {
id: number;
username: string;
@@ -53,17 +43,8 @@ export interface AuthResponse {
};
}
// ============================================
// 🔐 GESTION CENTRALISÉE DU JWT
// ============================================
/**
* ✅ Extraire username du JWT
* Source de vérité UNIQUE pour le username
*/
export const extractUsernameFromToken = (): string | null => {
try {
// ✅ CRITICAL: sessionStorage (pas localStorage!)
const token = sessionStorage.getItem("token");
if (!token) {
@@ -210,6 +191,15 @@ export const loginUser = async (
const data = await safeJson(response);
console.log("📋 [LOGIN] Réponse:", data);
// 2FA requis — retourner sans token
if (data.requires_2fa) {
return {
success: true,
requires_2fa: true,
session_token: data.session_token,
};
}
// ✅ Vérifier access_token
if (!data.access_token) {
console.error("❌ [LOGIN] Pas de access_token");
@@ -728,6 +718,9 @@ export const createCheckout = async (checkoutData: CheckoutData) => {
pay_currency: data.pay_currency as string | undefined,
price_amount: data.price_amount as number | undefined,
price_currency: data.price_currency as string | undefined,
referral_used: data.referral_used as number | undefined,
referral_balance: data.referral_balance as number | undefined,
client_order_number: data.client_order_number as number | undefined,
};
} catch (error) {
console.error("❌ [CHECKOUT] Erreur:", error);
@@ -804,8 +797,9 @@ export interface Product {
category: string;
unit?: string;
stock: number;
prices?: Array<{ quantity: number; price: number }>;
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
coming_soon?: boolean;
}
export interface Category {
@@ -1866,6 +1860,9 @@ export interface PublicSettings {
crypto_payment_enabled: boolean;
crypto_only: boolean;
nowpayments_currencies: string[];
shop_name: string;
two_fa_enabled: boolean;
contact_telegram: string;
}
export const getPublicSettings = async (): Promise<PublicSettings> => {
@@ -1880,6 +1877,9 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
crypto_payment_enabled: false,
crypto_only: false,
nowpayments_currencies: [],
shop_name: "Milieu-Nantais",
two_fa_enabled: false,
contact_telegram: "",
};
try {
const response = await fetch(`${API_URL}/app-settings`);
@@ -1901,12 +1901,93 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
nowpayments_currencies: Array.isArray(data.nowpayments_currencies)
? data.nowpayments_currencies
: [],
shop_name: data.shop_name || "Milieu-Nantais",
two_fa_enabled: data.two_fa_enabled ?? false,
contact_telegram: data.contact_telegram || "",
};
} catch {
return defaults;
}
};
export const verify2FA = async (
sessionToken: string,
code: string,
): Promise<AuthResponse> => {
try {
const response = await fetch(`${API_URL}/auth/2fa/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_token: sessionToken, code }),
});
const data = await safeJson(response);
if (!response.ok) {
return { success: false, message: data.error || "Code invalide" };
}
sessionStorage.setItem("token", data.access_token);
syncUsernameFromJWT();
return {
success: true,
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
user: data.user,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Erreur",
};
}
};
export const get2FAStatus = async (): Promise<{
two_fa_enabled: boolean;
telegram_linked: boolean;
admin_2fa_enabled: boolean;
}> => {
const token = sessionStorage.getItem("token");
try {
const response = await fetch(`${API_URL}/two-fa/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok)
return {
two_fa_enabled: false,
telegram_linked: false,
admin_2fa_enabled: false,
};
return await safeJson(response);
} catch {
return {
two_fa_enabled: false,
telegram_linked: false,
admin_2fa_enabled: false,
};
}
};
export const toggle2FA = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
const token = sessionStorage.getItem("token");
try {
const response = await fetch(`${API_URL}/two-fa/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled }),
});
const data = await safeJson(response);
if (!response.ok) return { success: false, error: data.error };
return { success: true };
} catch {
return { success: false, error: "Erreur réseau" };
}
};
export interface CryptoPaymentStatus {
command_id: number;
client_order_number?: number;
@@ -2058,3 +2139,97 @@ export const unlinkTelegram = async (): Promise<void> => {
/* silencieux */
}
};
// ============================================
// 🏆 POINTS — RÉCOMPENSES
// ============================================
export type RewardCategoryConfig = {
category: string;
all_products: boolean;
product_ids: number[];
product_names: string[];
amount: number;
};
export type PointsPoolInfo = {
key: string;
name: string;
points: number;
rewards_earned: number;
rewards_claimed: number;
rewards_available: number;
eligible_configs: RewardCategoryConfig[];
};
export type RewardItemConfig = {
product_id: number;
product_name: string;
quantity: number;
price: number;
};
export type PointsRewardConfig = {
threshold: number;
type: string;
description: string;
reward_items: RewardItemConfig[];
};
export const getMyPointsRewards = async (): Promise<{
success: boolean;
enabled: boolean;
pools: PointsPoolInfo[];
reward: PointsRewardConfig | null;
}> => {
const token = getAuthToken();
if (!token) return { success: false, enabled: false, pools: [], reward: null };
try {
const response = await fetch(`${API_URL}/points/rewards`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return { success: false, enabled: false, pools: [], reward: null };
const data = await safeJson(response);
return {
success: true,
enabled: data.enabled ?? false,
pools: data.pools ?? [],
reward: data.reward ?? null,
};
} catch {
return { success: false, enabled: false, pools: [], reward: null };
}
};
export const claimMyReward = async (poolKey: string): Promise<{
success: boolean;
description?: string;
remaining_rewards?: number;
product_added?: boolean;
product_name?: string;
error?: string;
}> => {
const token = getAuthToken();
if (!token) return { success: false, error: "Non authentifié" };
try {
const response = await fetch(`${API_URL}/points/claim`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ pool_key: poolKey }),
});
const data = await safeJson(response);
if (!response.ok) return { success: false, error: data.error || "Erreur" };
return {
success: true,
description: data.description,
remaining_rewards: data.remaining_rewards,
product_added: data.product_added,
product_name: data.product_name,
};
} catch {
return { success: false, error: "Erreur de connexion" };
}
};
+7 -12
View File
@@ -1,16 +1,7 @@
// ============================================
// api/api_TYPES.ts - TOUTES LES INTERFACES
// ============================================
// Interfaces complètes pour le frontend
// À importer dans les composants
// ============================================
// 🔐 AUTHENTIFICATION - TYPES
// ============================================
/**
* Réponse générique de l'API
*/
export interface ApiResponse {
success: boolean;
message?: string;
@@ -19,8 +10,7 @@ export interface ApiResponse {
token_type?: string;
expires_in?: number;
user?: UserResponse;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any; // Pour les champs additionnels
[key: string]: unknown;
}
/**
@@ -58,6 +48,8 @@ export interface LoginResponse {
token_type?: string;
expires_in?: number;
user?: UserResponse;
requires_2fa?: boolean;
session_token?: string;
}
/**
@@ -118,6 +110,7 @@ export interface CartItem {
quantity: number;
category: string;
image?: string;
is_reward?: boolean;
}
/**
@@ -364,6 +357,7 @@ export interface TrackingResponse {
export interface ProductPrice {
quantity: number;
price: number;
active_price?: boolean;
}
export interface Product {
id: number;
@@ -580,6 +574,7 @@ export interface CompletedOrder {
status: string;
adresse: string;
total_prix: number;
referral_used?: number;
livreur_assign?: string;
created_at: string;
updated_at: string;
@@ -776,6 +771,6 @@ export interface ConfirmReceptionResponse {
category?: string;
points_earned?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
[key: string]: any;
};
}
+87 -38
View File
@@ -443,59 +443,106 @@ body {
}
/* ============================================================
Notification panel
Notification bottom-sheet modal
============================================================ */
.notif-panel {
position: absolute;
top: calc(var(--topbar-h) - 4px);
right: 0;
width: 300px;
max-height: 380px;
.notif-modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn 0.18s ease;
}
.notif-modal {
width: 100%;
max-width: 640px;
height: 70vh;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
display: flex;
flex-direction: column;
animation: slideDown 0.18s ease;
overflow: hidden;
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.5);
animation: slideUp 0.26s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.notif-panel-header {
padding: 0.7rem 1rem;
.notif-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 1.25rem;
border-bottom: 1px solid var(--border);
color: var(--text);
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.notif-modal-title {
color: var(--text);
font-size: 1.05rem;
font-weight: 700;
}
.notif-modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.9rem;
cursor: pointer;
transition: all var(--transition);
}
.notif-modal-close:hover {
background: var(--surface-2);
color: var(--text);
}
.notif-modal-body {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.notif-modal-body::-webkit-scrollbar { width: 4px; }
.notif-modal-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.notif-empty {
padding: 1.5rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
color: var(--text-muted);
font-size: 0.85rem;
font-size: 0.9rem;
gap: 0.5rem;
height: 100%;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
max-height: 320px;
}
.notif-empty p { margin: 0; }
.notif-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 1rem;
gap: 0.3rem;
padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--border);
border-left: 3px solid transparent;
transition: background var(--transition);
}
@@ -503,20 +550,22 @@ body {
.notif-unread {
background: var(--primary-soft);
border-left: 3px solid var(--primary);
border-left-color: var(--primary);
}
.notif-read { opacity: 0.55; }
.notif-read {
opacity: 0.55;
}
.notif-message {
color: var(--text);
font-size: 0.83rem;
line-height: 1.45;
font-size: 0.88rem;
line-height: 1.5;
}
.notif-time {
color: var(--text-muted);
font-size: 0.72rem;
font-size: 0.74rem;
}
/* ============================================================
+70 -55
View File
@@ -31,6 +31,22 @@ interface MenuItem {
path: string;
}
function formatNotifDate(dateStr: string): string {
try {
const diffMs = Date.now() - new Date(dateStr).getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "À l'instant";
if (diffMin < 60) return `Il y a ${diffMin} min`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `Il y a ${diffH}h`;
const diffD = Math.floor(diffH / 24);
if (diffD === 1) return "Hier";
return `Il y a ${diffD} jours`;
} catch {
return "";
}
}
function Navbar() {
const { theme, toggleTheme } = useTheme();
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
@@ -39,9 +55,9 @@ function Navbar() {
const [unreadCount, setUnreadCount] = useState(0);
const [showNotifPanel, setShowNotifPanel] = useState(false);
const [referralEnabled, setReferralEnabled] = useState(true);
const [shopName, setShopName] = useState("Milieu-Nantais");
const seenKeysRef = useRef<Set<string>>(new Set());
const isFirstLoadRef = useRef(true);
const notifPanelRef = useRef<HTMLDivElement>(null);
const { cartCount } = useCart();
const navigate = useNavigate();
const location = useLocation();
@@ -74,28 +90,23 @@ function Navbar() {
}, [fetchNotifications]);
useEffect(() => {
getPublicSettings().then((s) => setReferralEnabled(s.referral_enabled));
}, []);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (notifPanelRef.current && !notifPanelRef.current.contains(e.target as Node)) {
setShowNotifPanel(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
getPublicSettings().then((s) => {
setReferralEnabled(s.referral_enabled);
if (s.shop_name) setShopName(s.shop_name);
});
}, []);
const handleNotifBellClick = async () => {
setShowNotifPanel((prev) => !prev);
if (!showNotifPanel && unreadCount > 0) {
setShowNotifPanel(true);
if (unreadCount > 0) {
await markNotificationsRead();
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}
};
const closeNotifPanel = () => setShowNotifPanel(false);
const menuItems: MenuItem[] = [
{ id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" },
{ id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" },
@@ -156,7 +167,7 @@ function Navbar() {
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
</button>
<span className="topbar-brand">MilieuNantais</span>
<span className="topbar-brand">{shopName}</span>
<div className="topbar-actions">
{/* Theme toggle */}
@@ -169,46 +180,18 @@ function Navbar() {
</button>
{/* Notifications */}
<div className="notif-wrapper" ref={notifPanelRef}>
<button
className="topbar-icon-btn"
onClick={handleNotifBellClick}
aria-label="Notifications"
>
<FontAwesomeIcon icon={faBell} />
{unreadCount > 0 && (
<span className="topbar-badge">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{showNotifPanel && (
<div className="notif-panel">
<div className="notif-panel-header">Notifications</div>
{notifications.length === 0 ? (
<div className="notif-empty">Aucune notification</div>
) : (
<ul className="notif-list">
{notifications.map((n, i) => (
<li
key={i}
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
>
<span className="notif-message">{n.message}</span>
<span className="notif-time">
{new Date(n.created_at).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)}
</div>
<button
className="topbar-icon-btn"
onClick={handleNotifBellClick}
aria-label="Notifications"
>
<FontAwesomeIcon icon={faBell} />
{unreadCount > 0 && (
<span className="topbar-badge">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
</button>
{/* Panier */}
<button
@@ -224,6 +207,38 @@ function Navbar() {
</div>
</header>
{/* ── Notifications modal ─────────────────────── */}
{showNotifPanel && (
<div className="notif-modal-overlay" onClick={closeNotifPanel}>
<div className="notif-modal" onClick={(e) => e.stopPropagation()}>
<div className="notif-modal-header">
<span className="notif-modal-title">Notifications</span>
<button className="notif-modal-close" onClick={closeNotifPanel} aria-label="Fermer">
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
<div className="notif-modal-body">
{notifications.length === 0 ? (
<div className="notif-empty">
<FontAwesomeIcon icon={faBell} style={{ fontSize: "2rem", marginBottom: "0.75rem", opacity: 0.3 }} />
<p>Aucune notification</p>
</div>
) : (
notifications.map((n, i) => (
<div
key={i}
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
>
<span className="notif-message">{n.message}</span>
<span className="notif-time">{formatNotifDate(n.created_at)}</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* ── Overlay ─────────────────────────────────── */}
{isMenuOpen && <div className="sidebar-overlay" onClick={closeMenu} />}
@@ -235,7 +250,7 @@ function Navbar() {
<FontAwesomeIcon icon={faShoppingCart} />
</div>
<div>
<p className="sidebar-brand-name">Milieu-Nantais</p>
<p className="sidebar-brand-name">{shopName}</p>
<p className="sidebar-brand-sub">Mon espace</p>
</div>
</div>
+25 -1
View File
@@ -14,7 +14,8 @@
.product-card:hover {
transform: scale(1.08);
box-shadow: 0 8px 25px color-mix(in srgb, var(--category-color, white) 40%, transparent);
box-shadow: 0 8px 25px
color-mix(in srgb, var(--category-color, white) 40%, transparent);
z-index: 10;
}
@@ -62,6 +63,29 @@
0 0 10px rgba(255, 0, 0, 0.5);
}
.coming-soon-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-15deg);
text-align: center;
z-index: 5;
pointer-events: none;
background-color: rgba(0, 0, 0, 0.8);
color: rgba(34, 197, 94, 0.95);
border: 6px solid rgba(34, 197, 94, 0.95);
padding: clamp(0.5rem, 2vw, 0.8rem) clamp(1.5rem, 5vw, 2.5rem);
font-size: clamp(1.1rem, 4.5vw, 1.8rem);
font-weight: 900;
letter-spacing: 3px;
text-transform: uppercase;
white-space: nowrap;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
text-shadow:
2px 2px 6px rgba(0, 0, 0, 0.9),
-2px -2px 6px rgba(0, 0, 0, 0.9);
}
.product-card.out-of-stock {
opacity: 0.75;
}
+54 -34
View File
@@ -7,7 +7,13 @@ import "./ProductCard.css";
function getTextColor(hex: string): string {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
@@ -22,10 +28,11 @@ interface ProductCardProps {
image: string;
stock: number;
category: string;
prices?: Array<{ quantity: number; price: number }>;
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
hasVideo?: boolean;
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
categoryColor?: string;
coming_soon?: boolean;
}
function ProductCard({
@@ -40,6 +47,7 @@ function ProductCard({
hasVideo = false,
videoUrl,
categoryColor,
coming_soon,
}: ProductCardProps) {
const navigate = useNavigate();
const { addToCart } = useCart();
@@ -52,6 +60,7 @@ function ProductCard({
const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo
const isOutOfStock = stock === 0;
const isComingSoon = coming_soon === true;
const normalizedCategory = (category || "autre").toLowerCase().trim();
const handleDetailsClick = (e: React.MouseEvent) => {
@@ -156,6 +165,9 @@ function ProductCard({
{isOutOfStock && (
<div className="sold-out-overlay">SOLD OUT</div>
)}
{isComingSoon && (
<div className="coming-soon-overlay">COMMING SOON</div>
)}
</div>
<div className="product-info">
@@ -177,18 +189,23 @@ function ProductCard({
) : (
<>
<button
className={`quick-add-btn ${isOutOfStock ? "disabled" : ""}`}
className={`quick-add-btn ${isOutOfStock || isComingSoon ? "disabled" : ""}`}
onClick={handleQuickAddClick}
disabled={isOutOfStock}
disabled={isOutOfStock || isComingSoon}
style={
categoryColor && !isOutOfStock
? { background: categoryColor, color: getTextColor(categoryColor) }
categoryColor && !isOutOfStock && !isComingSoon
? {
background: categoryColor,
color: getTextColor(categoryColor),
}
: undefined
}
>
{isOutOfStock
? "Rupture de stock"
: "Ajouter rapidement"}
: isComingSoon
? "BIENTÔT DISPONIBLE"
: "Ajouter rapidement"}
</button>
{showQuantitySelect && prices && prices.length > 0 && (
@@ -209,8 +226,9 @@ function ProductCard({
key={priceOption.quantity}
value={priceOption.quantity}
>
{priceOption.quantity}{unit} -{" "}
{priceOption.price.toFixed(2)}
{priceOption.quantity}
{unit} - {priceOption.price.toFixed(2)}{" "}
</option>
))}
</select>
@@ -220,32 +238,34 @@ function ProductCard({
</div>
{/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */}
{showVideo && videoUrl && createPortal(
<div className="video-modal" onClick={handleCloseVideo}>
<div
className="video-modal-content"
onClick={(e) => e.stopPropagation()}
>
<button
className="video-close-btn"
onClick={handleCloseVideo}
aria-label="Fermer la vidéo"
{showVideo &&
videoUrl &&
createPortal(
<div className="video-modal" onClick={handleCloseVideo}>
<div
className="video-modal-content"
onClick={(e) => e.stopPropagation()}
>
<X size={18} />
</button>
<video
src={videoUrl}
controls
autoPlay
className="video-player"
>
Votre navigateur ne supporte pas la lecture de
vidéos.
</video>
</div>
</div>,
document.body
)}
<button
className="video-close-btn"
onClick={handleCloseVideo}
aria-label="Fermer la vidéo"
>
<X size={18} />
</button>
<video
src={videoUrl}
controls
autoPlay
className="video-player"
>
Votre navigateur ne supporte pas la lecture de
vidéos.
</video>
</div>
</div>,
document.body,
)}
</div>
);
}
@@ -30,6 +30,7 @@ export interface CartItem {
quantity: number; // ✨ En GRAMMES (pas "combien de fois")
category: string;
image?: string;
is_reward?: boolean;
}
interface CartContextType {
@@ -129,6 +130,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
quantity: item.quantity as number,
category: category,
image: item.image as string | undefined,
is_reward: item.is_reward as boolean | undefined,
};
});
@@ -216,3 +216,109 @@
font-size: 0.85rem;
font-weight: 400 !important;
}
/* Modal feedback */
.cp-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.cp-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 2rem 1.75rem 1.75rem;
width: 100%;
max-width: 22rem;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
}
.cp-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
border-radius: 0.375rem;
transition: color 0.2s, background 0.2s;
}
.cp-modal-close:hover { color: var(--text); background: var(--bg); }
.cp-modal-icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.25rem;
}
.cp-modal-icon--success {
background: rgba(34, 197, 94, 0.12);
border: 2px solid rgba(34, 197, 94, 0.35);
color: #4ade80;
}
.cp-modal-icon--error {
background: rgba(239, 68, 68, 0.12);
border: 2px solid rgba(239, 68, 68, 0.35);
color: #f87171;
}
.cp-modal-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.cp-modal-body {
font-size: 0.9rem;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.cp-modal-btn {
margin-top: 0.5rem;
width: 100%;
padding: 0.7rem 1rem;
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
transition: opacity 0.2s;
}
.cp-modal-btn:hover { opacity: 0.88; }
.cp-modal-btn--success {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
color: #fff;
box-shadow: 0 4px 14px rgba(109, 40, 217, 0.4);
}
.cp-modal-btn--error {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Lock, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { Lock, Eye, EyeOff, ShieldCheck, CheckCircle, AlertTriangle, X } from "lucide-react";
import "./ChangePassword.css";
import { changePassword } from "../../api/api";
import { useNavigate } from "react-router-dom";
@@ -14,8 +14,9 @@ const ChangePasswordPage = () => {
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const validate = (): string | null => {
if (!currentPassword) return "Le mot de passe actuel est requis";
@@ -28,11 +29,11 @@ const ChangePasswordPage = () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError("");
const validationError = validate();
if (validationError) {
setError(validationError);
setErrorMsg(validationError);
setShowErrorModal(true);
return;
}
@@ -40,10 +41,10 @@ const ChangePasswordPage = () => {
try {
const result = await changePassword(currentPassword, newPassword);
if (result.success) {
setSuccess(true);
setTimeout(() => navigate("/user/accueil"), 1800);
setShowSuccessModal(true);
} else {
setError(result.message);
setErrorMsg(result.message || 'Erreur lors du changement de mot de passe.');
setShowErrorModal(true);
}
} finally {
setIsLoading(false);
@@ -51,6 +52,7 @@ const ChangePasswordPage = () => {
};
return (
<>
<div className="cp-container">
<div className="cp-content">
<div className="cp-header">
@@ -65,20 +67,7 @@ const ChangePasswordPage = () => {
</div>
<div className="cp-card">
{success ? (
<div className="cp-success">
<span className="cp-success-icon"></span>
<p>Mot de passe mis à jour avec succès !</p>
<p className="cp-success-sub">Redirection en cours</p>
</div>
) : (
<form className="cp-form" onSubmit={handleSubmit}>
{error && (
<div className="cp-error-banner">
{error}
</div>
)}
{/* Mot de passe actuel */}
<div className="cp-form-group">
<label className="cp-label">Mot de passe actuel</label>
@@ -87,7 +76,7 @@ const ChangePasswordPage = () => {
<input
type={showCurrent ? "text" : "password"}
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setError(""); }}
onChange={(e) => { setCurrentPassword(e.target.value); }}
className="cp-input"
placeholder="••••••••"
disabled={isLoading}
@@ -116,7 +105,7 @@ const ChangePasswordPage = () => {
<input
type={showNew ? "text" : "password"}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(""); }}
onChange={(e) => { setNewPassword(e.target.value); }}
className="cp-input"
placeholder="Minimum 8 caractères"
disabled={isLoading}
@@ -148,7 +137,7 @@ const ChangePasswordPage = () => {
<input
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(""); }}
onChange={(e) => { setConfirmPassword(e.target.value); }}
className={`cp-input ${confirmPassword && confirmPassword !== newPassword ? "cp-input-error" : ""}`}
placeholder="••••••••"
disabled={isLoading}
@@ -180,10 +169,45 @@ const ChangePasswordPage = () => {
{isLoading ? "Mise à jour…" : "Confirmer le nouveau mot de passe"}
</button>
</form>
)}
</div>
</div>
</div>
{/* Modal succès */}
{showSuccessModal && (
<div className="cp-modal-overlay" onClick={() => navigate('/user/accueil')}>
<div className="cp-modal" onClick={(e) => e.stopPropagation()}>
<div className="cp-modal-icon cp-modal-icon--success">
<CheckCircle size={28} />
</div>
<h3 className="cp-modal-title">Mot de passe mis à jour</h3>
<p className="cp-modal-body">Votre mot de passe a é changé avec succès.</p>
<button className="cp-modal-btn cp-modal-btn--success" onClick={() => navigate('/user/accueil')}>
<CheckCircle size={16} /> Continuer
</button>
</div>
</div>
)}
{/* Modal erreur */}
{showErrorModal && (
<div className="cp-modal-overlay" onClick={() => setShowErrorModal(false)}>
<div className="cp-modal" onClick={(e) => e.stopPropagation()}>
<button className="cp-modal-close" onClick={() => setShowErrorModal(false)}>
<X size={18} />
</button>
<div className="cp-modal-icon cp-modal-icon--error">
<AlertTriangle size={28} />
</div>
<h3 className="cp-modal-title">Une erreur est survenue</h3>
<p className="cp-modal-body">{errorMsg}</p>
<button className="cp-modal-btn cp-modal-btn--error" onClick={() => setShowErrorModal(false)}>
<X size={16} /> Fermer
</button>
</div>
</div>
)}
</>
);
};
+23 -1
View File
@@ -12,6 +12,28 @@
overflow-y: auto;
}
.login-theme-btn {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background-color: var(--surface);
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
}
.login-theme-btn:hover {
color: var(--text);
background-color: var(--surface-2);
}
.login-content {
width: 100%;
max-width: 28rem;
@@ -136,7 +158,7 @@
.error-message {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #f87171;
color: var(--red);
}
.error-banner {
+103 -18
View File
@@ -1,12 +1,14 @@
import { useState } from "react";
import { Lock, Mail, Eye, EyeOff, User } from "lucide-react";
import { Lock, Mail, Eye, EyeOff, User, Shield, Sun, Moon } from "lucide-react";
import "./Login.css";
import { loginUser, syncUsernameFromJWT } from "../../api/api";
import { loginUser, verify2FA, syncUsernameFromJWT } from "../../api/api";
import type { LoginRequest } from "../../api/api_types";
import { useNavigate } from "react-router-dom";
import { useTheme } from "../../context/ThemeContext";
const LoginClient = () => {
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const [formData, setFormData] = useState<LoginRequest>({
username: "",
@@ -21,6 +23,10 @@ const LoginClient = () => {
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>("");
const [twoFAStep, setTwoFAStep] = useState(false);
const [sessionToken, setSessionToken] = useState("");
const [twoFACode, setTwoFACode] = useState("");
/**
* Valider le formulaire
*/
@@ -79,30 +85,19 @@ const LoginClient = () => {
hasToken: !!result.access_token,
});
if (result.success && result.access_token) {
console.log("✅ [LOGIN] Connexion réussie!");
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser()
// Pas besoin de le faire ici
console.log("✅ [LOGIN] Token et username synchronisés");
// ✅ Vérifier la synchronisation
if (result.success && result.requires_2fa) {
setSessionToken(result.session_token || "");
setTwoFAStep(true);
} else if (result.success && result.access_token) {
const syncedUsername = syncUsernameFromJWT();
console.log("✅ [LOGIN] Username synchronisé:", syncedUsername);
// ✅ Redirection
if (result.user?.must_change_password) {
console.log("✅ [LOGIN] Première connexion - changement de mot de passe requis");
navigate("/user/change-password");
} else {
console.log("✅ [LOGIN] Redirection vers /user/accueil");
navigate("/user/accueil");
}
} else {
// ❌ Erreur API
const errorMessage =
result.message || "Identifiants incorrects";
console.error("❌ [LOGIN] Erreur API:", errorMessage);
const errorMessage = result.message || "Identifiants incorrects";
setApiError(errorMessage);
setErrors({ username: errorMessage });
}
@@ -117,6 +112,30 @@ const LoginClient = () => {
}
};
const handle2FASubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!twoFACode.trim()) return;
setIsLoading(true);
setApiError("");
try {
const result = await verify2FA(sessionToken, twoFACode.trim());
if (result.success && result.access_token) {
syncUsernameFromJWT();
if (result.user?.must_change_password) {
navigate("/user/change-password");
} else {
navigate("/user/accueil");
}
} else {
setApiError(result.message || "Code invalide");
}
} catch {
setApiError("Erreur de vérification");
} finally {
setIsLoading(false);
}
};
/**
* Gérer les changements d'input
*/
@@ -141,8 +160,74 @@ const LoginClient = () => {
}
};
if (twoFAStep) {
return (
<div className="login-container">
<button className="login-theme-btn" onClick={toggleTheme} aria-label="Changer le thème">
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Vérification 2FA</h1>
<p className="login-subtitle">
Entrez le code envoyé sur votre Telegram
</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handle2FASubmit}>
{apiError && (
<div className="error-banner"> {apiError}</div>
)}
<div className="form-group">
<label htmlFor="twoFACode" className="form-label">
Code de vérification
</label>
<div className="input-wrapper">
<input
type="text"
id="twoFACode"
value={twoFACode}
onChange={(e) => setTwoFACode(e.target.value)}
className="form-input"
placeholder="000000"
maxLength={6}
disabled={isLoading}
autoComplete="one-time-code"
style={{ letterSpacing: "0.3em", textAlign: "center", fontSize: "1.5rem" }}
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || twoFACode.length < 6}
className="submit-button"
style={{ opacity: isLoading ? 0.6 : 1, cursor: isLoading ? "not-allowed" : "pointer" }}
>
{isLoading ? "Vérification..." : "Confirmer"}
</button>
<button
type="button"
onClick={() => { setTwoFAStep(false); setTwoFACode(""); setApiError(""); }}
className="submit-button"
style={{ marginTop: "0.5rem", background: "transparent", border: "1px solid #555", color: "#aaa" }}
>
Retour
</button>
</form>
</div>
</div>
</div>
);
}
return (
<div className="login-container">
<button className="login-theme-btn" onClick={toggleTheme} aria-label="Changer le thème">
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="login-content">
<div className="login-header">
<div className="login-logo">
+5 -5
View File
@@ -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>
))}
+16 -2
View File
@@ -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);
+36 -14
View File
@@ -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<
+17 -16
View File
@@ -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;
+70 -19
View File
@@ -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;
}
+496 -415
View File
@@ -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 é 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>
)}
</>
);
}
+18 -34
View File
@@ -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">