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