canina/frontend/application/components/CartDrawer.tsx
parsa aghaei 7615aa0e51 fix: resolve seed encoding, search param, and wiki data source issues
- Fix seed-products.ts TS error (implicit any) and BOM handling
- Re-run full seed to restore correct Persian encoding in DB
- Fix productService query→search param mismatch
- Fix IngredientWiki hardcoded port 4000→use settingsStore
- Restore seed-products-data.json from git after accidental corruption
2026-07-11 15:40:49 +03:30

311 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { X, Trash2, Plus, Minus, ShoppingBag, ShieldCheck, ArrowLeft, Ticket, Sparkles, CheckCircle2 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { toPersian, cn } from "../lib/utils";
import { useCartStore } from "../lib/store/cartStore";
import { Product } from "../lib/data/products";
import { productService } from "../lib/services/productService";
import SafeImage from "./SafeImage";
import { toast } from "sonner";
export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate }: {
isOpen: boolean;
onClose: () => void;
onCheckout: () => void;
onShopNavigate?: () => void;
}) {
const { items, updateQuantity, removeItem, isSubscribed, toggleSubscription, getTotal, getSubtotal, getDiscount, coupon, applyCoupon, addItem } = useCartStore();
const [couponCode, setCouponCode] = useState("");
const [couponStatus, setCouponStatus] = useState<"idle" | "success" | "error">("idle");
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const handleApplyCoupon = async () => {
const success = await applyCoupon(couponCode);
if (success) {
setCouponStatus("success");
setTimeout(() => setCouponStatus("idle"), 3000);
} else {
setCouponStatus("error");
setTimeout(() => setCouponStatus("idle"), 2000);
}
};
// Smart Cross-sell Logic
const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag');
const hasLachsOl = items.some(i => i.product.id === 'lachs-ol');
const [suggestedProduct, setSuggestedProduct] = useState<Product | null>(null);
useEffect(() => {
if (!hasLachsOl && hasCanhydrox) {
productService.getProducts().then(prods => {
const found = prods.find(p => p.id === 'lachs-ol' || p.artNo === 'lachs-ol');
setSuggestedProduct(found || null);
});
} else {
setSuggestedProduct(null);
}
}, [hasLachsOl, hasCanhydrox]);
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-medical-gray-900/40 backdrop-blur-sm z-[100]"
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-y-0 right-0 w-full max-w-md bg-white shadow-2xl z-[101] flex flex-col"
dir="rtl"
>
{/* Header */}
<div className="p-6 border-b border-medical-gray-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-medical-gray-50 rounded-xl flex items-center justify-center text-canina-blue">
<ShoppingBag className="w-6 h-6" />
</div>
<h3 className="text-xl font-black text-medical-gray-900 italic">سبد خرید</h3>
</div>
<button onClick={onClose} className="p-2 hover:bg-medical-gray-50 rounded-full transition-colors">
<X className="w-6 h-6 text-medical-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center space-y-4">
<div className="w-20 h-20 bg-medical-gray-50 rounded-full flex items-center justify-center text-medical-gray-300">
<ShoppingBag className="w-10 h-10" />
</div>
<p className="text-medical-gray-500 font-bold">سبد خرید شما خالی است</p>
<button
onClick={() => {
onClose();
onShopNavigate?.();
}}
className="text-canina-blue font-black text-sm border-b-2 border-canina-blue pb-1 font-vazir"
>
مشاهده محصولات تخصصی
</button>
</div>
) : (
<>
<div className="space-y-4">
{items.map((item) => (
<div key={item.product.id} className="flex gap-4 p-4 bg-medical-gray-50 rounded-3xl border border-medical-gray-100 group">
<div className="w-20 h-20 bg-white rounded-2xl p-2 flex-shrink-0 flex items-center justify-center shadow-sm">
<SafeImage src={item.product.image} alt={item.product.name} className="w-full h-full" imgClassName="object-contain" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-black text-medical-gray-900 truncate mb-1">{item.product.name}</h4>
<p className="text-[10px] text-canina-blue font-bold mb-3">{item.product.category}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 bg-white rounded-lg border border-medical-gray-200 p-1">
<button
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
className="p-1 hover:text-canina-blue transition-colors"
data-testid="minus-btn"
>
<Minus className="w-3 h-3" />
</button>
<span className="text-xs font-black min-w-[20px] text-center font-vazir">{toPersian(item.quantity)}</span>
<button
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
className="p-1 hover:text-canina-blue transition-colors"
data-testid="plus-btn"
>
<Plus className="w-3 h-3" />
</button>
</div>
<button
onClick={() => setDeleteConfirmId(item.product.id)}
className="text-medical-gray-300 hover:text-red-500 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-left flex flex-col justify-between items-end">
<span className="text-xs font-black text-medical-gray-900 font-vazir">{toPersian((item.product.priceValue * item.quantity).toLocaleString())}</span>
<span className="text-[10px] text-medical-gray-400 font-bold">تومان</span>
</div>
{/* Delete Confirmation Overlay */}
<AnimatePresence>
{deleteConfirmId === item.product.id && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-white/95 z-40 flex flex-col items-center justify-center p-4 text-center rounded-3xl"
>
<div className="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center text-red-500 mb-2">
<Trash2 className="w-5 h-5" />
</div>
<p className="text-[11px] font-black text-medical-gray-900 mb-3 font-vazir">
مطمئنی میخوای این محصول رو حذف کنی؟
</p>
<div className="flex gap-2 w-full px-2">
<button
onClick={() => {
removeItem(item.product.id);
setDeleteConfirmId(null);
toast.error(`${item.product.name} حذف شد`);
}}
className="flex-1 bg-red-500 text-white py-2 rounded-xl text-[10px] font-black shadow-lg shadow-red-500/20"
>
حذف
</button>
<button
onClick={() => setDeleteConfirmId(null)}
className="flex-1 bg-medical-gray-100 text-medical-gray-600 py-2 rounded-xl text-[10px] font-black"
>
لغو
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
{/* Smart Cross-sell Recommendation */}
{suggestedProduct && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 bg-gradient-to-br from-canina-blue/5 to-transparent border border-canina-blue/10 rounded-3xl"
>
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-4 h-4 text-canina-blue animate-pulse" />
<span className="text-xs font-black text-medical-gray-900">پیشنهاد هوشمند برای جذب بهتر</span>
</div>
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white rounded-2xl p-2 shrink-0 shadow-sm border border-canina-blue/5">
<SafeImage src={suggestedProduct.image} alt="" className="w-full h-full" imgClassName="object-contain" />
</div>
<div className="flex-1">
<h5 className="text-[10px] font-black text-medical-gray-900 mb-1">{suggestedProduct.name}</h5>
<p className="text-[9px] text-medical-gray-500 font-medium leading-tight">برای جذب ویتامینهای Canhydrox، اسیدهای چرب سالمون ضروری هستند.</p>
</div>
<button
onClick={() => addItem(suggestedProduct, 1)}
className="bg-canina-blue text-white w-8 h-8 rounded-full flex items-center justify-center hover:scale-110 transition-transform shadow-lg shadow-canina-blue/20"
>
<Plus className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</>
)}
</div>
{/* Footer */}
{items.length > 0 && (
<div className="p-6 bg-medical-gray-50 border-t border-medical-gray-200 space-y-6">
{/* Discount Code */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest px-2">
<Ticket className="w-3 h-3" />
کد تخفیف اختصاصی
</div>
<div className="relative">
<input
type="text"
placeholder="CANINO2024"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
className={`w-full bg-white border ${couponStatus === 'error' ? 'border-red-300' : 'border-medical-gray-200'} rounded-2xl py-3 px-4 pr-10 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20 transition-all`}
/>
<button
onClick={handleApplyCoupon}
className={`absolute left-2 top-1/2 -translate-y-1/2 px-4 py-1.5 rounded-xl text-[10px] font-black transition-all ${couponStatus === 'success' ? 'bg-green-600 text-white' : 'bg-medical-gray-900 text-white hover:bg-canina-blue'}`}
>
{couponStatus === 'success' ? <CheckCircle2 className="w-3 h-3" /> : 'اعمال'}
</button>
</div>
{coupon && (
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-100 rounded-2xl">
<div className="flex items-center gap-2 text-[10px] font-bold text-green-600">
<div className="w-1.5 h-1.5 rounded-full bg-green-600 animate-pulse" />
کد {coupon.code} ({(coupon.discount < 1 ? coupon.discount * 100 : coupon.discount)} {coupon.discount < 1 ? '٪' : 'تومان'}) اعمال شد
</div>
<button
onClick={() => {
useCartStore.getState().removeCoupon();
setCouponCode("");
}}
className="p-1 hover:bg-red-50 hover:text-red-500 text-medical-gray-400 rounded-lg transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Subscription Toggle */}
<div
onClick={toggleSubscription}
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer ${isSubscribed ? 'bg-canina-blue/5 border-canina-blue' : 'bg-white border-medical-gray-100 hover:border-canina-blue/30'}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${isSubscribed ? 'bg-canina-blue text-white' : 'bg-medical-gray-50 text-medical-gray-300'}`}>
<ShieldCheck className="w-6 h-6" />
</div>
<div className="flex-1">
<h5 className="text-xs font-black text-medical-gray-900">فعالسازی سرویس تمدید خودکار (Refill)</h5>
<p className="text-[10px] text-medical-gray-500 font-medium">۵٪ تخفیف روی تمام فاکتور + ارسال اولویتدار</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${isSubscribed ? 'bg-canina-blue border-canina-blue' : 'border-medical-gray-200'}`}>
{isSubscribed && <X className="w-3 h-3 text-white" />}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm px-2">
<span className="text-medical-gray-500 font-bold">مجموع اقلام</span>
<span className="text-medical-gray-900 font-black font-vazir">{toPersian(getSubtotal().toLocaleString())} تومان</span>
</div>
{getDiscount() > 0 && (
<div className="flex items-center justify-between text-sm text-green-600 px-2 font-black">
<span className="font-bold">مجموع تخفیفها</span>
<span className="font-black font-vazir">-{toPersian(getDiscount().toLocaleString())} تومان</span>
</div>
)}
<div className="flex items-center justify-between border-t border-medical-gray-200 pt-4 px-2">
<span className="text-lg font-black text-medical-gray-900 italic">مجموع قابل پرداخت</span>
<div className="text-left">
<p className="text-2xl font-black text-canina-blue tracking-tighter leading-none font-vazir">{toPersian(getTotal().toLocaleString())}</p>
<p className="text-[10px] text-medical-gray-400 font-bold">تومان</p>
</div>
</div>
</div>
<button
onClick={onCheckout}
className="w-full bg-canina-blue text-white py-5 rounded-[2.5rem] font-black text-lg hover:shadow-2xl hover:shadow-canina-blue/20 transition-all flex items-center justify-center gap-3 group"
>
ثبت نهایی و تسویه حساب
<ArrowLeft className="w-5 h-5 group-hover:translate-x-[-4px] transition-transform" />
</button>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
);
}