305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
import React from 'react'
|
||
import { X, ShoppingBag, Plus, Minus, Trash2, Tag, ArrowRight } from 'lucide-react'
|
||
import { Link } from 'react-router-dom'
|
||
import { useCartStore } from '../store/cartStore'
|
||
import { formatPrice } from '@homaroma/types'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { API_BASE } from '../types'
|
||
|
||
export default function CartDrawer() {
|
||
const { t } = useTranslation()
|
||
const { isCartOpen, toggleCart, items, updateQuantity, removeFromCart, getCartTotal } = useCartStore()
|
||
|
||
React.useEffect(() => {
|
||
if (isCartOpen) {
|
||
document.body.style.overflow = 'hidden'
|
||
} else {
|
||
document.body.style.overflow = ''
|
||
}
|
||
return () => {
|
||
document.body.style.overflow = ''
|
||
}
|
||
}, [isCartOpen])
|
||
|
||
if (!isCartOpen) return null
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||
{/* Backdrop */}
|
||
<div
|
||
className="absolute inset-0 bg-pop-black/60 backdrop-blur-sm animate-fade-in"
|
||
onClick={() => toggleCart(false)}
|
||
/>
|
||
|
||
{/* Drawer */}
|
||
<div className="relative w-full max-w-md h-full bg-pop-bg shadow-2xl flex flex-col animate-slide-left">
|
||
{/* Header */}
|
||
<div className="p-6 border-b border-pop-black/10 flex items-center justify-between bg-pop-bg">
|
||
<h2 className="text-xl font-bold flex items-center gap-2 text-pop-black">
|
||
<ShoppingBag className="w-5 h-5 text-pop-pink" />
|
||
{t('cart.title')}
|
||
<span className="text-sm font-normal text-pop-black bg-pop-black/5 px-2 py-0.5 rounded-full ml-2">
|
||
{t('cart.items', { count: items.length })}
|
||
</span>
|
||
</h2>
|
||
<button
|
||
onClick={() => toggleCart(false)}
|
||
className="p-2 hover:bg-pop-black/5 rounded-full transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-pop-black/50 hover:text-pop-black" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Cart Items */}
|
||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||
{items.length === 0 ? (
|
||
<div className="h-full flex flex-col items-center justify-center text-pop-black/50 space-y-4">
|
||
<ShoppingBag className="w-16 h-16 opacity-20" />
|
||
<p>{t('cart.empty')}</p>
|
||
<button
|
||
onClick={() => toggleCart(false)}
|
||
className="text-pop-pink hover:underline tracking-widest uppercase text-xs font-bold"
|
||
>
|
||
{t('cart.continueShopping')}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
items.map((item) => (
|
||
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-pop-bg border border-pop-black/10 shadow-sm group relative">
|
||
<div className="w-18 h-22 rounded-lg bg-pop-black/5 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
|
||
<img
|
||
src={item.productImage || '/images/fallback-product.svg'}
|
||
alt={item.productName}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 flex flex-col pt-1 space-y-1 relative min-w-0">
|
||
<h3 className="font-bold text-sm text-pop-black leading-tight pr-6 truncate">{item.productName}</h3>
|
||
{item.variantSize && (
|
||
<p className="text-xs text-pop-black/60">{item.variantSize}</p>
|
||
)}
|
||
|
||
{/* Price row */}
|
||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||
<span className="text-xs text-pop-black/80">{formatPrice(item.price)}</span>
|
||
<span className="text-xs text-pop-black/60">×</span>
|
||
<span className="text-xs text-pop-black/80">{item.quantity}</span>
|
||
<span className="text-xs text-pop-black/60">=</span>
|
||
<span className="text-sm font-bold text-rose-500">{formatPrice(item.price * item.quantity)}</span>
|
||
</div>
|
||
|
||
{/* Quantity controls */}
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<div className="flex items-center gap-2 bg-pop-black/5 rounded-lg border border-pop-black/5">
|
||
<button
|
||
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
||
className="p-1.5 text-pop-black/50 hover:text-pop-black transition-colors"
|
||
>
|
||
<Minus className="w-3 h-3" />
|
||
</button>
|
||
<span className="w-5 text-center text-xs font-bold text-pop-black">{item.quantity}</span>
|
||
<button
|
||
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
||
className="p-1.5 text-pop-black/50 hover:text-pop-black transition-colors"
|
||
>
|
||
<Plus className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => removeFromCart(item.cartItemId)}
|
||
className="absolute top-0 right-0 p-1.5 text-pop-black/50 hover:text-rose-500 transition-colors"
|
||
title="Remove item"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
{items.length > 0 && (
|
||
<CartDrawerFooter subtotal={getCartTotal()} onClose={() => toggleCart(false)} t={t} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose: () => void; t?: any }) {
|
||
const [savedCoupon, setSavedCoupon] = React.useState<string | null>(null)
|
||
const [discountAmount, setDiscountAmount] = React.useState<number>(0)
|
||
const [isFreeShipping, setIsFreeShipping] = React.useState<boolean>(false)
|
||
const [couponValid, setCouponValid] = React.useState<boolean>(false)
|
||
const [activePromos, setActivePromos] = React.useState<any[]>([])
|
||
|
||
const [couponInput, setCouponInput] = React.useState<string>('')
|
||
const [couponLoading, setCouponLoading] = React.useState<boolean>(false)
|
||
const [couponError, setCouponError] = React.useState<string>('')
|
||
|
||
React.useEffect(() => {
|
||
fetch(`${API_BASE}/promotions/active`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (Array.isArray(data)) setActivePromos(data)
|
||
})
|
||
.catch(console.error)
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
const code = localStorage.getItem('app_active_coupon')
|
||
setSavedCoupon(code)
|
||
if (code && subtotal > 0) {
|
||
fetch(`${API_BASE}/coupons/validate`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, cartTotal: subtotalAfterL2 })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.valid) {
|
||
setCouponValid(true)
|
||
setDiscountAmount(data.discountAmount || 0)
|
||
setIsFreeShipping(data.isFreeShipping || false)
|
||
} else {
|
||
setCouponValid(false)
|
||
}
|
||
})
|
||
.catch(() => setCouponValid(false))
|
||
}
|
||
}, [subtotal, activePromos])
|
||
|
||
const removeCoupon = () => {
|
||
localStorage.removeItem('app_active_coupon')
|
||
setSavedCoupon(null)
|
||
setDiscountAmount(0)
|
||
setIsFreeShipping(false)
|
||
setCouponValid(false)
|
||
setCouponError('')
|
||
setCouponInput('')
|
||
}
|
||
|
||
const handleApplyCoupon = async () => {
|
||
if (!couponInput.trim()) return
|
||
setCouponLoading(true)
|
||
setCouponError('')
|
||
|
||
// We must validate with subtotalAfterL2, but since we don't have it defined here yet,
|
||
// we'll calculate it inline or rely on the effect. Actually, we can just save it to localStorage and let the effect handle it.
|
||
localStorage.setItem('app_active_coupon', couponInput.toUpperCase())
|
||
setSavedCoupon(couponInput.toUpperCase())
|
||
setCouponInput('')
|
||
setCouponLoading(false)
|
||
}
|
||
|
||
// Calculate Level 1 Promo
|
||
const promoLevel1 = activePromos.find(p => p.level === 1 && subtotal >= p.minPurchase)
|
||
const promoLevel1Disc = promoLevel1
|
||
? (promoLevel1.type === 'PERCENTAGE' ? subtotal * (promoLevel1.value / 100) : promoLevel1.value)
|
||
: 0
|
||
|
||
const subtotalAfterL1 = Math.max(0, subtotal - promoLevel1Disc)
|
||
|
||
// Calculate Level 2 Promo
|
||
const promoLevel2 = activePromos.find(p => p.level === 2 && subtotalAfterL1 >= p.minPurchase)
|
||
const promoLevel2Disc = promoLevel2
|
||
? (promoLevel2.type === 'PERCENTAGE' ? subtotalAfterL1 * (promoLevel2.value / 100) : promoLevel2.value)
|
||
: 0
|
||
|
||
const subtotalAfterL2 = Math.max(0, subtotalAfterL1 - promoLevel2Disc)
|
||
const total = Math.max(0, subtotalAfterL2 - discountAmount)
|
||
|
||
return (
|
||
<div className="p-5 border-t border-pop-black/10 bg-pop-bg space-y-4">
|
||
|
||
{/* Coupon Input */}
|
||
{!savedCoupon && (
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={couponInput}
|
||
onChange={e => setCouponInput(e.target.value.toUpperCase())}
|
||
placeholder={t('cart.couponPlaceholder', 'Discount Code')}
|
||
className="flex-1 bg-pop-bg border border-pop-black/20 rounded-lg px-3 py-2 text-sm text-pop-black focus:border-pop-pink outline-none uppercase tracking-widest placeholder:text-pop-black/50"
|
||
/>
|
||
<button
|
||
onClick={handleApplyCoupon}
|
||
disabled={couponLoading || !couponInput.trim()}
|
||
className="px-4 bg-pop-black/5 hover:bg-pop-black/10 border border-pop-black/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
|
||
>
|
||
{couponLoading ? '...' : t('cart.applyCoupon', 'Apply')}
|
||
</button>
|
||
</div>
|
||
{couponError && <span className="text-xs text-rose-500 font-medium">{couponError}</span>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Totals */}
|
||
<div className="space-y-2 text-sm text-pop-black">
|
||
<div className="flex justify-between text-pop-black/60">
|
||
<span>ราคาสินค้า</span>
|
||
<span>{formatPrice(subtotal)}</span>
|
||
</div>
|
||
{promoLevel1Disc > 0 && (
|
||
<div className="flex justify-between text-pop-pink font-medium">
|
||
<span>{promoLevel1?.name}</span>
|
||
<span>- {formatPrice(promoLevel1Disc)}</span>
|
||
</div>
|
||
)}
|
||
{promoLevel2Disc > 0 && (
|
||
<div className="flex justify-between text-pop-pink font-medium">
|
||
<span>{promoLevel2?.name}</span>
|
||
<span>- {formatPrice(promoLevel2Disc)}</span>
|
||
</div>
|
||
)}
|
||
{(discountAmount > 0 || isFreeShipping) && savedCoupon && (
|
||
<div className="flex justify-between text-pop-pink font-medium">
|
||
<span className="flex items-center gap-1.5">
|
||
ส่วนลดคูปอง
|
||
<span className="text-[10px] font-bold bg-pop-pink/10 px-1.5 py-0.5 rounded tracking-widest text-pop-pink">
|
||
{savedCoupon}
|
||
</span>
|
||
<button onClick={removeCoupon} className="text-pop-black/50 hover:text-rose-500 transition-colors" title="ยกเลิก">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
<span className="text-rose-500">{isFreeShipping ? 'ส่งฟรี' : `- ${formatPrice(discountAmount)}`}</span>
|
||
</div>
|
||
)}
|
||
{savedCoupon && !couponValid && (
|
||
<div className="flex justify-between text-amber-600 text-xs font-medium">
|
||
<span className="flex items-center gap-1.5">
|
||
<Tag className="w-3 h-3" />
|
||
คูปอง <span className="font-bold tracking-widest">{savedCoupon}</span> (ยังไม่ถึงยอดขั้นต่ำ)
|
||
<button onClick={removeCoupon} className="text-pop-black/50 hover:text-rose-500 transition-colors">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between font-bold text-base pt-2 border-t border-pop-black/10 text-pop-black">
|
||
<span>รวมสุทธิ</span>
|
||
<span className="text-rose-500">{formatPrice(total)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Checkout button */}
|
||
<Link
|
||
to="/checkout"
|
||
onClick={onClose}
|
||
className="flex items-center justify-center gap-2 w-full py-4 bg-pop-pink text-pop-bg font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-pop-black hover:text-pop-bg hover:shadow-lg transition-all duration-300 group"
|
||
>
|
||
<ShoppingBag className="w-4 h-4" />
|
||
ดำเนินการชำระเงิน
|
||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|