chore: update

This commit is contained in:
2026-03-19 19:56:51 +01:00
parent 3384ebded0
commit 7173266bb9
31 changed files with 3645 additions and 1942 deletions
+80 -49
View File
@@ -1,10 +1,24 @@
import { useState, useEffect } from "react";
import ProductCard from "../../components/ProductCard";
import Navbar from "../../components/Navbar";
import { getAllProducts, getProductsByCategory, getMediaUrl, getCategories } from "../../api/api";
import {
getAllProducts,
getProductsByCategory,
getMediaUrl,
getCategories,
} from "../../api/api";
import type { Product, Category } from "../../api/api";
import "./UserAccueil.css";
function getTextColor(hex: string): string {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
}
function UserAccueil() {
const [selectedCategory, setSelectedCategory] = useState<string>("tous");
const [categories, setCategories] = useState<Category[]>([]);
@@ -42,10 +56,7 @@ function UserAccueil() {
if (response.success && response.data) {
setProducts(response.data);
} else {
setError(
response.message ||
"Erreur lors du chargement des produits",
);
setProducts([]);
}
} catch (err: any) {
setError(err.message || "Erreur lors du chargement des produits");
@@ -104,13 +115,29 @@ function UserAccueil() {
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
};
const selectedCategoryObj = categories.find((c) => c.name === selectedCategory);
const selectedCategoryObj = categories.find(
(c) => c.name === selectedCategory,
);
const isSelectedComingSoon = selectedCategoryObj?.is_coming_soon ?? false;
<div className="category-header">
<h2 className="category-title">
{selectedCategory === "tous"
? "Tous les produits"
: selectedCategory}
</h2>
</div>;
return (
<>
<Navbar />
<div className="user-page-container">
<div className="category-header">
<h2 className="category-title">
{selectedCategory === "tous"
? "Tous les produits"
: selectedCategory}
</h2>
</div>
<div className="category-filter">
<button
className={`category-button ${selectedCategory === "tous" ? "active" : ""}`}
@@ -119,6 +146,7 @@ function UserAccueil() {
>
Tous
</button>
{categories.map((category) => {
const isActive = selectedCategory === category.name;
const catColor = category.color || "#7c3aed";
@@ -131,11 +159,13 @@ function UserAccueil() {
? {
backgroundColor: catColor,
borderColor: catColor,
color: "#ffffff",
color: getTextColor(catColor),
}
: { borderColor: `${catColor}66` }
}
onClick={() => handleCategoryChange(category.name)}
onClick={() =>
handleCategoryChange(category.name)
}
>
{category.name}
</button>
@@ -143,12 +173,6 @@ function UserAccueil() {
})}
</div>
<div className="category-header">
<h2 className="category-title">
{selectedCategory === "tous" ? "Tous les produits" : selectedCategory}
</h2>
</div>
{isSelectedComingSoon ? (
<div className="coming-soon-overlay">
<span className="coming-soon-text">Prochainement</span>
@@ -156,43 +180,50 @@ function UserAccueil() {
Les produits de cette catégorie arrivent bientôt !
</p>
</div>
) : loading ? (
<div className="loading-container">
<p>Chargement des produits...</p>
</div>
) : error ? (
<div className="error-container">
<p className="error-message">{error}</p>
<button onClick={loadProducts}>Réessayer</button>
</div>
) : products.length === 0 ? (
<div className="empty-container">
<p>
Il n'y a pas de produit disponible pour l'instant.
</p>
</div>
) : (
<>
{loading && (
<div className="loading-container">
<p>Chargement des produits...</p>
<div className="products-grid">
{products.map((product) => (
<div
key={product.id}
data-category={product.category}
>
<ProductCard
id={product.id}
name={product.name}
price={getProductPrice(product)}
unit={product.unit || "g"}
image={getProductImage(product)}
stock={product.stock}
category={product.category}
prices={product.prices}
hasVideo={hasProductVideo(product)}
videoUrl={getProductVideoUrl(product)}
categoryColor={
categories.find(
(c) =>
c.name.toLowerCase() ===
product.category?.toLowerCase(),
)?.color
}
/>
</div>
)}
{!loading && !error && products.length > 0 && (
<div className="products-grid">
{products.map((product) => (
<div
key={product.id}
data-category={product.category}
>
<ProductCard
id={product.id}
name={product.name}
price={getProductPrice(product)}
unit={product.unit || "g"}
image={getProductImage(product)}
stock={product.stock}
category={product.category}
prices={product.prices}
hasVideo={hasProductVideo(product)}
videoUrl={getProductVideoUrl(product)}
categoryColor={
categories.find(
(c) => c.name.toLowerCase() === product.category?.toLowerCase(),
)?.color
}
/>
</div>
))}
</div>
)}
</>
))}
</div>
)}
</div>
</>
+1
View File
@@ -19,6 +19,7 @@
margin-bottom: clamp(1.5rem, 4vw, 2rem);
flex-wrap: wrap;
gap: 1rem;
margin-top: -65px;
}
.cart-header h1 {
+1 -1
View File
@@ -7,7 +7,7 @@
// ✅ Pour acheter 2× le même produit, l'ajouter 2 fois
// ✅ Vérification continue de l'authentification
import { useCart } from "../../context/CartContext";
import { useCart } from "../../context/useCart";
import Navbar from "../../components/Navbar";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+181 -9
View File
@@ -1,3 +1,10 @@
.form-prefill-hint {
font-size: 0.75rem;
color: #6ee7b7;
margin: 0.3rem 0 0;
opacity: 0.85;
}
.checkout-container {
width: 100%;
min-height: 100vh;
@@ -327,11 +334,11 @@
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding: 1rem;
padding: 2rem 1rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@@ -351,8 +358,6 @@
border-radius: 20px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 70px rgba(91, 33, 182, 0.5), 0 0 120px rgba(91, 33, 182, 0.3);
animation: slideUp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
@@ -639,13 +644,12 @@
/* Modal responsive compacte */
.confirmation-modal-overlay {
padding: 0.75rem;
align-items: center;
padding: 1rem 0.75rem;
align-items: flex-start;
}
.confirmation-modal {
max-width: 100%;
max-height: 80vh;
border-radius: 12px;
border-width: 1px;
}
@@ -727,11 +731,10 @@
/* Modal encore plus compacte sur mobile */
.confirmation-modal-overlay {
padding: 0.5rem;
padding: 0.75rem 0.5rem;
}
.confirmation-modal {
max-height: 85vh;
border-radius: 10px;
}
@@ -972,3 +975,172 @@
.referral-switch input:checked + .referral-switch-slider::before {
transform: translateX(22px);
}
/* ============================================ */
/* PAIEMENT - Sélecteur de méthode */
/* ============================================ */
.payment-method-selector {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.payment-method-btn {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #2d2d2d;
border-radius: 10px;
background: #1a1a1a;
color: #9ca3af;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.payment-method-btn:hover {
border-color: #4b5563;
color: #fff;
}
.payment-method-btn.active {
border-color: #f7931a;
color: #f7931a;
background: rgba(247, 147, 26, 0.08);
}
/* ============================================ */
/* CRYPTO - Sélecteur de monnaie */
/* ============================================ */
.crypto-currency-selector {
margin-top: 1rem;
}
.crypto-currency-selector label {
display: block;
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 0.5rem;
}
.crypto-currency-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.crypto-currency-btn {
padding: 0.4rem 0.9rem;
border: 2px solid #2d2d2d;
border-radius: 8px;
background: #1a1a1a;
color: #9ca3af;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.05em;
}
.crypto-currency-btn:hover {
border-color: #4b5563;
color: #fff;
}
.crypto-currency-btn.active {
border-color: #f7931a;
color: #f7931a;
background: rgba(247, 147, 26, 0.08);
}
.crypto-info-hint {
font-size: 0.78rem;
color: #6b7280;
margin-top: 0.6rem;
}
/* ============================================ */
/* CRYPTO - Modal de paiement */
/* ============================================ */
.crypto-address-box {
display: flex;
align-items: center;
gap: 0.75rem;
background: #111;
border: 1px solid #2d2d2d;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.crypto-address {
font-family: monospace;
font-size: 0.78rem;
color: #d1d5db;
word-break: break-all;
flex: 1;
}
.crypto-copy-btn {
padding: 0.35rem 0.75rem;
border: 1px solid #374151;
border-radius: 6px;
background: #1f2937;
color: #9ca3af;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.crypto-copy-btn:hover {
background: #374151;
color: #fff;
}
.crypto-equiv {
color: #6b7280;
font-size: 0.85rem;
margin-left: 0.5rem;
}
.crypto-status--waiting { color: #fbbf24; }
.crypto-status--confirming { color: #60a5fa; }
.crypto-status--confirmed,
.crypto-status--finished { color: #34d399; }
.crypto-status--failed,
.crypto-status--expired { color: #f87171; }
.crypto-polling-info {
text-align: center;
color: #6b7280;
font-size: 0.82rem;
margin-top: 0.5rem;
}
.crypto-success-msg {
text-align: center;
color: #34d399;
font-size: 0.9rem;
padding: 0.75rem;
background: rgba(52, 211, 153, 0.08);
border-radius: 8px;
margin-top: 0.5rem;
}
.crypto-only-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.3);
border-radius: 10px;
color: #f7931a;
font-size: 0.9rem;
font-weight: 500;
}
+285 -7
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCart } from '../../context/CartContext';
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings } from '../../api/api';
import type { CheckoutData } from '../../api/api';
import { useCart } from '../../context/useCart';
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings, getMyProfile, getCryptoPaymentStatus } from '../../api/api';
import type { CheckoutData, CryptoPaymentStatus } from '../../api/api';
import Navbar from '../../components/Navbar';
import './Checkout.css';
@@ -42,6 +42,10 @@ function Checkout() {
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationData, setConfirmationData] = useState<ConfirmationData | null>(null);
// État pour le modal zone non desservie
const [showZoneModal, setShowZoneModal] = useState(false);
const [zoneErrorMsg, setZoneErrorMsg] = useState('');
// Informations personnelles
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
@@ -55,6 +59,17 @@ function Checkout() {
const [referralEnabled, setReferralEnabled] = useState(false);
const [useReferral, setUseReferral] = useState(false);
// Crypto
const [cryptoEnabled, setCryptoEnabled] = useState(false);
const [cryptoOnly, setCryptoOnly] = useState(false);
const [cryptoCurrencies, setCryptoCurrencies] = useState<string[]>([]);
const [paymentMethod, setPaymentMethod] = useState<'especes' | 'crypto'>('especes');
const [payCurrency, setPayCurrency] = useState('');
const [cryptoPaymentData, setCryptoPaymentData] = useState<CryptoPaymentStatus | null>(null);
const [showCryptoModal, setShowCryptoModal] = useState(false);
const [cryptoPolling, setCryptoPolling] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
@@ -79,7 +94,22 @@ function Checkout() {
return () => clearInterval(authInterval);
}, [navigate]);
// Charger solde parrainage si activé
// Charger les infos par défaut depuis le profil
useEffect(() => {
const savedAddress = localStorage.getItem('profile_default_address');
const savedPhone = localStorage.getItem('profile_default_phone');
if (savedAddress) setAddress(savedAddress);
if (savedPhone) setPhone(savedPhone);
// Pré-remplir nom depuis le backend
getMyProfile().then((res) => {
if (res.success && res.client) {
if (res.client.nom) setLastName(res.client.nom);
}
});
}, []);
// Charger settings publics (parrainage + crypto)
useEffect(() => {
getPublicSettings().then((settings) => {
if (settings.referral_enabled) {
@@ -88,9 +118,25 @@ function Checkout() {
if (res.success) setReferralBalance(res.balance);
});
}
if (settings.crypto_payment_enabled && settings.nowpayments_currencies.length > 0) {
setCryptoEnabled(true);
setCryptoCurrencies(settings.nowpayments_currencies);
setPayCurrency(settings.nowpayments_currencies[0]);
if (settings.crypto_only) {
setCryptoOnly(true);
setPaymentMethod('crypto');
}
}
});
}, []);
// Nettoyage du polling au démontage
useEffect(() => {
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, []);
/**
* ✅ Récupérer le username du JWT
*/
@@ -135,6 +181,39 @@ function Checkout() {
});
};
const startCryptoPolling = (commandId: number) => {
setCryptoPolling(true);
pollIntervalRef.current = setInterval(async () => {
const status = await getCryptoPaymentStatus(commandId);
if (!status) return;
setCryptoPaymentData(status);
if (status.payment_status === 'finished' || status.payment_status === 'confirmed') {
stopCryptoPolling();
const username = extractUsernameFromToken();
if (username) {
await clearCart(username);
await clearCartContext();
}
} else if (status.payment_status === 'failed' || status.payment_status === 'expired') {
stopCryptoPolling();
}
}, 10000);
};
const stopCryptoPolling = () => {
setCryptoPolling(false);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
const handleCryptoModalClose = () => {
stopCryptoPolling();
setShowCryptoModal(false);
navigate('/user/suivi-livraison');
};
/**
* ✅ Gérer la soumission de la commande
*/
@@ -174,7 +253,8 @@ function Checkout() {
first_name: firstName,
last_name: lastName,
phone,
payment_method: 'especes',
payment_method: paymentMethod === 'crypto' ? 'crypto' : 'especes',
pay_currency: paymentMethod === 'crypto' ? payCurrency : undefined,
use_referral_balance: useReferral && referralBalance > 0,
};
@@ -183,6 +263,28 @@ function Checkout() {
const response = await createCheckout(checkoutData);
console.log('📥 Réponse checkout:', response);
// Paiement crypto : afficher le modal avec l'adresse wallet
if (response.success && response.payment_method === 'crypto') {
setCryptoPaymentData({
command_id: response.command_id!,
payment_status: response.payment_status!,
pay_address: response.pay_address!,
pay_amount: response.pay_amount!,
pay_currency: response.pay_currency!,
price_amount: response.price_amount!,
price_currency: response.price_currency!,
});
setShowCryptoModal(true);
startCryptoPolling(response.command_id);
return;
}
if (!response.success && response.zone_error) {
setZoneErrorMsg(response.message);
setShowZoneModal(true);
return;
}
if (response.success && response.command_id) {
const { command_id, assigned_to, queue_info, delivery_address } = response;
@@ -337,6 +439,9 @@ function Checkout() {
disabled={loading}
required
/>
{localStorage.getItem('profile_default_address') && (
<p className="form-prefill-hint"><i className="fas fa-map-marker-alt" /> Pré-rempli depuis votre profil modifiez si vous êtes ailleurs</p>
)}
</div>
<div className="form-group">
@@ -350,14 +455,71 @@ function Checkout() {
disabled={loading}
required
/>
{localStorage.getItem('profile_default_phone') && (
<p className="form-prefill-hint"><i className="fas fa-phone" /> Pré-rempli depuis votre profil</p>
)}
</div>
</div>
{/* Méthode de paiement */}
{cryptoEnabled && (
<div className="form-section">
<h3>Méthode de paiement</h3>
{cryptoOnly ? (
<div className="crypto-only-badge">
<i className="fas fa-coins" /> Paiement uniquement en cryptomonnaie
</div>
) : (
<div className="payment-method-selector">
<button
type="button"
className={`payment-method-btn ${paymentMethod === 'especes' ? 'active' : ''}`}
onClick={() => setPaymentMethod('especes')}
disabled={loading}
>
<i className="fas fa-money-bill-wave" /> Espèces
</button>
<button
type="button"
className={`payment-method-btn ${paymentMethod === 'crypto' ? 'active' : ''}`}
onClick={() => setPaymentMethod('crypto')}
disabled={loading}
>
<i className="fas fa-coins" /> Crypto
</button>
</div>
)}
{(paymentMethod === 'crypto') && (
<div className="crypto-currency-selector">
<label>Cryptomonnaie</label>
<div className="crypto-currency-grid">
{cryptoCurrencies.map((currency) => (
<button
key={currency}
type="button"
className={`crypto-currency-btn ${payCurrency === currency ? 'active' : ''}`}
onClick={() => setPayCurrency(currency)}
disabled={loading}
>
{currency.toUpperCase()}
</button>
))}
</div>
<p className="crypto-info-hint">
<i className="fas fa-info-circle" /> Vous recevrez l'adresse de paiement après validation
</p>
</div>
)}
</div>
)}
{/* Toggle parrainage */}
{referralEnabled && referralBalance > 0 && (
<div className="referral-toggle-box">
<div className="referral-toggle-info">
<span className="referral-toggle-icon">🎁</span>
<span className="referral-toggle-icon"><i className="fas fa-gift"></i></span>
<div>
<p className="referral-toggle-label">Solde parrainage</p>
<p className="referral-toggle-balance">{referralBalance.toFixed(2)} € disponible</p>
@@ -397,6 +559,111 @@ function Checkout() {
</div>
</div>
{/* ============================================ */}
{/* MODAL PAIEMENT CRYPTO */}
{/* ============================================ */}
{showCryptoModal && cryptoPaymentData && (
<div className="confirmation-modal-overlay">
<div className="confirmation-modal">
<div className="confirmation-modal-header" style={{ background: 'linear-gradient(135deg, #f7931a, #c2620a)' }}>
<i className="fas fa-coins confirmation-icon" />
<h2>Paiement Crypto</h2>
</div>
<div className="confirmation-modal-body">
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-receipt icon" /> Commande #{cryptoPaymentData.command_id}
</div>
<div className="confirmation-detail">
<strong>Statut :</strong>{' '}
<span className={`crypto-status crypto-status--${cryptoPaymentData.payment_status}`}>
{cryptoPaymentData.payment_status === 'waiting' && ' En attente de paiement'}
{cryptoPaymentData.payment_status === 'confirming' && '🔄 Confirmation en cours...'}
{cryptoPaymentData.payment_status === 'confirmed' && ' Confirmé'}
{cryptoPaymentData.payment_status === 'finished' && ' Paiement reçu !'}
{cryptoPaymentData.payment_status === 'failed' && ' Paiement échoué'}
{cryptoPaymentData.payment_status === 'expired' && ' Expiré'}
{!['waiting','confirming','confirmed','finished','failed','expired'].includes(cryptoPaymentData.payment_status) && cryptoPaymentData.payment_status}
</span>
</div>
</div>
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-wallet icon" /> Adresse de paiement
</div>
<div className="crypto-address-box">
<code className="crypto-address">{cryptoPaymentData.pay_address}</code>
<button
type="button"
className="crypto-copy-btn"
onClick={() => navigator.clipboard.writeText(cryptoPaymentData.pay_address)}
>
<i className="fas fa-copy" /> Copier
</button>
</div>
</div>
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-coins icon" /> Montant à envoyer
</div>
<div className="confirmation-detail">
<strong>{cryptoPaymentData.pay_amount} {cryptoPaymentData.pay_currency.toUpperCase()}</strong>
<span className="crypto-equiv"> ≈ {cryptoPaymentData.price_amount.toFixed(2)} {cryptoPaymentData.price_currency.toUpperCase()}</span>
</div>
</div>
{cryptoPolling && (
<div className="crypto-polling-info">
<i className="fas fa-spinner fa-spin" /> Vérification automatique toutes les 10 secondes...
</div>
)}
{(cryptoPaymentData.payment_status === 'finished' || cryptoPaymentData.payment_status === 'confirmed') && (
<div className="crypto-success-msg">
<i className="fas fa-check-circle" /> Paiement confirmé ! Votre commande est en cours de traitement.
</div>
)}
</div>
<div className="confirmation-modal-actions">
<button className="confirmation-button" style={{ background: '#374151' }} onClick={handleCryptoModalClose}>
<i className="fas fa-location-arrow" /> Suivre ma commande
</button>
</div>
</div>
</div>
)}
{/* ============================================ */}
{/* MODAL ZONE NON DESSERVIE */}
{/* ============================================ */}
{showZoneModal && (
<div className="confirmation-modal-overlay" onClick={() => setShowZoneModal(false)}>
<div className="confirmation-modal" onClick={(e) => e.stopPropagation()}>
<div className="confirmation-modal-header" style={{ background: 'linear-gradient(135deg, #dc2626, #991b1b)' }}>
<i className="fas fa-map-marker-alt confirmation-icon"></i>
<h2>Zone non desservie</h2>
</div>
<div className="confirmation-modal-body">
<div className="confirmation-section">
<div className="confirmation-detail" style={{ textAlign: 'center', padding: '1rem 0' }}>
<p style={{ fontSize: '1rem', marginBottom: '0.75rem' }}>{zoneErrorMsg}</p>
<p style={{ color: '#9ca3af', fontSize: '0.875rem' }}>
Vérifiez l'adresse saisie ou contactez-nous pour connaître les zones de livraison disponibles.
</p>
</div>
</div>
</div>
<div className="confirmation-modal-actions">
<button className="confirmation-button" style={{ background: '#374151' }} onClick={() => setShowZoneModal(false)}>
<i className="fas fa-arrow-left"></i> Modifier l'adresse
</button>
</div>
</div>
</div>
)}
{/* ============================================ */}
{/* MODAL DE CONFIRMATION STYLISÉ */}
{/* ============================================ */}
@@ -463,6 +730,17 @@ function Checkout() {
</div>
)}
{/* Suivi */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-map-marker-alt icon"></i>
Suivi de livraison
</div>
<div className="confirmation-detail">
Suivez votre livraison en temps réel depuis la page Suivi
</div>
</div>
{/* Total */}
<div className="confirmation-total">
<div className="confirmation-total-label">
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,10 @@ import { Package, MapPin, User, TrendingUp } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCannabis,
faWind,
faPills,
faFlask,
faMortarPestle,
faStar,
faTrophy,
faExclamationTriangle,
faCheckCircle,
@@ -38,7 +41,7 @@ function ConsultationHistorique() {
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 });
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true, 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>('');
@@ -98,7 +101,6 @@ function ConsultationHistorique() {
console.log('🔍 [DEBUG] result.client_stats:', result.client_stats);
console.log('🔍 [DEBUG] points:', result.client_stats?.points);
console.log('🔍 [DEBUG] points_zipette:', result.client_stats?.points_zipette);
setOrders(result.commands);
setClientStats(result.client_stats || null);
@@ -190,36 +192,48 @@ function ConsultationHistorique() {
</div>
{/* Cartes points - affichées uniquement si le système de points est activé */}
{appSettings.points_enabled && (
appSettings.points_separated ? (
{appSettings.points_enabled && (() => {
const poolNames = clientStats.pool_names?.length ? clientStats.pool_names : appSettings.pool_names;
const poolPoints = clientStats.pool_points ?? [clientStats.points];
const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar];
const poolClasses = poolNames.map((_, i) => `points-pool-${i}`);
const poolIconClasses = poolNames.map((_, i) => `icon-pool-${i}`);
if (poolNames.length <= 1) {
return (
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Points {poolNames[0] ?? 'Points'}
</p>
<p className="stat-value">{poolPoints[0] || 0}</p>
</div>
</div>
);
}
const total = poolPoints.reduce((s, v) => s + (v || 0), 0);
return (
<>
<div className="stat-card2 points-weed">
<div className="stat-icon icon-weed">
<FontAwesomeIcon icon={faCannabis} size="lg" />
{poolNames.map((name, i) => (
<div key={i} className={`stat-card2 ${poolClasses[i] ?? 'points-extra'}`}>
<div className={`stat-icon ${poolIconClasses[i] ?? 'icon-total'}`}>
<FontAwesomeIcon icon={poolIcons[i] ?? faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={poolIcons[i] ?? faTrophy} style={{ marginRight: '0.5rem' }} />
Points {name}
</p>
<p className="stat-value">{poolPoints[i] || 0}</p>
</div>
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faCannabis} style={{ marginRight: '0.5rem' }} />
Points Weed/Hash
</p>
<p className="stat-value">{clientStats.points || 0}</p>
</div>
</div>
<div className="stat-card2 points-zipette">
<div className="stat-icon icon-zipette">
<FontAwesomeIcon icon={faWind} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faWind} style={{ marginRight: '0.5rem' }} />
Points Zipette
</p>
<p className="stat-value">{clientStats.points_zipette || 0}</p>
</div>
</div>
{(clientStats.points || 0) > 0 && (clientStats.points_zipette || 0) > 0 && (
))}
{total > 0 && poolNames.length > 1 && (
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
@@ -229,28 +243,13 @@ function ConsultationHistorique() {
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Total Points
</p>
<p className="stat-value">
{(clientStats.points || 0) + (clientStats.points_zipette || 0)}
</p>
<p className="stat-value">{total}</p>
</div>
</div>
)}
</>
) : (
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Points
</p>
<p className="stat-value">{clientStats.points || 0}</p>
</div>
</div>
)
)}
);
})()}
{/* Carte 5: Commandes Livrées */}
<div className="stat-card2 completed-orders">
+18 -12
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import { getReferralBalance, isUserAuthenticated } from '../../api/api';
import { getReferralBalance, isUserAuthenticated, getPublicSettings } from '../../api/api';
import './Parrainage.css';
const TELEGRAM_URL = 'https://t.me/';
@@ -11,25 +11,25 @@ const steps = [
num: 1,
title: 'Parrainez un ami',
desc: 'Recommandez nos services à un proche. Il doit nous contacter directement sur Telegram pour s\'inscrire.',
icon: '👥',
icon: 'fa-users',
},
{
num: 2,
title: 'Il passe sa 1ère commande',
desc: 'Une fois votre ami inscrit et sa première commande validée, signalez-le nous sur Telegram.',
icon: '',
icon: 'fa-circle-check',
},
{
num: 3,
title: 'Nous créditons votre compte',
desc: 'L\'admin vérifie et crédite manuellement votre solde de parrainage. Vous êtes notifié dès que c\'est fait.',
icon: '💰',
icon: 'fa-coins',
},
{
num: 4,
title: 'Utilisez votre solde',
desc: 'Au moment du checkout, choisissez d\'utiliser votre solde ou de le cumuler pour une prochaine commande.',
icon: '🛒',
icon: 'fa-cart-shopping',
},
];
@@ -43,9 +43,15 @@ export default function Parrainage() {
navigate('/login/client', { replace: true });
return;
}
getReferralBalance().then((res) => {
if (res.success) setBalance(res.balance);
setLoading(false);
getPublicSettings().then((s) => {
if (!s.referral_enabled) {
navigate('/user/accueil', { replace: true });
return;
}
getReferralBalance().then((res) => {
if (res.success) setBalance(res.balance);
setLoading(false);
});
});
}, [navigate]);
@@ -55,7 +61,7 @@ export default function Parrainage() {
<div className="parrainage-container">
{/* En-tête */}
<div className="parrainage-hero">
<div className="parrainage-hero-icon">🎁</div>
<div className="parrainage-hero-icon"><i className="fas fa-gift"></i></div>
<h1 className="parrainage-title">Programme de Parrainage</h1>
<p className="parrainage-subtitle">
Parrainez vos amis et cumulez du crédit sur votre compte
@@ -85,7 +91,7 @@ export default function Parrainage() {
<div className="parrainage-steps">
{steps.map((step) => (
<div key={step.num} className="step-card">
<div className="step-icon">{step.icon}</div>
<div className="step-icon"><i className={`fas ${step.icon}`}></i></div>
<div className="step-num">Étape {step.num}</div>
<h3 className="step-title">{step.title}</h3>
<p className="step-desc">{step.desc}</p>
@@ -96,7 +102,7 @@ export default function Parrainage() {
{/* Règle zone minimum */}
<div className="parrainage-warning-card">
<div className="warning-icon"></div>
<div className="warning-icon"><i className="fas fa-triangle-exclamation"></i></div>
<div className="warning-content">
<h3 className="warning-title">Règle du minimum de zone</h3>
<p className="warning-text">
@@ -127,7 +133,7 @@ export default function Parrainage() {
rel="noopener noreferrer"
className="telegram-btn"
>
<span className="telegram-icon"></span>
<i className="fab fa-telegram telegram-icon"></i>
Contacter sur Telegram
</a>
</div>
@@ -291,7 +291,7 @@
.grams-dropdown option:checked {
background-color: var(--cat-color, #7c3aed);
background: var(--cat-color, #7c3aed);
color: white;
color: var(--cat-text-color, white);
}
.grams-dropdown option:focus {
@@ -344,7 +344,7 @@
.add-to-cart-button {
width: 100%;
background: var(--cat-color, #7c3aed);
color: white;
color: var(--cat-text-color, white);
border: none;
border-radius: 12px;
padding: clamp(1.2rem, 3vw, 1.5rem);
@@ -2,7 +2,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { getProductById, getCategories, isUserAuthenticated } from "../../api/api";
import type { Product } from "../../api/api";
import { useCart } from "../../context/CartContext";
import { useCart } from "../../context/useCart";
import Navbar from "../../components/Navbar";
import Toast from "../../components/Toast";
import "./ProductDetail.css";
@@ -208,6 +208,13 @@ function ProductDetail() {
};
const catColorRgb = hexToRgb(catColor);
// Texte contrasté (noir sur fond clair, blanc sur fond foncé)
const h = catColor.replace("#", "");
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";
return (
<>
<Navbar />
@@ -224,7 +231,7 @@ function ProductDetail() {
<div
className="product-detail-container"
style={{ "--cat-color": catColor, "--cat-color-rgb": catColorRgb } 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
@@ -0,0 +1,158 @@
.profile-container {
max-width: 640px;
margin: 0 auto;
padding: 2rem 1rem 4rem;
}
.profile-loading {
display: flex;
justify-content: center;
padding: 4rem;
}
.profile-header {
display: flex;
align-items: center;
gap: 1.2rem;
margin-bottom: 2rem;
}
.profile-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
color: #fff;
flex-shrink: 0;
}
.profile-title {
font-size: 1.6rem;
font-weight: 700;
color: #f0f0f0;
margin: 0;
}
.profile-username {
font-size: 0.9rem;
color: #9ca3af;
margin: 0.2rem 0 0;
}
/* Alerts */
.profile-alert {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 1.2rem;
}
.profile-alert--success {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.4);
color: #6ee7b7;
}
.profile-alert--error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
/* Cards */
.profile-card {
background: #1e1e2e;
border: 1px solid #2d2d40;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.2rem;
}
.profile-card-title {
font-size: 1rem;
font-weight: 600;
color: #e2e8f0;
margin: 0 0 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.profile-card-icon { color: #7c3aed; }
.profile-card-icon--address { color: #10b981; }
.profile-card-icon--phone { color: #3b82f6; }
.profile-hint {
font-size: 0.82rem;
color: #6b7280;
margin: -0.5rem 0 1rem;
line-height: 1.5;
}
/* Fields */
.profile-fields { display: flex; flex-direction: column; gap: 0.9rem; }
.profile-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
.profile-group { display: flex; flex-direction: column; gap: 0.35rem; }
.profile-group label {
font-size: 0.82rem;
font-weight: 500;
color: #9ca3af;
}
.profile-group input {
background: #12121f;
border: 1px solid #2d2d40;
border-radius: 8px;
padding: 0.65rem 0.9rem;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
width: 100%;
box-sizing: border-box;
}
.profile-group input:focus { border-color: #7c3aed; }
.profile-group input::placeholder { color: #4b5563; }
/* Buttons */
.profile-btn {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.2rem;
padding: 0.65rem 1.3rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
border: none;
background: #7c3aed;
color: #fff;
}
.profile-btn--secondary {
background: #1a3a5c;
color: #60a5fa;
border: 1px solid #2563eb44;
}
.profile-btn:hover { opacity: 0.85; }
.profile-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 480px) {
.profile-row { grid-template-columns: 1fr; }
}
@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import { isUserAuthenticated, extractUsernameFromToken, getMyProfile, updateMyProfile } from '../../api/api';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faUser, faMapMarkerAlt, faPhone, faCommentDots,
faSave, faCheckCircle, faExclamationTriangle,
} 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';
export default function ProfilePage() {
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 locales (localStorage)
const [defaultAddress, setDefaultAddress] = useState('');
const [defaultPhone, setDefaultPhone] = useState('');
const [signalPseudo, setSignalPseudo] = useState('');
// Feedback
const [savingContact, setSavingContact] = useState(false);
const [successMsg, setSuccessMsg] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const username = extractUsernameFromToken() ?? '';
useEffect(() => {
if (!isUserAuthenticated()) {
navigate('/login/client', { replace: true });
return;
}
// Charger depuis localStorage
setDefaultAddress(localStorage.getItem(STORAGE_ADDRESS) ?? '');
setDefaultPhone(localStorage.getItem(STORAGE_PHONE) ?? '');
setSignalPseudo(localStorage.getItem(STORAGE_SIGNAL) || username);
// 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 = (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());
showSuccess('Informations par défaut enregistrées');
};
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>
</>
);
}
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>
</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={saveLocal}>
<FontAwesomeIcon icon={faSave} /> Enregistrer les infos par défaut
</button>
</div>
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -31,7 +31,7 @@
.coming-soon-text {
font-family: "Reach fill & Outline", sans-serif;
font-size: clamp(1.5rem, 8vw, 4rem);
font-size: clamp(1.5rem, 8vw, 1rem);
font-weight: 400;
color: #8e8fe8;
letter-spacing: 4px;
@@ -102,6 +102,7 @@
/* ===== CATEGORY HEADER ===== */
.category-header {
margin-top: -65px;
margin-bottom: clamp(2rem, 5vw, 3rem);
animation: headerFadeIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@@ -232,7 +233,6 @@
background-color: white;
color: black;
}
}
.loading-container,