chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s

This commit is contained in:
Xor290
2026-08-06 12:06:05 +02:00
parent c034088bee
commit 22a8d5026c
174 changed files with 30315 additions and 16120 deletions
+18 -27
View File
@@ -27,7 +27,13 @@ function UserAccueil() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getCategories().then(setCategories);
getCategories().then((cats) => {
const sorted = [...cats].sort((a, b) => {
if (a.is_coming_soon === b.is_coming_soon) return 0;
return a.is_coming_soon ? 1 : -1;
});
setCategories(sorted);
});
}, []);
useEffect(() => {
@@ -38,15 +44,11 @@ function UserAccueil() {
return;
}
loadProducts();
const interval = setInterval(() => loadProducts(true), 30_000);
return () => clearInterval(interval);
}, [selectedCategory, categories]); // eslint-disable-line react-hooks/exhaustive-deps
const loadProducts = async (silent = false) => {
if (!silent) {
setLoading(true);
setError(null);
}
const loadProducts = async () => {
setLoading(true);
setError(null);
try {
let response;
@@ -58,25 +60,14 @@ function UserAccueil() {
}
if (response.success && response.data) {
if (silent) {
setProducts((prev) =>
prev.map((p) => {
const updated = response.data!.find((r: Product) => r.id === p.id);
return updated ? { ...p, stock: updated.stock } : p;
})
);
} else {
setProducts(response.data);
}
} else if (!silent) {
setProducts(response.data);
} else {
setProducts([]);
}
} catch (err: unknown) {
if (!silent) {
setError(err instanceof Error ? err.message : "Erreur lors du chargement des produits");
}
setError(err instanceof Error ? err.message : "Erreur lors du chargement des produits");
} finally {
if (!silent) setLoading(false);
setLoading(false);
}
};
@@ -188,9 +179,9 @@ function UserAccueil() {
</div>
{isSelectedComingSoon ? (
<div className="coming-soon-overlay">
<span className="coming-soon-text">Prochainement</span>
<p className="coming-soon-desc">
<div className="category-cs-overlay">
<span className="category-cs-text">Prochainement</span>
<p className="category-cs-desc">
Les produits de cette catégorie arrivent bientôt !
</p>
</div>
@@ -201,7 +192,7 @@ function UserAccueil() {
) : error ? (
<div className="error-container">
<p className="error-message">{error}</p>
<button onClick={() => loadProducts()}>Réessayer</button>
<button onClick={loadProducts}>Réessayer</button>
</div>
) : products.length === 0 ? (
<div className="empty-container">
+52 -17
View File
@@ -8,7 +8,9 @@
padding-top: 60px;
max-width: 640px;
margin: 0 auto;
padding-bottom: 140px;
padding-bottom: calc(
var(--bottomnav-h, 66px) + var(--bottomnav-offset, 15px) + 150px
);
background: var(--bg);
}
@@ -22,6 +24,7 @@
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
margin-top: -70px;
}
.cart-header-left {
@@ -177,7 +180,9 @@
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.15s, background 0.15s;
transition:
opacity 0.15s,
background 0.15s;
flex-shrink: 0;
}
@@ -197,15 +202,22 @@
.cart-footer {
position: fixed;
bottom: 0;
/* Remonté au-dessus de la bottom-nav flottante (qui est en
bottom: var(--bottomnav-offset) avec z-index: 900), pour que
le bouton "Commander" ne soit jamais masqué par le menu. */
bottom: calc(
var(--bottomnav-h, 66px) + var(--bottomnav-offset, 15px) + 10px
);
left: 50%;
transform: translateX(-50%);
width: 100%;
width: calc(100% - 1.5rem);
max-width: 640px;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 1rem 1.25rem 1.25rem;
z-index: 100;
border: 1px solid var(--border);
border-radius: 16px;
padding: 1rem 1.25rem 1.1rem;
z-index: 850;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
.cart-footer-total {
@@ -236,7 +248,9 @@
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
transition:
opacity 0.2s,
transform 0.15s;
letter-spacing: 0.3px;
}
@@ -296,7 +310,9 @@
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
transition:
opacity 0.2s,
transform 0.15s;
}
.continue-shopping:hover {
@@ -320,8 +336,13 @@
}
@keyframes pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
/* ============================================
@@ -342,8 +363,12 @@
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content2 {
@@ -357,8 +382,14 @@
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-icon-container {
@@ -404,7 +435,9 @@
align-items: center;
justify-content: center;
gap: 0.4rem;
transition: opacity 0.15s, transform 0.15s;
transition:
opacity 0.15s,
transform 0.15s;
}
.modal-button:hover:not(:disabled) {
@@ -473,7 +506,9 @@
cursor: pointer;
z-index: 10;
font-size: 1.1rem;
transition: background 0.2s, transform 0.2s;
transition:
background 0.2s,
transform 0.2s;
}
.video-close-btn:hover {
+50
View File
@@ -48,6 +48,10 @@ function Checkout() {
const [showZoneModal, setShowZoneModal] = useState(false);
const [zoneErrorMsg, setZoneErrorMsg] = useState('');
// État pour le modal adresse invalide (correction suggérée)
const [showInvalidAddressModal, setShowInvalidAddressModal] = useState(false);
const [suggestedAddress, setSuggestedAddress] = useState('');
// Informations personnelles
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
@@ -315,6 +319,12 @@ function Checkout() {
return;
}
if (!response.success && response.invalid_address && response.suggested_address) {
setSuggestedAddress(response.suggested_address);
setShowInvalidAddressModal(true);
return;
}
if (response.success && response.command_id) {
const { command_id, assigned_to, queue_info, delivery_address } = response;
@@ -725,6 +735,46 @@ function Checkout() {
</div>
)}
{/* ============================================ */}
{/* MODAL ADRESSE INVALIDE (CORRECTION SUGGÉRÉE) */}
{/* ============================================ */}
{showInvalidAddressModal && (
<div className="confirmation-modal-overlay" onClick={() => setShowInvalidAddressModal(false)}>
<div className="confirmation-modal" onClick={(e) => e.stopPropagation()}>
<div className="confirmation-modal-header confirmation-modal-header--error">
<i className="fas fa-map-marker-alt confirmation-icon"></i>
<h2>Adresse invalide</h2>
</div>
<div className="confirmation-modal-body">
<div className="confirmation-section">
<div className="confirmation-detail confirmation-detail--centered">
<p style={{ marginBottom: '0.75rem' }}>
L'adresse saisie n'est pas reconnue. Voulez-vous utiliser l'adresse correcte suggérée ?
</p>
<p className="confirmation-detail--muted" style={{ fontWeight: 600 }}>
{suggestedAddress}
</p>
</div>
</div>
</div>
<div className="confirmation-modal-actions">
<button className="confirmation-button confirmation-button--neutral" onClick={() => setShowInvalidAddressModal(false)}>
Modifier
</button>
<button
className="confirmation-button"
onClick={() => {
setAddress(suggestedAddress);
setShowInvalidAddressModal(false);
}}
>
Utiliser cette adresse
</button>
</div>
</div>
</div>
)}
{/* ============================================ */}
{/* MODAL DE CONFIRMATION STYLISÉ */}
{/* ============================================ */}
@@ -20,6 +20,7 @@
font-weight: 700;
color: var(--text);
margin: 0 0 1.25rem 0;
margin-top: -70px;
}
/* ============================================
@@ -47,7 +48,10 @@
align-items: center;
text-align: center;
gap: 0.35rem;
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
transition:
transform 0.2s,
border-color 0.2s,
box-shadow 0.2s;
cursor: default;
}
@@ -79,18 +83,32 @@
margin-bottom: 0.25rem;
}
.stat-icon.icon-total-orders { background: linear-gradient(135deg, #7c3aed, #6d28d9); }
.stat-icon.icon-total { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon.icon-penalty-ok { background: rgba(100, 116, 139, 0.3); color: var(--text-muted); }
.stat-icon.icon-penalty-warning { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon.icon-total-orders {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
}
.stat-icon.icon-total {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
.stat-icon.icon-penalty-ok {
background: rgba(100, 116, 139, 0.3);
color: var(--text-muted);
}
.stat-icon.icon-penalty-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
.stat-icon.icon-penalty-critical {
background: linear-gradient(135deg, #ef4444, #dc2626);
animation: pulse-danger 2s ease-in-out infinite;
}
@keyframes pulse-danger {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
}
.stat-value {
@@ -101,8 +119,12 @@
line-height: 1;
}
.stat-value.value-danger { color: #ef4444; }
.stat-value.value-warning { color: #f59e0b; }
.stat-value.value-danger {
color: #ef4444;
}
.stat-value.value-warning {
color: #f59e0b;
}
.stat-label {
color: var(--text-muted);
@@ -125,7 +147,9 @@
padding: 0.875rem 1rem;
margin-bottom: 1.25rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
transition:
background 0.2s,
border-color 0.2s;
}
.referral-btn-row:hover {
@@ -328,7 +352,10 @@
border-radius: 12px;
padding: 1rem 1.125rem;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
border-color 0.15s,
box-shadow 0.15s;
}
.order-card:hover {
@@ -449,7 +476,9 @@
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.loading-text {
@@ -521,7 +550,9 @@
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
transition:
opacity 0.2s,
transform 0.2s;
}
.browse-button:hover {
@@ -529,6 +560,219 @@
transform: translateY(-1px);
}
/* ============================================
MODAL — CHOIX DU PRODUIT RÉCOMPENSE
============================================ */
.reward-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.reward-modal {
background: var(--surface);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 16px;
padding: 1.5rem;
max-width: 420px;
width: 100%;
max-height: 85vh;
overflow-y: auto;
animation: slideUp 0.25s ease;
}
.reward-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.reward-modal-header-title {
display: flex;
align-items: center;
gap: 0.625rem;
}
.reward-modal-icon {
color: #f59e0b;
font-size: 1.1rem;
}
.reward-modal-header h2 {
color: var(--text);
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
.reward-modal-close {
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.reward-modal-close:hover {
background: var(--surface-2);
color: var(--text);
}
.reward-modal-subtitle {
color: var(--text-muted);
font-size: 0.85rem;
margin: 0 0 1.1rem 0;
}
.reward-modal-products {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-bottom: 1.25rem;
}
.reward-product-card {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
background: rgba(245, 158, 11, 0.05);
border: 1.5px solid rgba(245, 158, 11, 0.2);
border-radius: 10px;
padding: 0.75rem 0.875rem;
cursor: pointer;
transition: all 0.15s;
text-align: left;
font-family: inherit;
}
.reward-product-card:hover {
border-color: rgba(245, 158, 11, 0.5);
background: rgba(245, 158, 11, 0.09);
}
.reward-product-card.selected {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.14);
}
.reward-product-radio {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(245, 158, 11, 0.5);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #f59e0b;
font-size: 0.7rem;
transition: all 0.15s;
}
.reward-product-card.selected .reward-product-radio {
background: #f59e0b;
border-color: #f59e0b;
color: #fff;
}
.reward-product-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.reward-product-name {
color: var(--text);
font-size: 0.92rem;
font-weight: 600;
}
.reward-product-meta {
color: var(--text-muted);
font-size: 0.78rem;
}
.reward-product-free {
flex-shrink: 0;
color: #10b981;
font-size: 0.78rem;
font-weight: 700;
background: rgba(16, 185, 129, 0.12);
border-radius: 6px;
padding: 3px 8px;
}
.reward-modal-actions {
display: flex;
gap: 0.75rem;
}
.reward-modal-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
border: none;
transition:
opacity 0.15s,
transform 0.15s;
font-family: inherit;
}
.reward-modal-btn:hover:not(:disabled) {
opacity: 0.88;
transform: translateY(-1px);
}
.reward-modal-btn-cancel {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.reward-modal-btn-confirm {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #fff;
}
.reward-modal-btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 480px) {
.reward-modal {
padding: 1.25rem;
border-radius: 14px;
}
}
/* ============================================
RESPONSIVE
============================================ */
@@ -13,7 +13,12 @@ import {
getMyPointsRewards,
claimMyReward,
} from "../../api/api";
import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api";
import type {
PublicSettings,
PointsPoolInfo,
PointsRewardConfig,
RewardItemConfig,
} from "../../api/api";
import type {
CompletedOrder,
ClientStats,
@@ -38,6 +43,8 @@ import {
faChevronRight,
faCheckCircle,
faHistory,
faTimes,
faCheck,
} from "@fortawesome/free-solid-svg-icons";
function ConsultationHistorique() {
@@ -60,6 +67,13 @@ function ConsultationHistorique() {
shop_name: "Milieu-Nantais",
two_fa_enabled: false,
contact_telegram: "",
client_color_primary: "",
client_color_secondary: "",
client_color_success: "",
client_color_danger: "",
client_color_warning: "",
client_title_gradient_from: "",
client_title_gradient_to: "",
});
const [referralBalance, setReferralBalance] = useState<number>(0);
const [pointsRewards, setPointsRewards] = useState<{
@@ -68,10 +82,21 @@ function ConsultationHistorique() {
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 [claimFeedback, setClaimFeedback] = useState<{
pool: string;
type: "success" | "error";
text: string;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>("");
// ── Modal de sélection du produit récompense ──
const [rewardModalPool, setRewardModalPool] =
useState<PointsPoolInfo | null>(null);
const [selectedProductId, setSelectedProductId] = useState<number | null>(
null,
);
useEffect(() => {
if (!isUserAuthenticated())
navigate("/login/client", { replace: true });
@@ -152,37 +177,51 @@ function ConsultationHistorique() {
});
};
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>
</>
);
}
// Ouvre la modal de choix si plusieurs produits sont éligibles,
// sinon réclame directement (1 seul produit configuré ou aucun item).
const openRewardModal = (pool: PointsPoolInfo) => {
const items = pool.eligible_reward_items ?? [];
if (items.length > 1) {
setSelectedProductId(items[0]?.product_id ?? null);
setRewardModalPool(pool);
} else {
handleClaim(pool.key);
}
};
const handleClaim = async (poolKey: string) => {
const closeRewardModal = () => {
setRewardModalPool(null);
setSelectedProductId(null);
};
const confirmRewardChoice = async () => {
if (!rewardModalPool) return;
const poolKey = rewardModalPool.key;
const productId = selectedProductId ?? undefined;
closeRewardModal();
await handleClaim(poolKey, productId);
};
const handleClaim = async (poolKey: string, productId?: number) => {
setClaimingPool(poolKey);
setClaimFeedback(null);
const res = await claimMyReward(poolKey);
const res = await claimMyReward(poolKey, productId);
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 !";
const text =
res.product_added && res.product_names?.length
? `🎁 ${res.product_names.join(", ")} ajouté${res.product_names.length > 1 ? "s" : ""} à votre panier ! Commandez au moins un produit pour en profiter.`
: res.description || "Récompense réclamée !";
setClaimFeedback({ pool: poolKey, type: "success", text });
getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); });
getMyPointsRewards().then((r) => {
if (r.success) setPointsRewards(r);
});
} else {
setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" });
setClaimFeedback({
pool: poolKey,
type: "error",
text: res.error || "Erreur",
});
}
};
@@ -202,6 +241,24 @@ function ConsultationHistorique() {
"#7c3aed",
];
if (isLoading) {
return (
<>
<Navbar />
<div className="history-container">
<div className="loading-container">
<div className="loading-spinner">
<div className="spinner"></div>
</div>
<p className="loading-text">
Chargement de l'historique...
</p>
</div>
</div>
</>
);
}
return (
<>
<Navbar />
@@ -298,81 +355,178 @@ function ConsultationHistorique() {
</div>
{/* Section récompenses par palier */}
{pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && (
<div className="rewards-section">
<p className="rewards-section-title">
<FontAwesomeIcon icon={faTrophy} className="rewards-section-icon" />
Récompenses
</p>
{pointsRewards.reward.description && (
<p className="rewards-section-desc">{pointsRewards.reward.description}</p>
)}
{(pointsRewards.reward.reward_items ?? []).filter((it: RewardItemConfig) => it.product_name).length > 0 && (
<div className="reward-eligible-cats">
{(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => (
<span key={idx} className="reward-eligible-cat">
<FontAwesomeIcon icon={faGift} style={{ marginRight: 4 }} />
{it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? `${it.price}` : ""}
</span>
))}
</div>
)}
<div className="rewards-pools">
{pointsRewards.pools.map((pool) => {
const threshold = pointsRewards.reward!.threshold;
const progress = Math.min(100, Math.round((pool.points % threshold) / threshold * 100));
const remaining = threshold - (pool.points % threshold);
const isClaiming = claimingPool === pool.key;
const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null;
return (
<div key={pool.key} className="reward-pool-card">
<div className="reward-pool-header">
<span className="reward-pool-name">{pool.name}</span>
<span className="reward-pool-pts">{pool.points} pts</span>
</div>
{pool.eligible_configs.length > 0 && (
<div className="reward-eligible-cats">
{pool.eligible_configs.flatMap((cfg) =>
cfg.all_products
? [<span key={cfg.category} className="reward-eligible-cat">
{cfg.category}
</span>]
: (cfg.product_names ?? []).map((name) => (
<span key={`${cfg.category}-${name}`} className="reward-eligible-cat">
{name}
</span>
))
{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>
)}
<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>
{feedback && (
<p
className={`reward-feedback reward-feedback-${feedback.type}`}
>
{feedback.text}
</p>
)}
{pool.rewards_available > 0 && (
<button
className="reward-claim-btn"
onClick={() =>
openRewardModal(pool)
}
disabled={isClaiming}
>
{isClaiming
? "..."
: "Réclamer ma récompense"}
</button>
)}
</div>
{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>
</div>
)}
)}
{/* Bouton parrainage */}
{appSettings.referral_enabled && (
@@ -494,10 +648,19 @@ function ConsultationHistorique() {
<div className="order-card-footer">
<span className="order-card-total">
{formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
{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
{" "}
dont{" "}
{formatPrice(
order.referral_used ??
0,
)}{" "}
parrainage
</span>
)}
</span>
@@ -512,6 +675,111 @@ function ConsultationHistorique() {
</>
)}
</div>
{/* ============================================ */}
{/* MODAL — CHOIX DU PRODUIT RÉCOMPENSE */}
{/* ============================================ */}
{rewardModalPool && pointsRewards?.reward && (
<div
className="reward-modal-overlay"
onClick={closeRewardModal}
>
<div
className="reward-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="reward-modal-header">
<div className="reward-modal-header-title">
<FontAwesomeIcon
icon={faGift}
className="reward-modal-icon"
/>
<h2>Choisissez votre récompense</h2>
</div>
<button
className="reward-modal-close"
onClick={closeRewardModal}
aria-label="Fermer"
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
<p className="reward-modal-subtitle">
{rewardModalPool.name} {" "}
{rewardModalPool.rewards_available} récompense
{rewardModalPool.rewards_available > 1
? "s"
: ""}{" "}
disponible
{rewardModalPool.rewards_available > 1 ? "s" : ""}
</p>
<div className="reward-modal-products">
{(rewardModalPool.eligible_reward_items ?? []).map(
(item) => {
const isSelected =
selectedProductId === item.product_id;
return (
<button
key={item.product_id}
type="button"
className={`reward-product-card ${isSelected ? "selected" : ""}`}
onClick={() =>
setSelectedProductId(
item.product_id,
)
}
>
<div className="reward-product-radio">
{isSelected && (
<FontAwesomeIcon
icon={faCheck}
/>
)}
</div>
<div className="reward-product-info">
<span className="reward-product-name">
{item.product_name}
</span>
<span className="reward-product-meta">
{item.quantity > 0 &&
item.quantity !== 1
? `×${item.quantity}`
: ""}
{item.price > 0
? ` · valeur ${item.price.toFixed(2)}`
: ""}
</span>
</div>
<span className="reward-product-free">
Offert
</span>
</button>
);
},
)}
</div>
<div className="reward-modal-actions">
<button
className="reward-modal-btn reward-modal-btn-cancel"
onClick={closeRewardModal}
>
Annuler
</button>
<button
className="reward-modal-btn reward-modal-btn-confirm"
onClick={confirmRewardChoice}
disabled={selectedProductId === null}
>
<FontAwesomeIcon icon={faGift} />
Confirmer
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -802,6 +802,148 @@
animation-delay: 0.4s;
}
/* ============================================
RATING LIVREUR
============================================ */
.rating-btn {
display: flex;
align-items: center;
gap: 0.4rem;
background: #f59e0b;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.75rem;
transition: opacity 0.2s;
}
.rating-btn:hover {
opacity: 0.85;
}
.rating-done-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: rgba(245, 158, 11, 0.1);
border-radius: 6px;
}
.rating-done-text {
color: #f59e0b;
font-size: 0.85rem;
}
.rating-star-filled {
color: #f59e0b;
}
.rating-star-empty {
color: #6b7280;
}
.rating-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.rating-modal-sheet {
background: var(--surface, #1a1a2e);
border-radius: 16px 16px 0 0;
padding: 1.5rem 1.5rem 2.5rem;
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rating-modal-title {
color: var(--text-primary, #fff);
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
.rating-modal-subtitle {
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
margin: 0;
}
.rating-stars-row {
display: flex;
gap: 0.5rem;
margin: 0.5rem 0;
}
.rating-star-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
line-height: 0;
}
.rating-comment-input {
width: 100%;
background: var(--bg, #0f0f1a);
border: 1px solid var(--border, #374151);
border-radius: 6px;
color: var(--text-primary, #fff);
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
box-sizing: border-box;
}
.rating-comment-input::placeholder {
color: var(--text-muted, #9ca3af);
}
.rating-submit-btn {
background: #f59e0b;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.rating-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.rating-submit-btn:not(:disabled):hover {
opacity: 0.85;
}
.rating-cancel-btn {
background: none;
border: none;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem;
text-align: center;
}
/* ============================================
PRINT STYLES
============================================ */
@@ -9,6 +9,8 @@ import {
isUserAuthenticated,
getProductById,
getMediaUrl,
submitLivreurRating,
getOrderRatingStatus,
} from "../../api/api";
import type { CompletedOrder, Product } from "../../api/api_types";
import {
@@ -26,6 +28,7 @@ import {
ShoppingBag,
Camera,
X,
Star,
} from "lucide-react";
interface OrderProduct {
@@ -73,6 +76,11 @@ function OrderDetails() {
const [error, setError] = useState<string>("");
const [showVideo, setShowVideo] = useState(false);
const [currentVideoUrl, setCurrentVideoUrl] = useState<string>("");
const [ratingModal, setRatingModal] = useState(false);
const [selectedStars, setSelectedStars] = useState(0);
const [ratingComment, setRatingComment] = useState("");
const [ratingSubmitting, setRatingSubmitting] = useState(false);
const [ratingDone, setRatingDone] = useState<{ rating: number; comment: string } | null>(null);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
@@ -315,6 +323,11 @@ function OrderDetails() {
console.log("✅ [ORDER DETAILS] Données mappées:", orderData);
setOrder(orderData);
if (apiData.command_info?.command_status === "approved") {
const r = await getOrderRatingStatus(id);
if (r.rated) setRatingDone({ rating: r.rating!, comment: r.comment ?? "" });
}
} else {
console.error("❌ [ORDER DETAILS] Erreur:", result.message);
setError(result.message || "Erreur lors du chargement");
@@ -327,6 +340,17 @@ function OrderDetails() {
}
};
const handleSubmitRating = async () => {
if (selectedStars === 0 || !order) return;
setRatingSubmitting(true);
const res = await submitLivreurRating(order.id, selectedStars, ratingComment);
setRatingSubmitting(false);
if (res.success) {
setRatingDone({ rating: selectedStars, comment: ratingComment });
setRatingModal(false);
}
};
const getProductCount = (totalPrix: number): number => {
return Math.max(1, Math.round(totalPrix / 25));
};
@@ -446,6 +470,22 @@ function OrderDetails() {
</div>
)}
{order.status === "approved" && (
ratingDone ? (
<div className="rating-done-row">
<Star size={14} className="rating-star-filled" />
<span className="rating-done-text">
Noté {ratingDone.rating}/5{ratingDone.comment ? ` · "${ratingDone.comment}"` : ""}
</span>
</div>
) : (
<button className="rating-btn" onClick={() => setRatingModal(true)}>
<Star size={14} />
Noter la livraison
</button>
)
)}
<div className="info-row">
<div className="info-icon">
<Calendar size={18} />
@@ -759,6 +799,47 @@ function OrderDetails() {
</div>
</div>
{/* Modal notation livreur */}
{ratingModal && (
<div className="rating-modal-overlay" onClick={() => setRatingModal(false)}>
<div className="rating-modal-sheet" onClick={(e) => e.stopPropagation()}>
<h2 className="rating-modal-title">Noter la livraison</h2>
{order.livreur_assign && (
<p className="rating-modal-subtitle">{order.livreur_assign}</p>
)}
<div className="rating-stars-row">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} className="rating-star-btn" onClick={() => setSelectedStars(star)}>
<Star
size={36}
className={star <= selectedStars ? "rating-star-filled" : "rating-star-empty"}
fill={star <= selectedStars ? "#f59e0b" : "none"}
/>
</button>
))}
</div>
<textarea
className="rating-comment-input"
placeholder="Commentaire (optionnel)"
value={ratingComment}
onChange={(e) => setRatingComment(e.target.value)}
maxLength={500}
rows={3}
/>
<button
className="rating-submit-btn"
onClick={handleSubmitRating}
disabled={selectedStars === 0 || ratingSubmitting}
>
{ratingSubmitting ? "Envoi..." : "Envoyer ma note"}
</button>
<button className="rating-cancel-btn" onClick={() => setRatingModal(false)}>
Annuler
</button>
</div>
</div>
)}
{/* Modal vidéo */}
{showVideo && currentVideoUrl && (
<div className="video-modal" onClick={handleCloseVideo}>
+17 -4
View File
@@ -38,6 +38,7 @@
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-top: -70px;
}
.parrainage-subtitle {
@@ -51,7 +52,11 @@
============================================ */
.parrainage-balance-card {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.05));
background: linear-gradient(
135deg,
rgba(139, 92, 246, 0.15),
rgba(139, 92, 246, 0.05)
);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 16px;
padding: 2rem;
@@ -96,7 +101,11 @@
.parrainage-reward-card {
display: flex;
gap: 1rem;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.12), rgba(99, 102, 241, 0.08));
background: linear-gradient(
135deg,
rgba(139, 92, 246, 0.12),
rgba(99, 102, 241, 0.08)
);
border: 1px solid rgba(139, 92, 246, 0.35);
border-radius: 14px;
padding: 1.5rem;
@@ -162,7 +171,9 @@
border-radius: 12px;
padding: 1.25rem 1rem;
text-align: center;
transition: border-color 0.2s, transform 0.2s;
transition:
border-color 0.2s,
transform 0.2s;
}
.step-card:hover {
@@ -276,7 +287,9 @@
padding: 0.8rem 1.8rem;
border-radius: 10px;
text-decoration: none;
transition: background 0.2s, transform 0.15s;
transition:
background 0.2s,
transform 0.15s;
}
.telegram-btn:hover {
File diff suppressed because it is too large Load Diff
+163 -61
View File
@@ -102,7 +102,9 @@ const getDeliveryAddress = (order: OrderWithTracking): string => {
const formatOrderItem = (item: OrderItem) => {
return {
name: String(item.produit || item.product_name || item.name_product || "Produit"),
name: String(
item.produit || item.product_name || item.name_product || "Produit",
),
quantity: Number(item.quantite || item.quantity || 0),
price: Number(item.prix || item.price || 0),
};
@@ -157,7 +159,6 @@ const getStatusIcon = (status: string): IconDefinition => {
return iconMap[status?.toLowerCase()] || faQuestionCircle;
};
/**
* ✅ Calculer les points avec le TOTAL (pas item par item)
*/
@@ -335,33 +336,35 @@ function SuiviLivraison() {
if (response.success) {
const ordersWithTracking = await Promise.all(
(response.commands || []).map(async (order: OrderDetail) => {
const normalizedOrder = {
...order,
total: getTotalAmount(order),
};
(response.commands || []).map(
async (order: OrderDetail) => {
const normalizedOrder = {
...order,
total: getTotalAmount(order),
};
let tracking;
let eta;
let tracking;
let eta;
try {
tracking = await getOrderTracking(order.id);
} catch {
tracking = undefined;
}
try {
tracking = await getOrderTracking(order.id);
} catch {
tracking = undefined;
}
try {
eta = await getOrderETA(order.id);
} catch {
eta = undefined;
}
try {
eta = await getOrderETA(order.id);
} catch {
eta = undefined;
}
return {
...normalizedOrder,
tracking,
eta,
};
}),
return {
...normalizedOrder,
tracking,
eta,
};
},
),
);
setOrders(ordersWithTracking);
@@ -369,7 +372,10 @@ function SuiviLivraison() {
}
} catch (err: unknown) {
console.error("Erreur loadOrders:", err);
const msg = err instanceof Error ? err.message : "Erreur lors du chargement";
const msg =
err instanceof Error
? err.message
: "Erreur lors du chargement";
setError(msg);
showToast(msg, "error");
} finally {
@@ -400,7 +406,10 @@ function SuiviLivraison() {
const order = orders.find((o) => o.id === orderId);
if (order) {
const { points, categoryDisplay } = calculateOrderPoints(order, poolNames);
const { points, categoryDisplay } = calculateOrderPoints(
order,
poolNames,
);
setSelectedOrderPoints(points);
setSelectedOrderCategoryDisplay(categoryDisplay);
} else {
@@ -549,7 +558,12 @@ function SuiviLivraison() {
}
} catch (error: unknown) {
console.error("❌ [CANCEL] Erreur:", error);
showToast(error instanceof Error ? error.message : "Erreur lors de l'annulation", "error");
showToast(
error instanceof Error
? error.message
: "Erreur lors de l'annulation",
"error",
);
} finally {
setCancellingOrder(null);
}
@@ -617,10 +631,19 @@ function SuiviLivraison() {
// Heure d'arrivée estimée : depuis le backend ou calculée côté client
const computedArrival = (() => {
if (statusLow !== "en_route") return null;
if (order.eta?.estimated_arrival) return order.eta.estimated_arrival;
if (order.eta?.eta_minutes && order.eta.eta_minutes > 0) {
return new Date(Date.now() + order.eta.eta_minutes * 60000)
.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
if (order.eta?.estimated_arrival)
return order.eta.estimated_arrival;
if (
order.eta?.eta_minutes &&
order.eta.eta_minutes > 0
) {
return new Date(
Date.now() +
order.eta.eta_minutes * 60000,
).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
return null;
})();
@@ -655,7 +678,8 @@ function SuiviLivraison() {
<div className="order-header-left">
<div className="order-id-row">
<span className="order-id">
Commande #{order.client_order_number}
Commande #
{order.client_order_number}
</span>
<div
className="status-badge"
@@ -677,7 +701,9 @@ function SuiviLivraison() {
</div>
{statusLow === "en_route" && (
<div className="eta-badge eta-badge--enRoute">
<FontAwesomeIcon icon={faClock} />
<FontAwesomeIcon
icon={faClock}
/>
{computedArrival
? ` Vers ${computedArrival}`
: " Aucune heure disponible"}
@@ -685,7 +711,9 @@ function SuiviLivraison() {
)}
{showArrivedEta && (
<div className="eta-badge eta-badge--arrived">
<FontAwesomeIcon icon={faClock} />
<FontAwesomeIcon
icon={faClock}
/>
{" ~5 min"}
</div>
)}
@@ -764,40 +792,80 @@ function SuiviLivraison() {
{showArrivedEta && (
<div className="eta-card eta-card--arrived">
<div className="eta-card-icon">
<FontAwesomeIcon icon={faMapMarkerAlt} />
<FontAwesomeIcon
icon={
faMapMarkerAlt
}
/>
</div>
<div className="eta-card-body">
<p className="eta-card-title">Livreur sur place</p>
<p className="eta-card-value">~5 min</p>
<p className="eta-card-sub">Préparez-vous à réceptionner votre commande</p>
<p className="eta-card-title">
Livreur sur place
</p>
<p className="eta-card-value">
~5 min
</p>
<p className="eta-card-sub">
Préparez-vous à
réceptionner votre
commande
</p>
</div>
</div>
)}
{/* ETA card - en_route */}
{(showEta || (statusLow === "en_route" && computedArrival)) && (
<div
className="eta-card eta-card--enRoute"
>
{(showEta ||
(statusLow === "en_route" &&
computedArrival)) && (
<div className="eta-card eta-card--enRoute">
<div className="eta-card-icon">
<FontAwesomeIcon icon={faClock} />
<FontAwesomeIcon
icon={faClock}
/>
</div>
<div className="eta-card-body">
<p className="eta-card-title">Heure d'arrivée estimée</p>
<p className="eta-card-title">
Heure d'arrivée
estimée
</p>
{computedArrival && (
<p className="eta-card-value">{computedArrival}</p>
)}
{order.eta?.eta_minutes && order.eta.eta_minutes > 0 && (
<p className="eta-card-sub">
~{order.eta.eta_minutes} min restantes
<p className="eta-card-value">
{
computedArrival
}
</p>
)}
{order.eta?.livreur_distance != null && (
{order.eta
?.eta_minutes &&
order.eta
.eta_minutes >
0 && (
<p className="eta-card-sub">
~
{
order
.eta
.eta_minutes
}{" "}
min
restantes
</p>
)}
{order.eta
?.livreur_distance !=
null && (
<p className="eta-card-sub">
Distance :{" "}
{typeof order.eta.livreur_distance === "number"
? order.eta.livreur_distance.toFixed(1)
: order.eta.livreur_distance}{" "}
{typeof order
.eta
.livreur_distance ===
"number"
? order.eta.livreur_distance.toFixed(
1,
)
: order.eta
.livreur_distance}{" "}
km
</p>
)}
@@ -924,19 +992,53 @@ 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)} €
{(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>
{Math.max(0, getTotalAmount(order) - (order.referral_used ?? 0)).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
{(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>
+12 -20
View File
@@ -20,45 +20,37 @@
}
/* ===== COMING SOON OVERLAY ===== */
.coming-soon-overlay {
.category-cs-overlay {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 80px 24px;
min-height: 50vh;
padding: 24px;
gap: 16px;
}
.coming-soon-text {
font-family: "Reach fill & Outline", sans-serif;
font-size: clamp(1.5rem, 8vw, 1rem);
.category-cs-text {
font-size: clamp(1.6rem, 5vw, 2rem);
font-weight: 400;
color: #8e8fe8;
letter-spacing: 4px;
letter-spacing: 2px;
text-align: center;
text-transform: uppercase;
text-shadow:
0 0 10px rgba(142, 143, 232, 0.9),
0 0 25px rgba(142, 143, 232, 0.6),
0 0 50px rgba(142, 143, 232, 0.3);
animation: pulse-scale 2s ease-in-out infinite;
text-shadow: 0 0 18px rgba(142, 143, 232, 0.85);
animation: pulse-scale 4s ease-in-out infinite;
}
.coming-soon-desc {
color: rgba(142, 143, 232, 0.7);
.category-cs-desc {
font-size: 1rem;
color: rgba(142, 143, 232, 0.7);
text-align: center;
margin: 0;
}
@keyframes pulse-scale {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.35);
}
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
.category-filter {