This commit is contained in:
Your Name committed 2026-10-08 07:12:58 +07:00
1 parent b7f337fb79
commit bf9599008e
29 files changed
+3828 -1541

No files matched your search

+97 -59
View File
@@ -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>
+5 -5
View File
@@ -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>
)
}
+84 -3
View File
@@ -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>
)
}