x
This commit is contained in:
1 parent
b7f337fb79
commit
bf9599008e
29 files changed
+3828
-1541
No files matched your search
@@ -3,7 +3,8 @@
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertCircle, CheckCircle2, Loader2, PackageCheck, ShoppingBag, Ticket, UserRound } from 'lucide-react'
|
||||
import { formatPrice, type CartItem, type CustomerAddress } from '@homaroma/types'
|
||||
|
||||
@@ -73,8 +74,11 @@ function calculatePromotionDiscount(promotion: Promotion | undefined, amount: nu
|
||||
return Math.min(promotion.value, amount)
|
||||
}
|
||||
|
||||
function formatAddress(address: CustomerAddress) {
|
||||
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
|
||||
function formatAddress(address: CustomerAddress, isThai = true) {
|
||||
if (isThai) {
|
||||
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
|
||||
}
|
||||
return `${address.addressLine}, ${address.subdistrict}, ${address.district}, ${address.province} ${address.zipcode}`
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -117,10 +121,28 @@ function saveLocalOrder(orderId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function CheckoutClient() {
|
||||
export default function CheckoutClient({ lang: propLang }: { lang?: string } = {}) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const routeLang = useLang()
|
||||
const lang = propLang || routeLang
|
||||
const { t: i18nT, i18n } = useTranslation()
|
||||
const isThai = lang === 'th'
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && i18n.language !== lang) {
|
||||
void i18n.changeLanguage(lang)
|
||||
}
|
||||
}, [lang, i18n])
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
return i18nT(key, { lng: lang, ...options })
|
||||
},
|
||||
[i18nT, lang],
|
||||
)
|
||||
|
||||
const { customer } = useAuthStore()
|
||||
const customerId = customer?.id
|
||||
const { items, clearCart, getCartTotal } = useCartStore()
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [shippingFee, setShippingFee] = useState(35)
|
||||
@@ -171,6 +193,7 @@ export default function CheckoutClient() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadCheckoutData = async () => {
|
||||
setLoadingSettings(true)
|
||||
try {
|
||||
@@ -179,16 +202,23 @@ export default function CheckoutClient() {
|
||||
readJson<Promotion[]>('/promotions/active'),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (typeof settings.shippingFee === 'number') setShippingFee(settings.shippingFee)
|
||||
setActivePromotions(Array.isArray(promotions) ? promotions : [])
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถโหลดข้อมูล checkout ได้')
|
||||
if (cancelled) return
|
||||
setSubmitError(error instanceof Error ? error.message : t('checkout.errors.loadFailed'))
|
||||
} finally {
|
||||
setLoadingSettings(false)
|
||||
if (!cancelled) {
|
||||
setLoadingSettings(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCheckoutData()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -237,7 +267,7 @@ export default function CheckoutClient() {
|
||||
}
|
||||
|
||||
void loadCustomerData()
|
||||
}, [customer])
|
||||
}, [customerId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return
|
||||
@@ -283,7 +313,7 @@ export default function CheckoutClient() {
|
||||
})
|
||||
|
||||
if (!data.valid || !data.coupon?.code) {
|
||||
throw new Error(data.error || 'คูปองนี้ไม่สามารถใช้ได้')
|
||||
throw new Error(data.error || t('checkout.errors.invalidCoupon'))
|
||||
}
|
||||
|
||||
setAppliedCoupon(data.coupon.code)
|
||||
@@ -294,7 +324,7 @@ export default function CheckoutClient() {
|
||||
setAppliedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
setCouponError(error instanceof Error ? error.message : 'ไม่สามารถตรวจสอบคูปองได้')
|
||||
setCouponError(error instanceof Error ? error.message : t('checkout.errors.validateCouponFailed'))
|
||||
} finally {
|
||||
setCouponLoading(false)
|
||||
}
|
||||
@@ -326,12 +356,15 @@ export default function CheckoutClient() {
|
||||
const resolveShippingAddress = () => {
|
||||
if (selectedAddressId && selectedAddressId !== 'new') {
|
||||
const address = savedAddresses.find((item) => item.id === selectedAddressId)
|
||||
if (address) return formatAddress(address)
|
||||
if (address) return formatAddress(address, isThai)
|
||||
}
|
||||
|
||||
const values = Object.values(addressForm).map((value) => value.trim())
|
||||
if (values.some((value) => !value)) return ''
|
||||
return `${addressForm.addressLine.trim()} ต.${addressForm.subdistrict.trim()} อ.${addressForm.district.trim()} จ.${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
|
||||
if (isThai) {
|
||||
return `${addressForm.addressLine.trim()} ต.${addressForm.subdistrict.trim()} อ.${addressForm.district.trim()} จ.${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
|
||||
}
|
||||
return `${addressForm.addressLine.trim()}, ${addressForm.subdistrict.trim()}, ${addressForm.district.trim()}, ${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
|
||||
}
|
||||
|
||||
const createOrder = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -339,13 +372,13 @@ export default function CheckoutClient() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (items.length === 0) {
|
||||
setSubmitError('ตะกร้าสินค้าว่าง กรุณาเลือกสินค้าก่อน checkout')
|
||||
setSubmitError(t('checkout.errors.emptyCart'))
|
||||
return
|
||||
}
|
||||
|
||||
const shippingAddress = resolveShippingAddress()
|
||||
if (!shippingAddress) {
|
||||
setSubmitError('กรุณากรอกที่อยู่จัดส่งให้ครบถ้วน')
|
||||
setSubmitError(t('checkout.errors.missingAddress'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,10 +410,15 @@ export default function CheckoutClient() {
|
||||
})
|
||||
|
||||
clearCart()
|
||||
try {
|
||||
window.localStorage.removeItem('app_active_coupon')
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
saveLocalOrder(order.id)
|
||||
router.push(getPathWithLocale(`/order/${order.id}`, lang))
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถสร้างคำสั่งซื้อได้')
|
||||
setSubmitError(error instanceof Error ? error.message : t('checkout.errors.createOrderFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -390,7 +428,7 @@ export default function CheckoutClient() {
|
||||
return (
|
||||
<div className="ui-panel p-10 text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand-primary" />
|
||||
<p className="mt-4 text-sm font-semibold text-muted-text">กำลังโหลดตะกร้าสินค้า...</p>
|
||||
<p className="mt-4 text-sm font-semibold text-muted-text">{t('checkout.loadingCart')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -399,10 +437,10 @@ export default function CheckoutClient() {
|
||||
return (
|
||||
<div className="ui-panel mx-auto max-w-xl p-10 text-center">
|
||||
<ShoppingBag className="mx-auto h-14 w-14 text-brand-primary" />
|
||||
<h2 className="mt-5 text-3xl font-semibold text-text-main">ตะกร้าสินค้าว่าง</h2>
|
||||
<p className="mt-3 text-muted-text">เลือกสินค้าที่คุณชอบก่อน แล้วกลับมายืนยันคำสั่งซื้อที่หน้านี้ได้เลย</p>
|
||||
<h2 className="mt-5 text-3xl font-semibold text-text-main">{t('checkout.emptyCartTitle')}</h2>
|
||||
<p className="mt-3 text-muted-text">{t('checkout.emptyCartDesc')}</p>
|
||||
<Link href="/shop" className="mt-8 inline-flex rounded-full bg-text-main px-8 py-4 text-sm font-bold uppercase tracking-[0.16em] text-surface-card transition hover:bg-brand-primary">
|
||||
กลับไปเลือกสินค้า
|
||||
{t('checkout.continueShopping')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
@@ -418,43 +456,43 @@ export default function CheckoutClient() {
|
||||
<div className="ui-panel-header">
|
||||
<UserRound className="h-5 w-5 text-brand-primary" />
|
||||
<div>
|
||||
<h2 className="ui-panel-title">ข้อมูลผู้รับสินค้า</h2>
|
||||
<p className="ui-panel-subtitle">ข้อมูลนี้จะถูกส่งไปสร้างคำสั่งซื้อตาม contract ของ backend</p>
|
||||
<h2 className="ui-panel-title">{t('checkout.recipientInfo.title')}</h2>
|
||||
<p className="ui-panel-subtitle">{t('checkout.recipientInfo.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">ชื่อผู้รับ *</span>
|
||||
<span className="ui-form-label">{t('checkout.recipientInfo.name')}</span>
|
||||
<input
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, name: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="ชื่อ-นามสกุล"
|
||||
placeholder={t('checkout.recipientInfo.namePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">เบอร์โทรศัพท์ *</span>
|
||||
<span className="ui-form-label">{t('checkout.recipientInfo.phone')}</span>
|
||||
<input
|
||||
required
|
||||
pattern="[0-9]{9,10}"
|
||||
title="กรุณากรอกเบอร์โทรศัพท์ 9-10 หลัก"
|
||||
title={t('checkout.recipientInfo.phoneValidation')}
|
||||
value={formData.phone}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, phone: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="0812345678"
|
||||
placeholder={t('checkout.recipientInfo.phonePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="ui-form-label">อีเมล *</span>
|
||||
<span className="ui-form-label">{t('checkout.recipientInfo.email')}</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, email: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('checkout.recipientInfo.emailPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -464,8 +502,8 @@ export default function CheckoutClient() {
|
||||
<div className="ui-panel-header">
|
||||
<PackageCheck className="h-5 w-5 text-brand-primary" />
|
||||
<div>
|
||||
<h2 className="ui-panel-title">ที่อยู่จัดส่ง</h2>
|
||||
<p className="ui-panel-subtitle">สมาชิกสามารถเลือกที่อยู่ที่บันทึกไว้ หรือกรอกที่อยู่ใหม่ได้</p>
|
||||
<h2 className="ui-panel-title">{t('checkout.shippingAddress.title')}</h2>
|
||||
<p className="ui-panel-subtitle">{t('checkout.shippingAddress.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -492,12 +530,12 @@ export default function CheckoutClient() {
|
||||
{address.recipientName}
|
||||
{address.isDefault && (
|
||||
<span className="ml-2 rounded-full bg-brand-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-brand-primary">
|
||||
default
|
||||
{t('checkout.shippingAddress.defaultBadge')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-text">{address.phone}</span>
|
||||
<span className="mt-1 block text-sm text-text-main/80">{formatAddress(address)}</span>
|
||||
<span className="mt-1 block text-sm text-text-main/80">{formatAddress(address, isThai)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -513,7 +551,7 @@ export default function CheckoutClient() {
|
||||
onChange={() => selectAddress('new')}
|
||||
className="accent-brand-primary"
|
||||
/>
|
||||
<span className="text-sm font-bold text-text-main">กรอกที่อยู่ใหม่</span>
|
||||
<span className="text-sm font-bold text-text-main">{t('checkout.shippingAddress.addNewAddress')}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -521,17 +559,17 @@ export default function CheckoutClient() {
|
||||
{(savedAddresses.length === 0 || selectedAddressId === 'new') && (
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="ui-form-label">บ้านเลขที่ / ถนน / ซอย *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.addressLine')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.addressLine}
|
||||
onChange={(event) => setAddressForm((current) => ({ ...current, addressLine: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="บ้านเลขที่ หมู่บ้าน ถนน ซอย"
|
||||
placeholder={t('checkout.shippingAddress.addressLinePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">ตำบล/แขวง *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.subdistrict')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.subdistrict}
|
||||
@@ -540,7 +578,7 @@ export default function CheckoutClient() {
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">อำเภอ/เขต *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.district')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.district}
|
||||
@@ -549,7 +587,7 @@ export default function CheckoutClient() {
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">จังหวัด *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.province')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.province}
|
||||
@@ -558,13 +596,13 @@ export default function CheckoutClient() {
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">รหัสไปรษณีย์ *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.zipcode')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.zipcode}
|
||||
onChange={(event) => setAddressForm((current) => ({ ...current, zipcode: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="10110"
|
||||
placeholder={t('checkout.shippingAddress.zipcodePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -585,7 +623,7 @@ export default function CheckoutClient() {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{submitting && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
สร้างคำสั่งซื้อ
|
||||
{t('checkout.summary.placeOrder')}
|
||||
</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</button>
|
||||
@@ -599,8 +637,8 @@ export default function CheckoutClient() {
|
||||
className="flex w-full items-center justify-between border-b border-line-primary pb-5 text-left lg:pointer-events-none"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-xl font-semibold text-text-main">สรุปรายการ</span>
|
||||
<span className="mt-1 block text-sm text-muted-text">{items.length} รายการในตะกร้า</span>
|
||||
<span className="block text-xl font-semibold text-text-main">{t('checkout.summary.title')}</span>
|
||||
<span className="mt-1 block text-sm text-muted-text">{t('checkout.summary.itemsCount', { count: items.length })}</span>
|
||||
</span>
|
||||
<span className="font-bold text-brand-primary lg:hidden">{formatPrice(total)}</span>
|
||||
</button>
|
||||
@@ -620,7 +658,7 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-text-main">{item.productName}</p>
|
||||
<p className="mt-1 text-xs text-muted-text">{item.variantName || 'Standard'} x {item.quantity}</p>
|
||||
<p className="mt-1 text-xs text-muted-text">{item.variantName || t('checkout.summary.standardVariant')} x {item.quantity}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-brand-primary">{formatPrice(item.price * item.quantity)}</p>
|
||||
</div>
|
||||
@@ -630,12 +668,12 @@ export default function CheckoutClient() {
|
||||
<div className="border-t border-line-primary pt-5">
|
||||
{!appliedCoupon ? (
|
||||
<div className="space-y-2">
|
||||
<label className="ui-form-label">คูปองส่วนลด</label>
|
||||
<label className="ui-form-label">{t('checkout.summary.couponLabel')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={couponCode}
|
||||
onChange={(event) => setCouponCode(event.target.value.toUpperCase())}
|
||||
placeholder="COUPON"
|
||||
placeholder={t('checkout.summary.couponPlaceholder')}
|
||||
className="ui-form-shell min-w-0 flex-1 uppercase tracking-widest"
|
||||
/>
|
||||
<button
|
||||
@@ -644,7 +682,7 @@ export default function CheckoutClient() {
|
||||
disabled={couponLoading || !couponCode.trim()}
|
||||
className="rounded-2xl bg-text-main px-4 py-3 text-xs font-bold uppercase tracking-widest text-surface-card transition hover:bg-brand-primary disabled:opacity-50"
|
||||
>
|
||||
{couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ใช้'}
|
||||
{couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : t('checkout.summary.applyCoupon')}
|
||||
</button>
|
||||
</div>
|
||||
{couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>}
|
||||
@@ -656,7 +694,7 @@ export default function CheckoutClient() {
|
||||
{appliedCoupon}
|
||||
</span>
|
||||
<button type="button" onClick={removeCoupon} className="text-xs font-bold uppercase tracking-widest text-muted-text hover:text-rose-500">
|
||||
ลบ
|
||||
{t('checkout.summary.removeCoupon')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -665,8 +703,8 @@ export default function CheckoutClient() {
|
||||
{customerPoints > 0 && (
|
||||
<div className="space-y-3 border-t border-line-primary pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-muted-text">คะแนนสะสม</span>
|
||||
<span className="text-sm font-bold text-text-main">{customerPoints} points</span>
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-muted-text">{t('checkout.summary.pointsLabel')}</span>
|
||||
<span className="text-sm font-bold text-text-main">{t('checkout.summary.pointsBalance', { count: customerPoints })}</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line-primary bg-surface-page p-4 text-sm font-semibold text-text-main">
|
||||
<input
|
||||
@@ -679,7 +717,7 @@ export default function CheckoutClient() {
|
||||
className="accent-brand-primary"
|
||||
disabled={maxRedeemablePoints <= 0}
|
||||
/>
|
||||
ใช้คะแนนเป็นส่วนลด
|
||||
{t('checkout.summary.usePoints')}
|
||||
</label>
|
||||
{usePoints && (
|
||||
<input
|
||||
@@ -696,7 +734,7 @@ export default function CheckoutClient() {
|
||||
|
||||
<div className="space-y-3 border-t border-line-primary pt-5 text-sm">
|
||||
<div className="flex justify-between text-muted-text">
|
||||
<span>Subtotal</span>
|
||||
<span>{t('checkout.summary.subtotal')}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
{promoLevel1Disc > 0 && (
|
||||
@@ -713,18 +751,18 @@ export default function CheckoutClient() {
|
||||
)}
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>Coupon {appliedCoupon ? `(${appliedCoupon})` : ''}</span>
|
||||
<span>{t('checkout.summary.coupon')} {appliedCoupon ? `(${appliedCoupon})` : ''}</span>
|
||||
<span>-{formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{pointsDiscount > 0 && (
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>Points</span>
|
||||
<span>{t('checkout.summary.points')}</span>
|
||||
<span>-{formatPrice(pointsDiscount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-muted-text">
|
||||
<span>Shipping</span>
|
||||
<span>{t('checkout.summary.shipping')}</span>
|
||||
{loadingSettings ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -733,12 +771,12 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
{isFreeShipping && (
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>Free shipping</span>
|
||||
<span>{t('checkout.summary.freeShipping')}</span>
|
||||
<span>-{formatPrice(shippingFee)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t border-line-primary pt-4 text-lg font-bold text-text-main">
|
||||
<span>Total</span>
|
||||
<span>{t('checkout.summary.total')}</span>
|
||||
<span className="text-brand-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -747,7 +785,7 @@ export default function CheckoutClient() {
|
||||
<div className="flex gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
รอบนี้จะสร้างคำสั่งซื้อสถานะรอชำระเงิน แล้วพาไปหน้า Order Detail ส่วน payment/slip upload ยังเป็นงานถัดไปตามแผน
|
||||
{t('checkout.summary.notice')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -760,7 +798,7 @@ export default function CheckoutClient() {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{submitting && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
สร้างคำสั่งซื้อ
|
||||
{t('checkout.summary.placeOrder')}
|
||||
</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface FooterProps {
|
||||
exploreMenu?: MenuItem[]
|
||||
supportMenu?: MenuItem[]
|
||||
settings?: StoreSettings | null
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const layoutClassMaps = {
|
||||
@@ -48,12 +49,11 @@ export default function Footer({
|
||||
exploreMenu = [],
|
||||
supportMenu = [],
|
||||
settings = null,
|
||||
lang: propLang,
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const lang = useLang()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { queueMicrotask(() => setMounted(true)) }, [])
|
||||
const effectiveLang = mounted ? lang : 'th'
|
||||
const routeLang = useLang()
|
||||
const effectiveLang = propLang || routeLang || 'th'
|
||||
|
||||
const translateMenuLabel = (item: MenuItem): string => {
|
||||
if (effectiveLang !== 'en' && item.translations?.length) {
|
||||
@@ -72,7 +72,7 @@ export default function Footer({
|
||||
'Track Order': 'nav.trackOrder',
|
||||
'Delivery & Returns': 'nav.delivery',
|
||||
}
|
||||
return map[item.label] ? t(map[item.label]) : item.label
|
||||
return map[item.label] ? t(map[item.label], { lng: effectiveLang }) : item.label
|
||||
}
|
||||
|
||||
const footerLayout = normalizeFooterLayout(settings?.footerLayout)
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Landmark,
|
||||
Loader2,
|
||||
QrCode,
|
||||
UploadCloud,
|
||||
} from 'lucide-react'
|
||||
import { formatPrice, type Order } from '@homaroma/types'
|
||||
|
||||
import { API_BASE } from '../lib/api'
|
||||
import { toneClass } from '../lib/theme'
|
||||
|
||||
interface BankSettings {
|
||||
bankName?: string
|
||||
bankAccountName?: string
|
||||
bankAccountNumber?: string
|
||||
}
|
||||
|
||||
interface OrderPaymentClientProps {
|
||||
initialOrder: Order
|
||||
bankSettings: BankSettings | null
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export default function OrderPaymentClient({
|
||||
initialOrder,
|
||||
bankSettings,
|
||||
lang = 'th',
|
||||
}: OrderPaymentClientProps) {
|
||||
const { t: i18nT } = useTranslation()
|
||||
const t = useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
return i18nT(key, { lng: lang, ...options })
|
||||
},
|
||||
[i18nT, lang],
|
||||
)
|
||||
|
||||
const [order, setOrder] = useState<Order>(initialOrder)
|
||||
const [paymentMethod, setPaymentMethod] = useState<'bank' | 'promptpay'>('bank')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
|
||||
const [generatingQr, setGeneratingQr] = useState(false)
|
||||
const [qrError, setQrError] = useState<string | null>(null)
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
// Polling for automated payment status verification (e.g. PromptPay or Admin approval)
|
||||
useEffect(() => {
|
||||
if (!polling || order.status !== 'PENDING_PAYMENT') return
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/orders/${order.id}`)
|
||||
if (!res.ok) return
|
||||
const updatedOrder = (await res.json()) as Order
|
||||
if (updatedOrder && updatedOrder.status === 'PAID') {
|
||||
setOrder(updatedOrder)
|
||||
setPolling(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[website-order] Polling status check error', err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [polling, order.id, order.status])
|
||||
|
||||
const copyAccountNumber = async () => {
|
||||
if (!bankSettings?.bankAccountNumber) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(bankSettings.bankAccountNumber)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.warn('[website-order] Failed to copy account number', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError(t('order.payment.supportFormat'))
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
// 1. Upload the image file
|
||||
const uploadRes = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error(t('order.payment.uploadFailed'))
|
||||
}
|
||||
|
||||
const uploadData = (await uploadRes.json()) as { url?: string }
|
||||
if (!uploadData.url) {
|
||||
throw new Error(t('order.payment.uploadFailed'))
|
||||
}
|
||||
|
||||
// 2. Attach slip to the order
|
||||
const updateRes = await fetch(`${API_BASE}/orders/${order.id}/slip`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentSlipUrl: uploadData.url }),
|
||||
})
|
||||
|
||||
if (!updateRes.ok) {
|
||||
throw new Error(t('order.payment.uploadFailed'))
|
||||
}
|
||||
|
||||
const updatedOrder = (await updateRes.json()) as Order
|
||||
setOrder(updatedOrder)
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : t('order.payment.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGeneratePromptPay = async () => {
|
||||
setGeneratingQr(true)
|
||||
setQrError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/payments/charge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orderId: order.id,
|
||||
amount: order.totalAmount,
|
||||
type: 'promptpay',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = (await res.json()) as { success?: boolean; qrCodeUrl?: string; error?: string }
|
||||
if (res.ok && data.success && data.qrCodeUrl) {
|
||||
setQrCodeUrl(data.qrCodeUrl)
|
||||
setPolling(true)
|
||||
} else {
|
||||
setQrError(data.error || t('order.payment.failedGenerateQr'))
|
||||
}
|
||||
} catch (err) {
|
||||
setQrError(err instanceof Error ? err.message : t('order.payment.failedGenerateQr'))
|
||||
} finally {
|
||||
setGeneratingQr(false)
|
||||
}
|
||||
}
|
||||
|
||||
// If already paid, show confirmation block
|
||||
if (order.status === 'PAID') {
|
||||
return (
|
||||
<section className="ui-panel p-8 text-center border border-emerald-500/20 bg-emerald-500/5">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-600">
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-text-main">{t('order.payment.paymentComplete')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-text">Status: {t('order.status.PAID')}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
{/* Method Selector Tabs */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('bank')}
|
||||
className={`flex items-center justify-center gap-3 rounded-2xl p-4 text-sm font-bold transition-all border ${
|
||||
paymentMethod === 'bank'
|
||||
? 'border-brand-primary bg-surface-card text-brand-primary shadow-card ring-1 ring-brand-primary'
|
||||
: 'border-line-primary bg-surface-card text-muted-text hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Landmark className="h-5 w-5" />
|
||||
<span>{t('order.payment.methodBank')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('promptpay')}
|
||||
className={`flex items-center justify-center gap-3 rounded-2xl p-4 text-sm font-bold transition-all border ${
|
||||
paymentMethod === 'promptpay'
|
||||
? 'border-brand-primary bg-surface-card text-brand-primary shadow-card ring-1 ring-brand-primary'
|
||||
: 'border-line-primary bg-surface-card text-muted-text hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<QrCode className="h-5 w-5" />
|
||||
<span>{t('order.payment.methodPromptPay')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Panel */}
|
||||
{paymentMethod === 'bank' && (
|
||||
<div className="space-y-6">
|
||||
<div className="ui-panel p-6 md:p-8 space-y-6 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-muted-text">
|
||||
{t('order.payment.required')}
|
||||
</p>
|
||||
<div className="text-3xl md:text-4xl font-extrabold text-brand-primary">
|
||||
{formatPrice(order.totalAmount)}
|
||||
</div>
|
||||
|
||||
{bankSettings ? (
|
||||
<div className="mx-auto max-w-md rounded-2xl border border-line-primary/40 bg-surface-page p-6 text-left space-y-4">
|
||||
{bankSettings.bankName && (
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-text block">
|
||||
{t('order.payment.bank')}
|
||||
</span>
|
||||
<span className="text-base font-bold text-text-main block mt-0.5">
|
||||
{bankSettings.bankName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bankSettings.bankAccountNumber && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-text block">
|
||||
{t('order.payment.accountNumber')}
|
||||
</span>
|
||||
<span className="font-mono text-xl md:text-2xl font-bold tracking-wider text-brand-primary block mt-0.5">
|
||||
{bankSettings.bankAccountNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAccountNumber}
|
||||
title={t('order.payment.copyAccount')}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-line-primary bg-surface-card px-3.5 py-2 text-xs font-semibold text-text-main transition hover:border-brand-primary hover:text-brand-primary shadow-sm"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-emerald-600">{t('order.payment.copied')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{t('order.payment.copyAccount')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bankSettings.bankAccountName && (
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-text block">
|
||||
{t('order.payment.accountName')}
|
||||
</span>
|
||||
<span className="text-base font-bold text-text-main block mt-0.5">
|
||||
{bankSettings.bankAccountName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-text">
|
||||
{t('order.payment.transferInstruction')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Slip Upload Area */}
|
||||
<div className="ui-panel p-6 md:p-8 space-y-4">
|
||||
<h3 className="border-b border-line-primary/20 pb-3 text-sm font-bold uppercase tracking-[0.2em] text-brand-primary">
|
||||
{t('order.payment.slipVerification')}
|
||||
</h3>
|
||||
|
||||
{uploadError && (
|
||||
<div className={`p-4 rounded-xl text-sm flex items-center gap-3 ${toneClass('error')}`}>
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.paymentSlipUrl ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 py-4 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-emerald-600">
|
||||
{t('order.payment.slipUploaded')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-text">
|
||||
{t('order.payment.slipUploadedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative mt-2 max-w-xs overflow-hidden rounded-xl border border-line-primary shadow-card">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={order.paymentSlipUrl}
|
||||
alt="Payment Slip Proof"
|
||||
className="max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<label className="cursor-pointer text-xs font-bold text-brand-primary underline hover:opacity-80">
|
||||
<span>{t('order.payment.dropSlip')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="relative flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-line-primary/80 bg-surface-card p-10 text-center transition hover:border-brand-primary group">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
/>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-brand-primary" />
|
||||
) : (
|
||||
<UploadCloud className="h-10 w-10 text-muted-text transition group-hover:text-brand-primary" />
|
||||
)}
|
||||
<span className="mt-3 block text-sm font-bold text-text-main">
|
||||
{uploading ? t('order.payment.uploading') : t('order.payment.dropSlip')}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-text">
|
||||
{t('order.payment.supportFormat')}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PromptPay QR Panel */}
|
||||
{paymentMethod === 'promptpay' && (
|
||||
<div className="ui-panel p-6 md:p-8 space-y-6 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-muted-text">
|
||||
{t('order.payment.promptPayTitle')}
|
||||
</p>
|
||||
<div className="text-3xl md:text-4xl font-extrabold text-brand-primary">
|
||||
{formatPrice(order.totalAmount)}
|
||||
</div>
|
||||
|
||||
{qrError && (
|
||||
<div className={`p-4 rounded-xl text-sm flex items-center justify-center gap-3 ${toneClass('error')}`}>
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<span>{qrError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!qrCodeUrl ? (
|
||||
<div className="py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePromptPay}
|
||||
disabled={generatingQr}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand-primary px-8 py-3.5 text-sm font-bold text-surface-card shadow-card transition hover:bg-brand-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{generatingQr ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t('order.payment.generatingQr')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<QrCode className="h-5 w-5" />
|
||||
<span>{t('order.payment.generatePromptPay')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-sm mx-auto">
|
||||
<div className="inline-block rounded-2xl border border-line-primary/60 bg-surface-card p-4 shadow-card">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="PromptPay QR Code"
|
||||
className="h-64 w-64 object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-main">
|
||||
{t('order.payment.scanPromptPay')}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-text">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
<span>Checking payment status...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,12 @@ interface ProductCardProps {
|
||||
product: Product
|
||||
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch'
|
||||
badgeText?: string | null
|
||||
layout?: 'grid' | 'list'
|
||||
showPrice?: boolean
|
||||
showAddToCart?: boolean
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, cardStyle = 'standard', badgeText }: ProductCardProps) {
|
||||
export default function ProductCard({ product, cardStyle = 'standard', badgeText, layout = 'grid', showPrice = true, showAddToCart = true }: ProductCardProps) {
|
||||
const lang = useLang()
|
||||
const { addToCart } = useCartStore()
|
||||
const selectedVariant = product.variants?.[0] || null
|
||||
@@ -192,6 +195,80 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'list') {
|
||||
return (
|
||||
<div className="ui-card ui-card-hover group flex cursor-pointer flex-col sm:flex-row overflow-hidden p-3 gap-5 items-stretch">
|
||||
<div className="relative aspect-square sm:w-56 sm:h-56 shrink-0 overflow-hidden rounded-xl bg-surface-page">
|
||||
<div className="absolute right-2 top-2 z-20">
|
||||
<WishlistButton product={product} />
|
||||
</div>
|
||||
{categoryName && (
|
||||
<span className="ui-chip absolute left-3 top-3 z-20 border-line-primary/20 bg-surface-card/90 px-2.5 py-1 text-[10px] text-text-main shadow-sm backdrop-blur-sm pointer-events-none">
|
||||
{categoryName}
|
||||
</span>
|
||||
)}
|
||||
<Link href={`/products/${productSlug}`} className="block h-full w-full">
|
||||
<Image
|
||||
src={images[0]}
|
||||
alt={productName}
|
||||
fill
|
||||
unoptimized
|
||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between py-1">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-bold leading-tight text-text-main transition-colors group-hover:text-brand-primary">
|
||||
<Link href={`/products/${productSlug}`}>{productName}</Link>
|
||||
</h3>
|
||||
{categoryName && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-widest text-muted-text sm:text-xs">
|
||||
{categoryName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-base sm:text-xl font-black text-brand-primary shrink-0">
|
||||
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{colorSwatches.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
{colorSwatches.slice(0, 8).map(({ colorCode, variant, label }) => (
|
||||
<span
|
||||
key={variant.id || colorCode}
|
||||
className="relative h-4 w-4 rounded-full border border-black/15 dark:border-white/20 shadow-sm shrink-0"
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={label || colorCode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center justify-between border-t border-line-primary/10 mt-4">
|
||||
<span className="text-xs text-muted-text">
|
||||
{inStock ? '✓ พร้อมส่ง (In Stock)' : '⏳ สั่งผลิต (Pre-Order)'}
|
||||
</span>
|
||||
<Link href={`/products/${productSlug}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-button-primary px-5 py-2 text-xs font-bold gap-1.5 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
<span>{addToCartText}</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-surface-page">
|
||||
@@ -282,13 +359,16 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
|
||||
)) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||
{showPrice && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-black text-brand-primary md:text-lg">
|
||||
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product.variants && product.variants.length > 1 ? (
|
||||
{showAddToCart && (
|
||||
product.variants && product.variants.length > 1 ? (
|
||||
<Link href={`/products/${productSlug}`} className="block w-full">
|
||||
<button
|
||||
type="button"
|
||||
@@ -321,7 +401,8 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
{addToCartText}
|
||||
</button>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import type { Product, CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
import CategoryFilterSidebar, { type FilterState, type DynamicFacet, type DynamicFacetOption } from './CategoryFilterSidebar'
|
||||
import CategoryFilterHorizontalBar from './CategoryFilterHorizontalBar'
|
||||
import CategoryToolbar from './CategoryToolbar'
|
||||
import { FilterX, X } from 'lucide-react'
|
||||
|
||||
@@ -14,6 +15,9 @@ interface CategoryCatalogContainerProps {
|
||||
availableFilters?: AttributeDefinition[]
|
||||
layoutGridClass?: string
|
||||
lang?: string
|
||||
showFilters?: boolean
|
||||
showToolbar?: boolean
|
||||
itemsPerPage?: number
|
||||
}
|
||||
|
||||
function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
|
||||
@@ -105,6 +109,9 @@ export default function CategoryCatalogContainer({
|
||||
availableFilters = [],
|
||||
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
|
||||
lang = 'th',
|
||||
showFilters = true,
|
||||
showToolbar = true,
|
||||
itemsPerPage,
|
||||
}: CategoryCatalogContainerProps) {
|
||||
// Determine price bounds from products
|
||||
const priceBounds = useMemo<[number, number]>(() => {
|
||||
@@ -297,121 +304,218 @@ export default function CategoryCatalogContainer({
|
||||
})
|
||||
}, [products, filters, sortBy, dynamicFacets, lang])
|
||||
|
||||
return (
|
||||
<div id="products" className="pt-6 pb-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
{/* Left Column: Filter Sidebar (Desktop) */}
|
||||
<div className="hidden lg:block lg:col-span-3 sticky top-24">
|
||||
<CategoryFilterSidebar
|
||||
filters={filters}
|
||||
onChangeFilters={setFilters}
|
||||
onReset={() => setFilters(initialFilters)}
|
||||
config={filterConfig}
|
||||
priceBounds={priceBounds}
|
||||
stockCounts={stockCounts}
|
||||
dynamicFacets={dynamicFacets}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
const shouldShowFilters = showFilters !== false && filterConfig?.showFilters !== false
|
||||
const shouldShowToolbar = showToolbar !== false && filterConfig?.showToolbar !== false
|
||||
const isTopBarFilter = filterConfig?.filterPosition === 'top_bar'
|
||||
|
||||
{/* Right Column: Catalog Grid & Toolbar */}
|
||||
<div className="lg:col-span-9 space-y-6">
|
||||
<CategoryToolbar
|
||||
categoryName={categoryName}
|
||||
totalProducts={products.length}
|
||||
displayedProducts={filteredProducts.length}
|
||||
sortBy={sortBy}
|
||||
onChangeSortBy={setSortBy}
|
||||
viewMode={viewMode}
|
||||
onChangeViewMode={setViewMode}
|
||||
onOpenMobileFilter={() => setIsMobileFilterOpen(true)}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
{/* Active Filter Pills Bar */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2">
|
||||
<span className="text-xs text-muted-text font-medium">Active Filters:</span>
|
||||
{filters.stockStatus.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-primary/10 text-brand-primary border border-brand-primary/20"
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [filters, sortBy])
|
||||
|
||||
const effectivePageSize = itemsPerPage || 24
|
||||
const totalPages = Math.ceil(filteredProducts.length / effectivePageSize)
|
||||
const paginatedProducts = useMemo(() => {
|
||||
if (!itemsPerPage) return filteredProducts
|
||||
const start = (currentPage - 1) * effectivePageSize
|
||||
return filteredProducts.slice(start, start + effectivePageSize)
|
||||
}, [filteredProducts, currentPage, effectivePageSize, itemsPerPage])
|
||||
|
||||
const renderProductList = () => (
|
||||
<>
|
||||
{shouldShowToolbar && (
|
||||
<CategoryToolbar
|
||||
categoryName={categoryName}
|
||||
totalProducts={products.length}
|
||||
displayedProducts={filteredProducts.length}
|
||||
sortBy={sortBy}
|
||||
onChangeSortBy={setSortBy}
|
||||
viewMode={viewMode}
|
||||
onChangeViewMode={setViewMode}
|
||||
onOpenMobileFilter={() => setIsMobileFilterOpen(true)}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Active Filter Pills Bar */}
|
||||
{shouldShowFilters && activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2">
|
||||
<span className="text-xs text-muted-text font-medium">Active Filters:</span>
|
||||
{filters.stockStatus.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-primary/10 text-brand-primary border border-brand-primary/20"
|
||||
>
|
||||
<span>{s === 'in_stock' ? 'In Stock' : 'Pre-Order'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
stockStatus: filters.stockStatus.filter((item) => item !== s),
|
||||
})
|
||||
}
|
||||
className="hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{Object.entries(filters.attributes).map(([code, vals]) =>
|
||||
vals.map((v) => (
|
||||
<span
|
||||
key={`${code}-${v}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-surface-card border border-line-primary/30 text-text-main"
|
||||
>
|
||||
<span className="text-[10px] text-muted-text font-mono uppercase">{code}:</span>
|
||||
<span>{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveActiveAttribute(code, v)}
|
||||
className="text-muted-text hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<span>{s === 'in_stock' ? 'In Stock' : 'Pre-Order'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
stockStatus: filters.stockStatus.filter((item) => item !== s),
|
||||
})
|
||||
}
|
||||
className="hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{Object.entries(filters.attributes).map(([code, vals]) =>
|
||||
vals.map((v) => (
|
||||
<span
|
||||
key={`${code}-${v}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-surface-card border border-line-primary/30 text-text-main"
|
||||
>
|
||||
<span className="text-[10px] text-muted-text font-mono uppercase">{code}:</span>
|
||||
<span>{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveActiveAttribute(code, v)}
|
||||
className="text-muted-text hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="text-xs text-muted-text hover:text-brand-primary underline ml-2 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-line-primary/20 rounded-3xl space-y-3 bg-surface-card/30">
|
||||
<FilterX className="w-10 h-10 text-muted-text/50 mx-auto" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-bold text-text-main">No products found matching active filters</p>
|
||||
<p className="text-xs text-muted-text">
|
||||
Try adjusting your price range or clearing selected attribute filters.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="mt-3 px-4 py-2 rounded-xl bg-brand-primary text-surface-card font-bold text-xs hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Reset All Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="text-xs text-muted-text hover:text-brand-primary underline ml-2 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-line-primary/20 rounded-3xl space-y-3 bg-surface-card/30">
|
||||
<FilterX className="w-10 h-10 text-muted-text/50 mx-auto" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-bold text-text-main">No products found matching active filters</p>
|
||||
<p className="text-xs text-muted-text">
|
||||
Try adjusting your price range or clearing selected attribute filters.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="mt-3 px-4 py-2 rounded-xl bg-brand-primary text-surface-card font-bold text-xs hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Reset All Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
|
||||
{paginatedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
layout={viewMode}
|
||||
cardStyle={filterConfig?.cardStyle || 'standard'}
|
||||
showPrice={filterConfig?.showPrice ?? true}
|
||||
showAddToCart={filterConfig?.showAddToCart ?? true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{itemsPerPage && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
className="px-3 py-1.5 rounded-lg border border-line-primary/20 text-xs font-bold disabled:opacity-30 disabled:cursor-not-allowed hover:bg-surface-card"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={`w-8 h-8 rounded-lg text-xs font-bold transition-all ${
|
||||
currentPage === i + 1
|
||||
? 'bg-brand-primary text-surface-card shadow-sm'
|
||||
: 'border border-line-primary/20 hover:bg-surface-card text-muted-text'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
className="px-3 py-1.5 rounded-lg border border-line-primary/20 text-xs font-bold disabled:opacity-30 disabled:cursor-not-allowed hover:bg-surface-card"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div id="products" className="pt-6 pb-12 space-y-6">
|
||||
{!shouldShowFilters ? (
|
||||
/* ── Full Width Catalog Grid (No Filters) ── */
|
||||
<div className="w-full space-y-6">
|
||||
{renderProductList()}
|
||||
</div>
|
||||
) : isTopBarFilter ? (
|
||||
/* ── Top Bar Filter Layout (Full 100% Width) ── */
|
||||
<div className="space-y-6">
|
||||
<div className="hidden lg:block">
|
||||
<CategoryFilterHorizontalBar
|
||||
filters={filters}
|
||||
onChangeFilters={setFilters}
|
||||
onReset={() => setFilters(initialFilters)}
|
||||
config={filterConfig}
|
||||
priceBounds={priceBounds}
|
||||
stockCounts={stockCounts}
|
||||
dynamicFacets={dynamicFacets}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-6">
|
||||
{renderProductList()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Standard Sidebar Filter Layout ── */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
{/* Left Column: Filter Sidebar (Desktop) */}
|
||||
<div className={`hidden lg:block ${filterConfig?.fullWidth ? 'lg:col-span-3 xl:col-span-3 2xl:col-span-2' : 'lg:col-span-3'} sticky top-24`}>
|
||||
<CategoryFilterSidebar
|
||||
filters={filters}
|
||||
onChangeFilters={setFilters}
|
||||
onReset={() => setFilters(initialFilters)}
|
||||
config={filterConfig}
|
||||
priceBounds={priceBounds}
|
||||
stockCounts={stockCounts}
|
||||
dynamicFacets={dynamicFacets}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Catalog Grid & Toolbar */}
|
||||
<div className={`${filterConfig?.fullWidth ? 'lg:col-span-9 xl:col-span-9 2xl:col-span-10' : 'lg:col-span-9'} space-y-6`}>
|
||||
{renderProductList()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Drawer Filter Modal */}
|
||||
{isMobileFilterOpen && (
|
||||
{shouldShowFilters && isMobileFilterOpen && (
|
||||
<div className="fixed inset-0 z-[120] lg:hidden bg-black/70 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xs h-full bg-surface-card overflow-y-auto shadow-2xl animate-in slide-in-from-right duration-200">
|
||||
<CategoryFilterSidebar
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function CategoryFeaturedSeries({
|
||||
<div className="absolute right-10 bottom-0 w-96 h-96 rounded-full bg-orange-500/10 blur-3xl pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-8 items-center p-6 md:p-10 lg:p-12">
|
||||
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-8 items-center ${featured.fullWidth ? 'p-6 md:p-10 lg:p-12 xl:p-16' : 'p-6 md:p-10 lg:p-12'}`}>
|
||||
{/* Left Column: Content */}
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { RotateCcw, ChevronDown, Check, SlidersHorizontal, X } from 'lucide-react'
|
||||
import type { CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
|
||||
import type { FilterState, DynamicFacet } from './CategoryFilterSidebar'
|
||||
|
||||
interface CategoryFilterHorizontalBarProps {
|
||||
filters: FilterState
|
||||
onChangeFilters: (filters: FilterState) => void
|
||||
onReset: () => void
|
||||
config?: CategoryFilterConfig | null
|
||||
priceBounds: [number, number]
|
||||
stockCounts: { inStock: number; preOrder: number }
|
||||
dynamicFacets?: DynamicFacet[]
|
||||
lang?: string
|
||||
}
|
||||
|
||||
function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') {
|
||||
const trimmed = val.trim()
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return resolveLocalizedText(parsed, lang)
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
const rec = val as Record<string, unknown>
|
||||
if (rec.value && rec.label) {
|
||||
return resolveLocalizedText(rec.label, lang) || String(rec.value)
|
||||
}
|
||||
const candidate = rec[lang] || rec.th || rec.en || Object.values(rec).find((v) => typeof v === 'string')
|
||||
if (candidate) return resolveLocalizedText(candidate, lang)
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
export default function CategoryFilterHorizontalBar({
|
||||
filters,
|
||||
onChangeFilters,
|
||||
onReset,
|
||||
config,
|
||||
priceBounds,
|
||||
stockCounts,
|
||||
dynamicFacets = [],
|
||||
lang = 'th',
|
||||
}: CategoryFilterHorizontalBarProps) {
|
||||
const showPrice = config?.showPrice ?? true
|
||||
const showStock = config?.showStock ?? true
|
||||
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setActiveDropdown(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const toggleDropdown = (key: string) => {
|
||||
setActiveDropdown((prev) => (prev === key ? null : key))
|
||||
}
|
||||
|
||||
const toggleStock = (s: string) => {
|
||||
const next = filters.stockStatus.includes(s)
|
||||
? filters.stockStatus.filter((item) => item !== s)
|
||||
: [...filters.stockStatus, s]
|
||||
onChangeFilters({ ...filters, stockStatus: next })
|
||||
}
|
||||
|
||||
const toggleAttribute = (code: string, val: string) => {
|
||||
const current = filters.attributes[code] || []
|
||||
const next = current.includes(val)
|
||||
? current.filter((v) => v !== val)
|
||||
: [...current, val]
|
||||
|
||||
onChangeFilters({
|
||||
...filters,
|
||||
attributes: {
|
||||
...filters.attributes,
|
||||
[code]: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getFacetTitle = (attr: AttributeDefinition): string => {
|
||||
if (attr.label) {
|
||||
const text = resolveLocalizedText(attr.label, lang)
|
||||
if (text) return text
|
||||
}
|
||||
return attr.code
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full rounded-2xl bg-surface-card border border-line-primary/10 p-4 shadow-sm space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Left: Filters Label + Facet Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex items-center gap-2 pr-2 border-r border-line-primary/10">
|
||||
<SlidersHorizontal className="w-4 h-4 text-brand-primary" />
|
||||
<span className="text-xs font-black uppercase tracking-wider text-text-main">Filters</span>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Attribute Facet Dropdowns */}
|
||||
{dynamicFacets.map((facet) => {
|
||||
const title = getFacetTitle(facet.attr)
|
||||
const selectedVals = filters.attributes[facet.attr.code] || []
|
||||
const isOpen = activeDropdown === facet.attr.code
|
||||
|
||||
return (
|
||||
<div key={facet.attr.code} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDropdown(facet.attr.code)}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
selectedVals.length > 0
|
||||
? 'bg-brand-primary/10 border-brand-primary text-brand-primary'
|
||||
: isOpen
|
||||
? 'bg-surface-page border-text-main text-text-main'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{selectedVals.length > 0 && (
|
||||
<span className="w-4 h-4 rounded-full bg-brand-primary text-surface-card text-[10px] font-bold flex items-center justify-center">
|
||||
{selectedVals.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 top-full mt-2 z-50 w-64 max-h-72 overflow-y-auto rounded-xl bg-surface-card border border-line-primary/20 p-3 shadow-xl space-y-1.5">
|
||||
{facet.options.length === 0 ? (
|
||||
<p className="text-xs text-muted-text py-2 text-center">No options available</p>
|
||||
) : (
|
||||
facet.options.map((opt) => {
|
||||
const isChecked = selectedVals.includes(opt.value)
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer p-1.5 rounded-lg hover:bg-surface-page transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleAttribute(facet.attr.code, opt.value)}
|
||||
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5 accent-brand-primary"
|
||||
/>
|
||||
<span className={isChecked ? 'font-bold text-brand-primary' : ''}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-text font-mono">({opt.count})</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Price Range Dropdown */}
|
||||
{showPrice && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDropdown('price')}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
filters.priceRange[1] < priceBounds[1]
|
||||
? 'bg-brand-primary/10 border-brand-primary text-brand-primary'
|
||||
: activeDropdown === 'price'
|
||||
? 'bg-surface-page border-text-main text-text-main'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>Price</span>
|
||||
{filters.priceRange[1] < priceBounds[1] && (
|
||||
<span className="text-[10px] font-mono font-bold text-brand-primary">
|
||||
≤฿{filters.priceRange[1].toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${activeDropdown === 'price' ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{activeDropdown === 'price' && (
|
||||
<div className="absolute left-0 top-full mt-2 z-50 w-72 rounded-xl bg-surface-card border border-line-primary/20 p-4 shadow-xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-text-main">Max Price</span>
|
||||
<span className="text-xs font-mono font-bold text-brand-primary">
|
||||
฿{filters.priceRange[1].toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds[0]}
|
||||
max={priceBounds[1]}
|
||||
value={filters.priceRange[1]}
|
||||
onChange={(e) =>
|
||||
onChangeFilters({
|
||||
...filters,
|
||||
priceRange: [filters.priceRange[0], Number(e.target.value)],
|
||||
})
|
||||
}
|
||||
className="w-full h-1.5 bg-surface-page rounded-lg appearance-none cursor-pointer accent-brand-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-text font-mono">
|
||||
<span>฿{priceBounds[0].toLocaleString()}</span>
|
||||
<span>฿{priceBounds[1].toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stock Availability Pills */}
|
||||
{showStock && (
|
||||
<div className="flex items-center gap-1.5 pl-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStock('in_stock')}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
filters.stockStatus.includes('in_stock')
|
||||
? 'bg-emerald-500/10 border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>In Stock ({stockCounts.inStock})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStock('pre_order')}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
filters.stockStatus.includes('pre_order')
|
||||
? 'bg-amber-500/10 border-amber-500 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>Pre-Order ({stockCounts.preOrder})</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Reset All */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-xs font-semibold text-brand-primary hover:underline flex items-center gap-1 cursor-pointer shrink-0"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
<span>Reset All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user