Files
homaroma/apps/spa/src/components/CartDrawer.tsx
T

291 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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()
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-white shadow-2xl flex flex-col animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-spa-stone/10 flex items-center justify-between bg-spa-light">
<h2 className="text-xl font-bold flex items-center gap-2 text-pop-black">
<ShoppingBag className="w-5 h-5 text-spa-forest" />
{t('cart.title')}
<span className="text-sm font-normal text-spa-stone bg-spa-sage 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-spa-sage rounded-full transition-colors"
>
<X className="w-5 h-5 text-spa-stone 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-spa-stone space-y-4">
<ShoppingBag className="w-16 h-16 opacity-20" />
<p>{t('cart.empty')}</p>
<button
onClick={() => toggleCart(false)}
className="text-spa-forest 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-white border border-spa-stone/10 shadow-sm group relative">
<div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
<img
src={item.productImage || '/images/oil_ozone.png'}
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-spa-forest leading-tight pr-6 truncate">{item.productName}</h3>
{item.variantSize && (
<p className="text-xs text-spa-stone">{item.variantSize}</p>
)}
{/* Price row */}
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-xs text-spa-stone/80">{formatPrice(item.price)}</span>
<span className="text-xs text-spa-stone/60">×</span>
<span className="text-xs text-spa-stone/80">{item.quantity}</span>
<span className="text-xs text-spa-stone/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-spa-sage/50 rounded-lg border border-spa-stone/5">
<button
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
className="p-1.5 text-spa-stone hover:text-spa-forest 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-spa-stone hover:text-spa-forest 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-spa-stone/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('homaroma_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('homaroma_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('homaroma_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-spa-stone/10 bg-spa-light space-y-4">
{/* Coupon Input */}
{!savedCoupon && (
<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-white border border-spa-stone/20 rounded-lg px-3 py-2 text-sm text-pop-black focus:border-spa-forest outline-none uppercase tracking-widest placeholder:text-spa-stone/50"
/>
<button
onClick={handleApplyCoupon}
disabled={couponLoading || !couponInput.trim()}
className="px-4 bg-spa-stone/10 hover:bg-spa-stone/20 border border-spa-stone/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>
)}
{/* Totals */}
<div className="space-y-2 text-sm text-pop-black">
<div className="flex justify-between text-spa-stone">
<span>ราคาสินค้า</span>
<span>{formatPrice(subtotal)}</span>
</div>
{promoLevel1Disc > 0 && (
<div className="flex justify-between text-spa-forest font-medium">
<span>{promoLevel1?.name}</span>
<span>- {formatPrice(promoLevel1Disc)}</span>
</div>
)}
{promoLevel2Disc > 0 && (
<div className="flex justify-between text-spa-forest font-medium">
<span>{promoLevel2?.name}</span>
<span>- {formatPrice(promoLevel2Disc)}</span>
</div>
)}
{(discountAmount > 0 || isFreeShipping) && savedCoupon && (
<div className="flex justify-between text-spa-forest font-medium">
<span className="flex items-center gap-1.5">
ส่วนลดคูปอง
<span className="text-[10px] font-bold bg-spa-sage px-1.5 py-0.5 rounded tracking-widest text-spa-forest">
{savedCoupon}
</span>
<button onClick={removeCoupon} className="text-spa-stone/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-spa-stone/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-spa-stone/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-spa-forest text-white font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-spa-stone 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>
)
}