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

No files matched your search

@@ -13,7 +13,11 @@ import {
CheckCircle2, CheckCircle2,
Tag, Tag,
Sliders, Sliders,
Maximize2 Maximize2,
LayoutGrid,
PanelLeft,
PanelsTopBottom,
Palette,
} from 'lucide-react' } from 'lucide-react'
import { API_BASE, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig, type AttributeDefinition } from '@homaroma/types' import { API_BASE, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig, type AttributeDefinition } from '@homaroma/types'
import CategoryFilterManagerModal from './CategoryFilterManagerModal' import CategoryFilterManagerModal from './CategoryFilterManagerModal'
@@ -228,6 +232,52 @@ export default function CategoryHybridCmsEditor({
{featuredSeries.enabled && ( {featuredSeries.enabled && (
<div className="space-y-5 pt-2"> <div className="space-y-5 pt-2">
{/* Full Wide Banner Toggle */}
<div
onClick={() => onChangeFeaturedSeries({ ...featuredSeries, fullWidth: !featuredSeries.fullWidth })}
className={`flex items-center justify-between p-4 rounded-xl border transition-all cursor-pointer select-none ${
featuredSeries.fullWidth
? 'bg-cyan-glow/10 border-cyan-glow/40 shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20'
}`}
>
<div className="space-y-0.5">
<div className="text-sm font-bold text-white flex items-center gap-2">
<Maximize2 className="w-4 h-4 text-cyan-glow" />
<span>Full Wide Banner (ขยายกว้างเต็มจอ)</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
featuredSeries.fullWidth
? 'text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20'
: 'text-slate-400 bg-white/5 border border-white/10'
}`}>
{featuredSeries.fullWidth ? 'Full Wide (1920px+)' : 'Boxed (1280px)'}
</span>
</div>
<p className="text-xs text-slate-400">
ขยายขนาดแบนเนอร์ Featured Series ให้กว้างเต็มจอหน้าเว็บของผู้ใช้ (Fluid Full Width แทนที่ Boxed max-w-7xl)
</p>
</div>
<button
type="button"
role="switch"
aria-checked={featuredSeries.fullWidth ?? false}
onClick={(e) => {
e.stopPropagation();
onChangeFeaturedSeries({ ...featuredSeries, fullWidth: !featuredSeries.fullWidth });
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
featuredSeries.fullWidth ? 'bg-cyan-glow' : 'bg-night-950 border border-white/20'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
featuredSeries.fullWidth ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Badge Kicker */} {/* Badge Kicker */}
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -502,6 +552,289 @@ export default function CategoryHybridCmsEditor({
{/* ── TAB 3: Filter System Configuration ──────────────────────────── */} {/* ── TAB 3: Filter System Configuration ──────────────────────────── */}
{activeTab === 'filters' && ( {activeTab === 'filters' && (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Container Width (Standard, Wide, Full) */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h5 className="text-xs font-bold text-white flex items-center gap-2">
<Maximize2 className="w-4 h-4 text-cyan-glow" />
<span>Container Width (ความกว้างพื้นที่แคตตาล็อกสินค้า)</span>
</h5>
<p className="text-[11px] text-slate-400 mt-0.5">
เลือกความกว้างของบล็อกสินค้าและตัวกรอง ให้พอดีกับดีไซน์หน้าร้าน
</p>
</div>
<span className="text-[10px] font-bold px-2.5 py-1 rounded-full text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 shrink-0">
{filterConfig.containerWidth === 'full' || filterConfig.fullWidth
? 'Full Width (1920px+ Fluid)'
: filterConfig.containerWidth === 'wide'
? 'Wide (1600px)'
: 'Standard (1280px Boxed)'}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
{[
{ val: 'standard', label: 'Standard Boxed', desc: 'กรอบ 1280px กึ่งกลาง' },
{ val: 'wide', label: 'Wide (1600px)', desc: 'ขยายกว้างพิเศษ 1600px' },
{ val: 'full', label: 'Full Width Fluid', desc: 'เต็มพื้นที่ 100% (1920px+)' },
].map((item) => {
const currentWidth = filterConfig.containerWidth || (filterConfig.fullWidth ? 'full' : 'standard')
const isSelected = currentWidth === item.val
return (
<button
key={item.val}
type="button"
onClick={() =>
onChangeFilterConfig({
...filterConfig,
containerWidth: item.val as any,
fullWidth: item.val === 'full',
})
}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 text-slate-400'
}`}
>
<div className="text-xs font-bold">{item.label}</div>
<div className="text-[10px] text-slate-500 mt-0.5">{item.desc}</div>
</button>
)
})}
</div>
</div>
{/* Card Presentation & Styling (Parity with Web CMS Product Carousel) */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-5">
<div>
<h5 className="text-xs font-bold text-white flex items-center gap-2">
<Palette className="w-4 h-4 text-cyan-glow" />
<span>Product Card Presentation (รูปแบบและการแสดงผลการ์ดสินค้า)</span>
</h5>
<p className="text-[11px] text-slate-400 mt-0.5">
ปรับแต่งสไตล์การ์ด ขนาด และองค์ประกอบปุ่มการสั่งซื้อ (Parity กับ CMS Product Carousel)
</p>
</div>
{/* 1. Card Style Selector */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Card Layout Style (สไตล์การ์ด)
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{[
{
val: 'standard',
label: 'Standard Retail',
desc: 'กรอบ 1:1 Boxed พร้อมปุ่มเพิ่มลงตะกร้า',
badge: 'E-Commerce'
},
{
val: 'minimal',
label: 'Fashion Minimal',
desc: 'กรอบแนวตั้ง 3:4 Portrait โหมด Lookbook/Zara/Pomelo',
badge: 'Fashion / Minimal'
},
{
val: 'swatch',
label: 'Editorial Swatch',
desc: 'การ์ดพร้อมจุด Swatch สีของ Variant สินค้า',
badge: 'Interactive'
},
].map((item) => {
const isSelected = (filterConfig.cardStyle || 'standard') === item.val
return (
<button
key={item.val}
type="button"
onClick={() => onChangeFilterConfig({ ...filterConfig, cardStyle: item.val as any })}
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 text-slate-400'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold">{item.label}</span>
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded-md bg-white/5 border border-white/10 text-slate-400">
{item.badge}
</span>
</div>
<p className="text-[10px] text-slate-400 mt-1 leading-relaxed">{item.desc}</p>
</button>
)
})}
</div>
</div>
{/* 2. Card Size / Density */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Card Size & Density (ขนาดและความหนาแน่นการ์ด)
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{[
{ val: 'compact', label: 'Compact (กะทัดรัด)', desc: '~5-6 ใบต่อหน้าจอ เหมาะกับแคตตาล็อกสินค้าจำนวนมาก' },
{ val: 'medium', label: 'Medium (มาตรฐาน)', desc: '~4 ใบต่อหน้าจอ สัดส่วนสวยงามสมดุล (แนะนำ)' },
{ val: 'large', label: 'Large (ใหญ่พิเศษ)', desc: '~2-3 ใบต่อหน้าจอ การ์ดกว้างเต็มตา โชว์ภาพคมชัด' },
].map((item) => {
const isSelected = (filterConfig.cardSize || 'medium') === item.val
return (
<button
key={item.val}
type="button"
onClick={() => onChangeFilterConfig({ ...filterConfig, cardSize: item.val as any })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 text-slate-400'
}`}
>
<div className="text-xs font-bold">{item.label}</div>
<p className="text-[10px] text-slate-400 mt-0.5">{item.desc}</p>
</button>
)
})}
</div>
</div>
{/* 3. Card Elements Toggles */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-white/5">
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/[0.02] rounded-xl cursor-pointer select-none">
<input
type="checkbox"
checked={filterConfig.showPrice ?? true}
onChange={(e) => onChangeFilterConfig({ ...filterConfig, showPrice: e.target.checked })}
className="accent-cyan-glow"
/>
<div>
<span className="text-xs font-bold text-slate-200 block">Show Price (แสดงราคา)</span>
<span className="text-[10px] text-slate-400">แสดงป้ายราคาสินค้าบนการ์ด</span>
</div>
</label>
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/[0.02] rounded-xl cursor-pointer select-none">
<input
type="checkbox"
checked={filterConfig.showAddToCart ?? true}
onChange={(e) => onChangeFilterConfig({ ...filterConfig, showAddToCart: e.target.checked })}
className="accent-cyan-glow"
/>
<div>
<span className="text-xs font-bold text-slate-200 block">Show Add to Cart (ปุ่มสั่งซื้อ)</span>
<span className="text-[10px] text-slate-400">แสดงปุ่มหยิบลงตะกร้าบนการ์ดสินค้า</span>
</div>
</label>
</div>
</div>
{/* Card Columns & Grid Layout */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h5 className="text-xs font-bold text-white flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-cyan-glow" />
<span>Product Card Columns (จำนวนคอลัมน์การ์ดสินค้า)</span>
</h5>
<p className="text-[11px] text-slate-400 mt-0.5">
กำหนดขนาดและความกว้างของการ์ดสินค้าบน Desktop / Ultra-Wide
</p>
</div>
<span className="text-[10px] font-bold px-2.5 py-1 rounded-full text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 shrink-0">
{filterConfig.gridColumns ? `${filterConfig.gridColumns} Columns` : 'Adaptive Auto'}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2.5">
{[
{ val: undefined, label: 'Auto (ตามธีม)', desc: 'ปรับอัตโนมัติ' },
{ val: 2, label: '2 คอลัมน์', desc: 'การ์ดใหญ่พิเศษ Wide' },
{ val: 3, label: '3 คอลัมน์', desc: 'ขนาดมาตรฐาน' },
{ val: 4, label: '4 คอลัมน์', desc: 'หน้าจอกว้าง Wide' },
{ val: 5, label: '5 คอลัมน์', desc: 'ความหนาแน่นสูง Dense' },
].map((item) => {
const isSelected = filterConfig.gridColumns === item.val
return (
<button
key={item.label}
type="button"
onClick={() => onChangeFilterConfig({ ...filterConfig, gridColumns: item.val as any })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 text-slate-400'
}`}
>
<div className="text-xs font-bold">{item.label}</div>
<div className="text-[10px] text-slate-500 mt-0.5">{item.desc}</div>
</button>
)
})}
</div>
</div>
{/* Filters UI Layout Position */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h5 className="text-xs font-bold text-white flex items-center gap-2">
<Sliders className="w-4 h-4 text-cyan-glow" />
<span>Filters UI Position (ตำแหน่งและการจัดวางตัวกรอง)</span>
</h5>
<p className="text-[11px] text-slate-400 mt-0.5">
เลือกตำแหน่งแถบตัวกรอง เพื่อขยายตารางสินค้าให้กว้างเต็ม 100% หรือจัดเป็น Sidebar ด้านข้าง
</p>
</div>
<span className="text-[10px] font-bold px-2.5 py-1 rounded-full text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 shrink-0">
{filterConfig.filterPosition === 'top_bar' ? 'Top Bar (Wide Ribbon)' : 'Sidebar (ซ้าย)'}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => onChangeFilterConfig({ ...filterConfig, filterPosition: 'sidebar' })}
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer flex items-start gap-3 ${
filterConfig.filterPosition !== 'top_bar'
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 text-slate-400'
}`}
>
<div className="p-2 rounded-lg bg-white/5 border border-white/10 shrink-0 text-cyan-glow">
<PanelLeft className="w-4 h-4" />
</div>
<div>
<div className="text-xs font-bold">Sidebar (ด้านซ้ายแนวตั้ง)</div>
<div className="text-[10px] text-slate-400 mt-1 leading-relaxed">
แถบตัวกรองอยู่ทางซ้าย เหมาะสำหรับหมวดหมู่ที่มีตัวกรองหลายหัวข้อย่อย
</div>
</div>
</button>
<button
type="button"
onClick={() => onChangeFilterConfig({ ...filterConfig, filterPosition: 'top_bar' })}
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer flex items-start gap-3 ${
filterConfig.filterPosition === 'top_bar'
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 text-slate-400'
}`}
>
<div className="p-2 rounded-lg bg-white/5 border border-white/10 shrink-0 text-cyan-glow">
<PanelsTopBottom className="w-4 h-4" />
</div>
<div>
<div className="text-xs font-bold">Top Bar (แนวนอนด้านบน Wide Ribbon)</div>
<div className="text-[10px] text-slate-400 mt-1 leading-relaxed">
ตัวกรองวางแนวนอนเต็มความกว้างด้านบน ปลดล็อกให้ตารางสินค้าด้านล่างได้พื้นที่กว้าง 100% เต็มจอ
</div>
</div>
</button>
</div>
</div>
{/* Section 1: Standard Global Facet Presets */} {/* Section 1: Standard Global Facet Presets */}
<div> <div>
<div className="mb-3"> <div className="mb-3">
+715 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { import {
AlertCircle, AlertCircle,
ArrowDown, ArrowDown,
@@ -25,11 +25,17 @@ import {
Sparkles, Sparkles,
Trash2, Trash2,
Upload, Upload,
Columns,
Sliders,
Maximize2,
Palette,
LayoutGrid,
} from 'lucide-react' } from 'lucide-react'
import { API_BASE, type Category, type CategorySection } from '@homaroma/types' import { API_BASE, type Category, type CategorySection, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig } from '@homaroma/types'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCarouselBlockForm' import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCarouselBlockForm'
import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm' import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm'
import CategoryHybridCmsEditor from '../components/category/CategoryHybridCmsEditor'
import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation' import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization' import { getAdminDisplayString } from '../utils/localization'
import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal' import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
@@ -173,6 +179,41 @@ export default function CategoryCmsPage() {
const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false) const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export') const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
const [searchParams, setSearchParams] = useSearchParams()
const initialTab = searchParams.get('tab') === 'blocks' ? 'page_builder' : 'presentation'
const [cmsTab, setCmsTab] = useState<'presentation' | 'page_builder'>(initialTab)
// Presentation & Hybrid CMS States
const [layoutType, setLayoutType] = useState<string>('split_showcase')
const [featuredSeries, setFeaturedSeries] = useState<CategoryFeaturedSeries>({
enabled: false,
badge: 'FEATURED SERIES /',
title: '',
subtitle: '',
description: '',
ctaText: 'EXPLORE SERIES →',
ctaUrl: '#products',
imageUrl: '',
actionImageUrl: '',
features: ['ADJUSTABLE DAMPING', 'SUPERIOR RELIABILITY', 'RACE PROVEN'],
})
const [uspBenefits, setUspBenefits] = useState<CategoryUspBenefit[]>([
{ icon: 'shield', title: 'Superior Damping', description: 'Engineered for maximum stability on and off the track' },
{ icon: 'gauge', title: 'Race Proven Performance', description: 'Tested and proven by champions in extreme conditions' },
{ icon: 'wrench', title: 'Durable For Extreme Use', description: 'Premium grade components built to last' },
])
const [filterConfig, setFilterConfig] = useState<CategoryFilterConfig>({
showCategoryNav: true,
showSeries: true,
showType: true,
showColor: true,
showFitment: true,
showPrice: true,
showStock: true,
})
const [savingPresentation, setSavingPresentation] = useState(false)
const [presentationSuccess, setPresentationSuccess] = useState(false)
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const selectedSection = selectedSectionId === 'new' const selectedSection = selectedSectionId === 'new'
@@ -228,6 +269,12 @@ export default function CategoryCmsPage() {
if (!categoryRes.ok) throw new Error('Category not found') if (!categoryRes.ok) throw new Error('Category not found')
const categoryData = await categoryRes.json() const categoryData = await categoryRes.json()
setCategory(categoryData) setCategory(categoryData)
if (categoryData.layoutType) setLayoutType(categoryData.layoutType)
if (categoryData.featuredSeries) setFeaturedSeries(categoryData.featuredSeries)
if (Array.isArray(categoryData.uspBenefits) && categoryData.uspBenefits.length > 0) {
setUspBenefits(categoryData.uspBenefits)
}
if (categoryData.filterConfig) setFilterConfig(categoryData.filterConfig)
await fetchDetailSections(id) await fetchDetailSections(id)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -239,6 +286,34 @@ export default function CategoryCmsPage() {
fetchData() fetchData()
}, [id]) }, [id])
const handleSavePresentation = async () => {
if (!id) return
setSavingPresentation(true)
setError(null)
setPresentationSuccess(false)
try {
const response = await fetch(`${API_BASE}/categories/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layoutType,
featuredSeries,
uspBenefits,
filterConfig,
}),
})
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(getApiErrorMessage(data, 'Failed to save presentation settings'))
setCategory((prev) => (prev ? { ...prev, ...data } : data))
setPresentationSuccess(true)
setTimeout(() => setPresentationSuccess(false), 3000)
} catch (err: any) {
setError(err instanceof Error ? err.message : 'Failed to save presentation settings')
} finally {
setSavingPresentation(false)
}
}
const syncNativePayload = (field: string, value: unknown) => { const syncNativePayload = (field: string, value: unknown) => {
setSectionPayload((prev) => { setSectionPayload((prev) => {
const next = { ...prev, [field]: value } const next = { ...prev, [field]: value }
@@ -472,6 +547,334 @@ export default function CategoryCmsPage() {
</div> </div>
)} )}
{/* Top Workspace Mode Tabs: Category Presentation & Hybrid CMS (Wide Full) vs Dynamic Page Builder */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/10 pb-4">
<div className="flex items-center gap-2 p-1.5 rounded-2xl bg-night-950 border border-white/10">
<button
type="button"
onClick={() => {
setCmsTab('presentation')
setSearchParams({ tab: 'presentation' })
}}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
cmsTab === 'presentation'
? 'bg-cyan-glow text-night-950 shadow-lg shadow-cyan-glow/20'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Sparkles className="w-4 h-4" />
<span>Category Presentation & Hybrid CMS</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-night-900/60 font-mono">
Wide Full
</span>
</button>
<button
type="button"
onClick={() => {
setCmsTab('page_builder')
setSearchParams({ tab: 'blocks' })
}}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
cmsTab === 'page_builder'
? 'bg-cyan-glow text-night-950 shadow-lg shadow-cyan-glow/20'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<LayoutTemplate className="w-4 h-4" />
<span>Page Builder Blocks ({detailSections.length})</span>
</button>
</div>
{/* Tab Quick Action */}
{cmsTab === 'presentation' && (
<button
type="button"
onClick={handleSavePresentation}
disabled={savingPresentation}
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-xs font-bold shadow-lg shadow-cyan-glow/20 cursor-pointer self-start sm:self-auto"
>
{savingPresentation ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span>Save Presentation Changes</span>
</>
)}
</button>
)}
</div>
{/* Main Tab Content */}
{cmsTab === 'presentation' ? (
<div className="space-y-6">
{/* Presentation Feedback */}
{presentationSuccess && (
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-400 text-sm animate-fade-in shadow-lg">
<CheckCircle2 className="w-5 h-5 shrink-0" />
<p>บันทึกการตั้งค่า Category Presentation & Hybrid CMS สำเร็จ!</p>
</div>
)}
{/* Card 1: Category Layout & Presentation Archetypes in Wide View */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/5 pb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20">
<LayoutTemplate className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
Category Layout & Presentation Archetype
<span className="text-[10px] uppercase font-mono font-bold tracking-wider px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
Wide Full
</span>
</h2>
<p className="text-xs text-slate-400">เลือกโครงสร้างส่วนหัวและการแสดงผลสินค้าบน Storefront & Website</p>
</div>
</div>
<span className={`px-2.5 py-1 rounded-lg text-xs font-mono font-semibold border ${
layoutType === 'none'
? 'bg-rose-500/10 text-rose-300 border-rose-500/30'
: 'bg-white/5 text-slate-300 border-white/10'
}`}>
{layoutType === 'none' ? 'none (ปิดส่วนหัว)' : layoutType}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{/* Option 1: Split Showcase */}
<div
onClick={() => setLayoutType('split_showcase')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'split_showcase'
? 'bg-cyan-glow/10 border-cyan-glow shadow-lg shadow-cyan-glow/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2.5 flex flex-col justify-between overflow-hidden">
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<div className="h-2 w-3/4 bg-white/30 rounded" />
<div className="h-1.5 w-1/2 bg-white/15 rounded" />
</div>
<div className="w-8 h-8 rounded-lg bg-cyan-glow/30 border border-cyan-glow/50 shrink-0" />
</div>
<div className="grid grid-cols-3 gap-1 pt-1">
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<Columns className="w-4 h-4 text-cyan-glow" />
Split Showcase
</h4>
{layoutType === 'split_showcase' && (
<CheckCircle2 className="w-4 h-4 text-cyan-glow" />
)}
</div>
<p className="text-[11px] text-cyan-400 font-medium mt-0.5">ค่าเริ่มต้น (Balanced)</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
หัวข้อแบ่ง 2 ฝั่ง (ข้อความซ้าย + รูปเด่นขวา) พร้อมการจัดแสดงสินค้า 3-4 คอลัมน์มาตรฐาน
</p>
</div>
</div>
</div>
{/* Option 2: Editorial Hero */}
<div
onClick={() => setLayoutType('editorial_hero')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'editorial_hero'
? 'bg-amber-500/10 border-amber-400 shadow-lg shadow-amber-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2 flex flex-col justify-between overflow-hidden">
<div className="w-full h-10 rounded-lg bg-amber-500/20 border border-amber-500/40 flex flex-col items-center justify-center gap-1">
<div className="h-2 w-16 bg-amber-200/80 rounded" />
<div className="h-1 w-10 bg-amber-200/40 rounded" />
</div>
<div className="grid grid-cols-2 gap-1.5 pt-1">
<div className="h-8 rounded-lg bg-white/10 border border-white/5" />
<div className="h-8 rounded-lg bg-white/10 border border-white/5" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-amber-400" />
Editorial Hero
</h4>
{layoutType === 'editorial_hero' && (
<CheckCircle2 className="w-4 h-4 text-amber-400" />
)}
</div>
<p className="text-[11px] text-amber-400 font-medium mt-0.5">Cinematic & Luxury</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
Hero Banner เต็มจอสไตล์นิตยสารหรูหรา พร้อมการ์ดสินค้าขนาดใหญ่ 2-3 คอลัมน์
</p>
</div>
</div>
</div>
{/* Option 3: Dense Catalog */}
<div
onClick={() => setLayoutType('dense_catalog')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'dense_catalog'
? 'bg-purple-500/10 border-purple-400 shadow-lg shadow-purple-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2 flex flex-col justify-between overflow-hidden">
<div className="flex items-center justify-between border-b border-white/10 pb-1">
<div className="h-2 w-12 bg-white/40 rounded" />
<div className="h-2 w-6 bg-purple-400/60 rounded" />
</div>
<div className="grid grid-cols-4 gap-1 pt-1">
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<Grid3X3 className="w-4 h-4 text-purple-400" />
Dense Catalog
</h4>
{layoutType === 'dense_catalog' && (
<CheckCircle2 className="w-4 h-4 text-purple-400" />
)}
</div>
<p className="text-[11px] text-purple-400 font-medium mt-0.5">High Density Shopper</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
Header ย่อกะทัดรัด พร้อมตารางสินค้าความหนาแน่นสูง 4-5 คอลัมน์ เหมาะสำหรับสินค้าจำนวนมาก
</p>
</div>
</div>
</div>
{/* Option 4: None / ปิดส่วนหัว */}
<div
onClick={() => setLayoutType('none')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'none'
? 'bg-rose-500/10 border-rose-400 shadow-lg shadow-rose-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2.5 flex flex-col justify-between overflow-hidden">
<div className="w-full py-1 px-2 border-b border-dashed border-rose-500/30 flex items-center justify-between">
<div className="h-1.5 w-12 bg-white/20 rounded" />
<span className="text-[9px] font-mono uppercase text-rose-400 font-bold">No Header</span>
</div>
<div className="grid grid-cols-3 gap-1 pt-1 flex-1 items-center">
<div className="h-8 rounded bg-white/10 border border-white/5" />
<div className="h-8 rounded bg-white/10 border border-white/5" />
<div className="h-8 rounded bg-white/10 border border-white/5" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<EyeOff className="w-4 h-4 text-rose-400" />
ปิด (No Header)
</h4>
{layoutType === 'none' && (
<CheckCircle2 className="w-4 h-4 text-rose-400" />
)}
</div>
<p className="text-[11px] text-rose-400 font-medium mt-0.5">Clean & Streamlined</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
ไม่แสดง Archetype Header ด้านบน เพื่อเริ่มแสดง Featured Series หรือสินค้าทันที ลดความซ้ำซ้อน
</p>
</div>
</div>
</div>
</div>
</div>
{/* Card 2: CategoryHybridCmsEditor in Wide Full */}
<CategoryHybridCmsEditor
featuredSeries={featuredSeries}
onChangeFeaturedSeries={setFeaturedSeries}
uspBenefits={uspBenefits}
onChangeUspBenefits={setUspBenefits}
filterConfig={filterConfig}
onChangeFilterConfig={setFilterConfig}
onOpenMediaModal={(target) => {
openMediaPicker((url) => {
if (target === 'featured_image') {
setFeaturedSeries((prev) => ({ ...prev, imageUrl: url }))
} else if (target === 'featured_action') {
setFeaturedSeries((prev) => ({ ...prev, actionImageUrl: url }))
}
})
}}
categoryId={id ? parseInt(id, 10) : undefined}
categoryName={categoryName}
/>
{/* Sticky Bottom Save Bar */}
<div className="sticky bottom-6 z-20 p-4 rounded-2xl bg-night-900/95 backdrop-blur-md border border-white/10 shadow-2xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
<Sparkles className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-bold text-white">Save Presentation & Hybrid CMS Settings</p>
<p className="text-xs text-slate-400">บันทึก Layout Archetype, แบนเนอร์ Featured Series, การ์ด USP Benefits และตัวกรอง</p>
</div>
</div>
<div className="flex items-center gap-3">
{presentationSuccess && (
<span className="text-xs font-semibold text-emerald-400 flex items-center gap-1.5 animate-fade-in">
<CheckCircle2 className="w-4 h-4" />
บันทึกสำเร็จ!
</span>
)}
<button
type="button"
onClick={handleSavePresentation}
disabled={savingPresentation}
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm font-bold shadow-lg shadow-cyan-glow/20 cursor-pointer"
>
{savingPresentation ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving Changes...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span>Save Presentation Changes</span>
</>
)}
</button>
</div>
</div>
</div>
) : (
/* Page Builder Blocks Tab */
<div className="space-y-6">
{/* Top 4 Stats Cards */} {/* Top 4 Stats Cards */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4"> <div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
@@ -758,6 +1161,32 @@ export default function CategoryCmsPage() {
{sectionType === 'CATEGORY_HEADER' && ( {sectionType === 'CATEGORY_HEADER' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Layout Archetype banner with shortcut */}
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-xs font-bold text-white flex items-center gap-2">
<span>Active Layout Archetype:</span>
<span className="font-mono text-cyan-glow uppercase text-[11px] px-2 py-0.5 rounded bg-cyan-glow/10 border border-cyan-glow/20">
{layoutType}
</span>
</p>
<p className="text-[11px] text-slate-400 mt-1">
เลือก Split Showcase, Editorial Hero, Dense Catalog หรือ ปิดส่วนหัว ได้ที่แท็บ Presentation
</p>
</div>
<button
type="button"
onClick={() => {
setCmsTab('presentation')
setSearchParams({ tab: 'presentation' })
}}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer self-start sm:self-auto shrink-0"
>
<LayoutTemplate className="w-3.5 h-3.5" />
<span>เปลี่ยน Archetype ➔</span>
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Title ({activeLang.toUpperCase()})</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Title ({activeLang.toUpperCase()})</label>
@@ -806,8 +1235,31 @@ export default function CategoryCmsPage() {
{sectionType === 'CATALOG_GRID' && ( {sectionType === 'CATALOG_GRID' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> {/* Facet filter shortcut */}
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3"> <div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-xs font-bold text-white flex items-center gap-2">
<Sliders className="w-3.5 h-3.5 text-cyan-glow" />
<span>Filter Facets & Product Attributes</span>
</p>
<p className="text-[11px] text-slate-400 mt-1">
ปรับแต่งตัวกรองแถบข้าง (Series, Type, Color, Fitment, Price, Stock) และจัดการ Attributes
</p>
</div>
<button
type="button"
onClick={() => {
setCmsTab('presentation')
setSearchParams({ tab: 'presentation' })
}}
className="px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer self-start sm:self-auto shrink-0"
>
<span>จัดการ Filters ใน Presentation ➔</span>
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={sectionPayload.showFilters !== false} checked={sectionPayload.showFilters !== false}
@@ -816,7 +1268,7 @@ export default function CategoryCmsPage() {
/> />
<span className="text-xs font-bold text-slate-300">Show filter sidebar</span> <span className="text-xs font-bold text-slate-300">Show filter sidebar</span>
</label> </label>
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3"> <label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={sectionPayload.showToolbar !== false} checked={sectionPayload.showToolbar !== false}
@@ -827,6 +1279,239 @@ export default function CategoryCmsPage() {
</label> </label>
</div> </div>
{/* Container Width (Standard, Wide, Full) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 flex items-center gap-1.5">
<Maximize2 className="w-3.5 h-3.5 text-cyan-glow" />
<span>Container Width (ความกว้างหน้าแคตตาล็อก)</span>
</label>
<span className="text-[10px] font-bold text-cyan-glow">
{(sectionPayload.containerWidth ?? filterConfig.containerWidth) === 'full' || (sectionPayload.fullWidth ?? filterConfig.fullWidth)
? 'Full Width (1920px+)'
: (sectionPayload.containerWidth ?? filterConfig.containerWidth) === 'wide'
? 'Wide (1600px)'
: 'Standard (1280px)'}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{ val: 'standard', label: 'Standard Boxed', desc: '1280px กึ่งกลาง' },
{ val: 'wide', label: 'Wide (1600px)', desc: 'ขยายกว้าง 1600px' },
{ val: 'full', label: 'Full Width Fluid', desc: 'เต็มจอ 100% (1920px+)' },
].map((item) => {
const currentWidth = (sectionPayload.containerWidth ?? filterConfig.containerWidth) ||
((sectionPayload.fullWidth ?? filterConfig.fullWidth) ? 'full' : 'standard')
const isSelected = currentWidth === item.val
return (
<button
key={item.val}
type="button"
onClick={() => {
syncNativePayload('containerWidth', item.val)
syncNativePayload('fullWidth', item.val === 'full')
setFilterConfig((prev) => ({ ...prev, containerWidth: item.val as any, fullWidth: item.val === 'full' }))
}}
className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-night-950 border-white/10 text-slate-400 hover:text-white'
}`}
>
<div className="text-xs font-bold">{item.label}</div>
<div className="text-[10px] text-slate-500">{item.desc}</div>
</button>
)
})}
</div>
</div>
{/* Product Card Presentation (Card Style, Density & Elements) */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-4">
<div>
<h5 className="text-xs font-bold text-white flex items-center gap-2">
<Palette className="w-4 h-4 text-cyan-glow" />
<span>Product Card Presentation (รูปแบบและการแสดงผลการ์ดสินค้า)</span>
</h5>
<p className="text-[11px] text-slate-400 mt-0.5">
ปรับแต่งสไตล์การ์ด ขนาด และองค์ประกอบปุ่มการสั่งซื้อ (Parity กับ CMS Product Carousel)
</p>
</div>
{/* Card Style Selector */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
Card Layout Style (สไตล์การ์ด)
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{ val: 'standard', label: 'Standard Retail', desc: 'กรอบ 1:1 Boxed พร้อมปุ่มเพิ่มลงตะกร้า' },
{ val: 'minimal', label: 'Fashion Minimal', desc: 'กรอบแนวตั้ง 3:4 Portrait โหมด Lookbook' },
{ val: 'swatch', label: 'Editorial Swatch', desc: 'การ์ดโชว์กลิ่นโน้ต/สี Fragrance Swatches' },
].map((item) => {
const currentStyle = (sectionPayload.cardStyle ?? filterConfig.cardStyle) || 'standard'
const isSelected = currentStyle === item.val
return (
<button
key={item.val}
type="button"
onClick={() => {
syncNativePayload('cardStyle', item.val)
setFilterConfig((prev) => ({ ...prev, cardStyle: item.val as any }))
}}
className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 text-slate-400 hover:text-white'
}`}
>
<div className="text-xs font-bold">{item.label}</div>
<div className="text-[10px] text-slate-500">{item.desc}</div>
</button>
)
})}
</div>
</div>
{/* Card Size / Density */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
Card Size / Density (ขนาดความหนาแน่นของการ์ด)
</label>
<div className="grid grid-cols-3 gap-2">
{[
{ val: 'compact', label: 'Compact Dense', desc: 'กะทัดรัด เห็นของเยอะ' },
{ val: 'medium', label: 'Medium Standard', desc: 'สมดุลมาตรฐาน' },
{ val: 'large', label: 'Large Editorial', desc: 'ใหญ่ ชัดเจน โดดเด่น' },
].map((item) => {
const currentSize = (sectionPayload.cardSize ?? filterConfig.cardSize) || 'medium'
const isSelected = currentSize === item.val
return (
<button
key={item.val}
type="button"
onClick={() => {
syncNativePayload('cardSize', item.val)
setFilterConfig((prev) => ({ ...prev, cardSize: item.val as any }))
}}
className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-white shadow-sm'
: 'bg-white/[0.02] border-white/10 text-slate-400 hover:text-white'
}`}
>
<div className="text-xs font-bold">{item.label}</div>
<div className="text-[10px] text-slate-500">{item.desc}</div>
</button>
)
})}
</div>
</div>
{/* Quick Element Toggles */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/[0.02] rounded-xl cursor-pointer select-none">
<input
type="checkbox"
checked={(sectionPayload.showPrice ?? filterConfig.showPrice) !== false}
onChange={(e) => {
syncNativePayload('showPrice', e.target.checked)
setFilterConfig((prev) => ({ ...prev, showPrice: e.target.checked }))
}}
className="accent-cyan-glow"
/>
<div>
<span className="text-xs font-bold text-slate-200 block">Show Price (แสดงราคา)</span>
<span className="text-[10px] text-slate-400">แสดงป้ายราคาสินค้าบนการ์ด</span>
</div>
</label>
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/[0.02] rounded-xl cursor-pointer select-none">
<input
type="checkbox"
checked={(sectionPayload.showAddToCart ?? filterConfig.showAddToCart) !== false}
onChange={(e) => {
syncNativePayload('showAddToCart', e.target.checked)
setFilterConfig((prev) => ({ ...prev, showAddToCart: e.target.checked }))
}}
className="accent-cyan-glow"
/>
<div>
<span className="text-xs font-bold text-slate-200 block">Show Add to Cart (ปุ่มสั่งซื้อ)</span>
<span className="text-[10px] text-slate-400">แสดงปุ่มหยิบลงตะกร้าบนการ์ดสินค้า</span>
</div>
</label>
</div>
</div>
{/* Card Columns Selector */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 flex items-center gap-1.5">
<LayoutGrid className="w-3.5 h-3.5 text-cyan-glow" />
<span>Card Columns (คอลัมน์การ์ดสินค้า)</span>
</label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{[
{ val: undefined, label: 'Auto (ตามธีม)' },
{ val: 2, label: '2 Cols (Wide การ์ดใหญ่)' },
{ val: 3, label: '3 Cols (Standard)' },
{ val: 4, label: '4 Cols (Wide)' },
{ val: 5, label: '5 Cols (Dense)' },
].map((item) => {
const isSelected = (sectionPayload.gridColumns ?? filterConfig.gridColumns) === item.val
return (
<button
key={item.label}
type="button"
onClick={() => {
syncNativePayload('gridColumns', item.val)
setFilterConfig((prev) => ({ ...prev, gridColumns: item.val as any }))
}}
className={`py-2 px-3 rounded-xl border text-xs font-bold transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-cyan-glow'
: 'bg-night-950 border-white/10 text-slate-400 hover:text-white'
}`}
>
{item.label}
</button>
)
})}
</div>
</div>
{/* Filter Position */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
Filter Position (ตำแหน่งตัวกรอง)
</label>
<div className="grid grid-cols-2 gap-2">
{[
{ val: 'sidebar', label: 'Sidebar (แถบแนวตั้งด้านซ้าย)' },
{ val: 'top_bar', label: 'Top Bar (แนวนอนด้านบน Wide Ribbon)' },
].map((item) => {
const isSelected = ((sectionPayload.filterPosition ?? filterConfig.filterPosition) || 'sidebar') === item.val
return (
<button
key={item.val}
type="button"
onClick={() => {
syncNativePayload('filterPosition', item.val)
setFilterConfig((prev) => ({ ...prev, filterPosition: item.val as any }))
}}
className={`py-2 px-3 rounded-xl border text-xs font-bold transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow text-cyan-glow'
: 'bg-night-950 border-white/10 text-slate-400 hover:text-white'
}`}
>
{item.label}
</button>
)
})}
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Items Per Page</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Items Per Page</label>
<input <input
@@ -852,7 +1537,29 @@ export default function CategoryCmsPage() {
/> />
<span className="text-xs font-bold text-slate-300">Show USP benefits bar</span> <span className="text-xs font-bold text-slate-300">Show USP benefits bar</span>
</label> </label>
<p className="text-xs text-slate-500">Renders the 3 USP benefit columns configured for this category.</p>
{/* Shortcut to USP cards in presentation */}
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-xs font-bold text-white flex items-center gap-2">
<Shield className="w-3.5 h-3.5 text-orange-400" />
<span>Configured USP Benefits ({uspBenefits.length} รายการ)</span>
</p>
<p className="text-[11px] text-slate-400 mt-1">
แก้ไขไอคอน, หัวข้อ และคำอธิบายการ์ดคุณสมบัติเด่นของหมวดหมู่ได้ที่แท็บ Presentation
</p>
</div>
<button
type="button"
onClick={() => {
setCmsTab('presentation')
setSearchParams({ tab: 'presentation' })
}}
className="px-3 py-1.5 rounded-lg bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20 text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer self-start sm:self-auto shrink-0"
>
<span>แก้ไขการ์ด USP Benefits ➔</span>
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -928,6 +1635,8 @@ export default function CategoryCmsPage() {
</div> </div>
</div> </div>
</section> </section>
</div>
)}
{/* Media Library Modal */} {/* Media Library Modal */}
<MediaLibraryModal <MediaLibraryModal
+52 -199
View File
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig } from '@homaroma/types' import { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig } from '@homaroma/types'
import CategoryHybridCmsEditor from '../components/category/CategoryHybridCmsEditor'
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
@@ -23,9 +22,6 @@ import {
Download, Download,
Sparkles, Sparkles,
LayoutTemplate, LayoutTemplate,
Columns,
Grid3X3,
EyeOff,
Upload, Upload,
} from 'lucide-react' } from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
@@ -118,8 +114,6 @@ export default function CategoryEditPage() {
showStock: true, showStock: true,
}) })
const [mediaTarget, setMediaTarget] = useState<'role' | 'featured_image' | 'featured_action'>('role')
// Media Library Modal // Media Library Modal
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail') const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
@@ -470,11 +464,11 @@ export default function CategoryEditPage() {
{!isNew && ( {!isNew && (
<Link <Link
to={`/categories/${id}/cms`} to={`/categories/${id}/cms?tab=presentation`}
className="px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 text-sm font-semibold transition-all cursor-pointer flex items-center gap-2" className="px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 text-sm font-semibold transition-all cursor-pointer flex items-center gap-2"
> >
<LayoutTemplate className="w-4 h-4" /> <LayoutTemplate className="w-4 h-4" />
<span>CMS Blocks</span> <span>CMS & Presentation</span>
</Link> </Link>
)} )}
@@ -803,212 +797,78 @@ export default function CategoryEditPage() {
)} )}
</div> </div>
{/* Card: Category Layout Archetype */} {/* Card: Category Presentation & CMS Blocks Overview */}
<div className="admin-card p-6 md:p-8 space-y-6"> {!isNew ? (
<div className="flex items-center justify-between border-b border-white/5 pb-4"> <div className="admin-card p-6 md:p-8 space-y-6 border border-cyan-glow/20 bg-gradient-to-br from-night-900/90 via-night-900/60 to-night-950/80">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/5 pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400"> <div className="p-2.5 rounded-xl bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
<LayoutTemplate className="w-5 h-5" /> <LayoutTemplate className="w-5 h-5" />
</div> </div>
<div> <div>
<h2 className="text-lg font-bold text-white">Category Layout & Presentation</h2> <h2 className="text-base font-bold text-white flex items-center gap-2">
<p className="text-xs text-slate-400">Choose how this category displays on Storefront & Website</p> Category Presentation & CMS Blocks
</div> <span className="text-[10px] uppercase font-mono font-bold tracking-wider px-2 py-0.5 rounded-full bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30">
</div> CMS Page
<span className={`px-2.5 py-1 rounded-lg text-xs font-mono font-semibold border ${
layoutType === 'none'
? 'bg-rose-500/10 text-rose-300 border-rose-500/30'
: 'bg-white/5 text-slate-300 border-white/10'
}`}>
{layoutType === 'none' ? 'none (ปิดส่วนหัว)' : layoutType}
</span> </span>
</div> </h2>
<p className="text-xs text-slate-400">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4"> ปรับแต่ง Layout Archetype, แบนเนอร์ Featured Series, USP Benefits และ Dynamic Blocks ในหน้า CMS แบบเต็มจอ (Wide Full)
{/* Option 1: Split Showcase */}
<div
onClick={() => setLayoutType('split_showcase')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'split_showcase'
? 'bg-cyan-glow/10 border-cyan-glow shadow-lg shadow-cyan-glow/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
{/* Visual Layout Mockup */}
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2.5 flex flex-col justify-between overflow-hidden">
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<div className="h-2 w-3/4 bg-white/30 rounded" />
<div className="h-1.5 w-1/2 bg-white/15 rounded" />
</div>
<div className="w-8 h-8 rounded-lg bg-cyan-glow/30 border border-cyan-glow/50 shrink-0" />
</div>
<div className="grid grid-cols-3 gap-1 pt-1">
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<Columns className="w-4 h-4 text-cyan-glow" />
Split Showcase
</h4>
{layoutType === 'split_showcase' && (
<CheckCircle2 className="w-4 h-4 text-cyan-glow" />
)}
</div>
<p className="text-[11px] text-cyan-400 font-medium mt-0.5">ค่าเริ่มต้น (Balanced)</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
หัวข้อแบ่ง 2 ฝั่ง (ข้อความซ้าย + รูปเด่นขวา) พร้อมการจัดแสดงสินค้า 3-4 คอลัมน์มาตรฐาน
</p> </p>
</div> </div>
</div> </div>
</div>
{/* Option 2: Editorial Hero */} <Link
<div to={`/categories/${id}/cms?tab=presentation`}
onClick={() => setLayoutType('editorial_hero')} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-cyan-glow hover:bg-cyan-400 text-night-950 text-xs font-bold transition-all shadow-md shadow-cyan-glow/20 cursor-pointer self-start sm:self-auto shrink-0"
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'editorial_hero'
? 'bg-amber-500/10 border-amber-400 shadow-lg shadow-amber-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
> >
<div className="space-y-3"> <LayoutTemplate className="w-4 h-4" />
{/* Visual Layout Mockup */} <span>จัดการ Presentation & CMS Blocks</span>
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2 flex flex-col justify-between overflow-hidden"> <ExternalLink className="w-3.5 h-3.5" />
<div className="w-full h-10 rounded-lg bg-amber-500/20 border border-amber-500/40 flex flex-col items-center justify-center gap-1"> </Link>
<div className="h-2 w-16 bg-amber-200/80 rounded" />
<div className="h-1 w-10 bg-amber-200/40 rounded" />
</div>
<div className="grid grid-cols-2 gap-1.5 pt-1">
<div className="h-8 rounded-lg bg-white/10 border border-white/5" />
<div className="h-8 rounded-lg bg-white/10 border border-white/5" />
</div>
</div> </div>
<div> {/* Status Chips */}
<div className="flex items-center justify-between"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5"> <div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
<Sparkles className="w-4 h-4 text-amber-400" /> <p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Layout</p>
Editorial Hero <p className="mt-1 text-xs font-bold text-slate-200 capitalize">
</h4> {categoryMeta?.layoutType || layoutType || 'split_showcase'}
{layoutType === 'editorial_hero' && (
<CheckCircle2 className="w-4 h-4 text-amber-400" />
)}
</div>
<p className="text-[11px] text-amber-400 font-medium mt-0.5">Cinematic & Luxury</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
Hero Banner เต็มจอสไตล์นิตยสารหรูหรา พร้อมการ์ดสินค้าขนาดใหญ่ 2-3 คอลัมน์
</p> </p>
</div> </div>
</div> <div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
</div> <p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Featured Series</p>
<p className={`mt-1 text-xs font-bold ${categoryMeta?.featuredSeries?.enabled ? 'text-emerald-400' : 'text-slate-500'}`}>
{/* Option 3: Dense Catalog */} {categoryMeta?.featuredSeries?.enabled ? 'เปิดใช้งาน' : 'ปิดอยู่'}
<div
onClick={() => setLayoutType('dense_catalog')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'dense_catalog'
? 'bg-purple-500/10 border-purple-400 shadow-lg shadow-purple-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
{/* Visual Layout Mockup */}
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2 flex flex-col justify-between overflow-hidden">
<div className="flex items-center justify-between border-b border-white/10 pb-1">
<div className="h-2 w-12 bg-white/40 rounded" />
<div className="h-2 w-6 bg-purple-400/60 rounded" />
</div>
<div className="grid grid-cols-4 gap-1 pt-1">
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
<div className="h-6 rounded bg-white/10" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<Grid3X3 className="w-4 h-4 text-purple-400" />
Dense Catalog
</h4>
{layoutType === 'dense_catalog' && (
<CheckCircle2 className="w-4 h-4 text-purple-400" />
)}
</div>
<p className="text-[11px] text-purple-400 font-medium mt-0.5">High Density Shopper</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
Header ย่อกะทัดรัด พร้อมตารางสินค้าความหนาแน่นสูง 4-5 คอลัมน์ เหมาะสำหรับสินค้าจำนวนมาก
</p> </p>
</div> </div>
</div> <div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
</div> <p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">USP Benefits</p>
<p className="mt-1 text-xs font-bold text-slate-200">
{/* Option 4: None / ปิดส่วนหัว */} {(categoryMeta?.uspBenefits as any[])?.length || (uspBenefits as any[])?.length || 0} รายการ
<div
onClick={() => setLayoutType('none')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'none'
? 'bg-rose-500/10 border-rose-400 shadow-lg shadow-rose-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
{/* Visual Layout Mockup */}
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2.5 flex flex-col justify-between overflow-hidden">
<div className="w-full py-1 px-2 border-b border-dashed border-rose-500/30 flex items-center justify-between">
<div className="h-1.5 w-12 bg-white/20 rounded" />
<span className="text-[9px] font-mono uppercase text-rose-400 font-bold">No Header</span>
</div>
<div className="grid grid-cols-3 gap-1 pt-1 flex-1 items-center">
<div className="h-8 rounded bg-white/10 border border-white/5" />
<div className="h-8 rounded bg-white/10 border border-white/5" />
<div className="h-8 rounded bg-white/10 border border-white/5" />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<EyeOff className="w-4 h-4 text-rose-400" />
ปิด (No Header)
</h4>
{layoutType === 'none' && (
<CheckCircle2 className="w-4 h-4 text-rose-400" />
)}
</div>
<p className="text-[11px] text-rose-400 font-medium mt-0.5">Clean & Streamlined</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
ไม่แสดง Archetype Header ด้านบน เพื่อเริ่มแสดง Featured Series หรือสินค้าทันที ลดความซ้ำซ้อน
</p> </p>
</div> </div>
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">CMS Builder</p>
<Link
to={`/categories/${id}/cms?tab=blocks`}
className="mt-1 text-xs font-bold text-cyan-glow hover:underline flex items-center gap-1"
>
<span>Page Blocks</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div> </div>
</div> </div>
</div> </div>
) : (
<div className="admin-card p-6 border border-dashed border-white/10 text-center space-y-2">
<LayoutTemplate className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-sm font-semibold text-slate-300">Category Presentation & CMS Blocks</p>
<p className="text-xs text-slate-500 max-w-md mx-auto">
บันทึกสร้างหมวดหมู่นี้ก่อน จากนั้นจะสามารถไปปรับแต่ง Presentation & Dynamic CMS Blocks ได้ที่หน้า CMS
</p>
</div> </div>
)}
{/* Hybrid CMS Presentation Editor */}
<CategoryHybridCmsEditor
featuredSeries={featuredSeries}
onChangeFeaturedSeries={setFeaturedSeries}
uspBenefits={uspBenefits}
onChangeUspBenefits={setUspBenefits}
filterConfig={filterConfig}
onChangeFilterConfig={setFilterConfig}
onOpenMediaModal={(target) => {
setMediaTarget(target)
setIsMediaModalOpen(true)
}}
categoryId={!isNew && id ? parseInt(id) : undefined}
categoryName={name.th || name.en || 'Category'}
/>
{/* Card 3: Associated Products & Quick Link */} {/* Card 3: Associated Products & Quick Link */}
{!isNew && categoryMeta && ( {!isNew && categoryMeta && (
@@ -1046,7 +906,6 @@ export default function CategoryEditPage() {
uploadingRole={uploadingRole} uploadingRole={uploadingRole}
onChange={setMedia} onChange={setMedia}
onBrowse={(role) => { onBrowse={(role) => {
setMediaTarget('role')
setActiveMediaRole(role) setActiveMediaRole(role)
setIsMediaModalOpen(true) setIsMediaModalOpen(true)
}} }}
@@ -1085,13 +944,7 @@ export default function CategoryEditPage() {
isOpen={isMediaModalOpen} isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)} onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => { onSelect={(url) => {
if (mediaTarget === 'featured_image') {
setFeaturedSeries(prev => ({ ...prev, imageUrl: url }))
} else if (mediaTarget === 'featured_action') {
setFeaturedSeries(prev => ({ ...prev, actionImageUrl: url }))
} else {
setMediaUrl(activeMediaRole, url) setMediaUrl(activeMediaRole, url)
}
setIsMediaModalOpen(false) setIsMediaModalOpen(false)
}} }}
/> />
@@ -3,7 +3,7 @@ import Image from 'next/image'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo' import { generateSeoMetadata } from '@/lib/seo'
import type { Category, Product, AttributeDefinition, CategorySection, PageSection } from '@homaroma/types' import type { Category, Product, AttributeDefinition, CategorySection, PageSection, CategoryFilterConfig } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types' import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard' import ProductCard from '@/components/ProductCard'
@@ -14,6 +14,9 @@ import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult,
import { splitCmsLayoutSections } from '@/lib/cms-sections' import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization' import { resolveLocalized } from '@/lib/localization'
export const dynamic = 'force-dynamic'
export const revalidate = 0
function toPageSection(section: CategorySection): PageSection { function toPageSection(section: CategorySection): PageSection {
return { return {
id: section.id, id: section.id,
@@ -126,22 +129,65 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
const hasArtDirectedHero = Boolean(category.media?.some((item) => item.role === 'hero_desktop' || item.role === 'hero_mobile')) const hasArtDirectedHero = Boolean(category.media?.some((item) => item.role === 'hero_desktop' || item.role === 'hero_mobile'))
const layoutType = category.layoutType || 'split_showcase' const layoutType = category.layoutType || 'split_showcase'
// Determine Product Grid Class based on layout archetype // Determine Product Grid Class based on layout archetype, cardSize, and width mode
const getProductGridClass = () => { const getProductGridClass = (config?: CategoryFilterConfig, isFullWidth?: boolean) => {
// 1. Explicit grid columns on section block
const explicitCols = config?.gridColumns
if (explicitCols === 2) {
return 'grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8'
}
if (explicitCols === 3) {
return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8'
}
if (explicitCols === 4) {
return 'grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-5 md:gap-6'
}
if (explicitCols === 5) {
return 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5'
}
// 2. Card Size / Density setting on section block (large, compact, medium)
const cardSize = config?.cardSize ?? category.filterConfig?.cardSize
if (cardSize === 'compact') {
return isFullWidth
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 md:gap-4'
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4'
}
if (cardSize === 'large') {
return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6 md:gap-8'
}
// 3. Fallback to category-level grid columns
const categoryCols = category.filterConfig?.gridColumns
if (categoryCols === 2) return 'grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8'
if (categoryCols === 3) return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8'
if (categoryCols === 4) return 'grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-5 md:gap-6'
if (categoryCols === 5) return 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5'
switch (layoutType) { switch (layoutType) {
case 'editorial_hero': case 'editorial_hero':
return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10' return isFullWidth
? 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-6 md:gap-8'
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10'
case 'dense_catalog': case 'dense_catalog':
return 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 md:gap-6' return isFullWidth
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 md:gap-5'
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 md:gap-6'
case 'split_showcase': case 'split_showcase':
default: default:
return 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8' return isFullWidth
? 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6 md:gap-6'
: 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8'
} }
} }
const renderHeaderSection = (payload?: Record<string, unknown>) => { const renderHeaderSection = (payload?: Record<string, unknown>) => {
const showPills = payload?.showCategoryNav !== false && category.filterConfig?.showCategoryNav !== false const showPills = payload?.showCategoryNav !== false && category.filterConfig?.showCategoryNav !== false
const showBreadcrumb = payload?.showBreadcrumbs !== false const showBreadcrumb = payload?.showBreadcrumbs !== false
const isFullWidth = Boolean(category.filterConfig?.fullWidth || category.featuredSeries?.fullWidth)
const headerContainerClass = isFullWidth
? 'mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-8'
: 'mx-auto max-w-7xl px-6'
return ( return (
<div className="w-full"> <div className="w-full">
@@ -201,7 +247,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
) : layoutType === 'dense_catalog' ? ( ) : layoutType === 'dense_catalog' ? (
/* ── Archetype 3: Dense Catalog Layout ── */ /* ── Archetype 3: Dense Catalog Layout ── */
<section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10 pt-28 pb-4 md:pt-32 md:pb-5"> <section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10 pt-28 pb-4 md:pt-32 md:pb-5">
<div className="mx-auto max-w-7xl px-6 flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className={`${headerContainerClass} flex flex-col md:flex-row md:items-center justify-between gap-4`}>
<div className="space-y-1.5"> <div className="space-y-1.5">
{showBreadcrumb && ( {showBreadcrumb && (
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text"> <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
@@ -229,7 +275,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
) : ( ) : (
/* ── Archetype 1: Split Showcase Layout (Default) ── */ /* ── Archetype 1: Split Showcase Layout (Default) ── */
<section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10"> <section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10">
<div className="relative mx-auto max-w-7xl px-6 pt-32 pb-12 md:pt-32 md:pb-20 flex flex-col-reverse md:flex-row items-center gap-10 md:gap-16"> <div className={`relative ${headerContainerClass} pt-32 pb-12 md:pt-32 md:pb-20 flex flex-col-reverse md:flex-row items-center gap-10 md:gap-16`}>
<div className="flex-1 text-center md:text-left space-y-4"> <div className="flex-1 text-center md:text-left space-y-4">
{showBreadcrumb && ( {showBreadcrumb && (
<div className="flex items-center justify-center md:justify-start gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text"> <div className="flex items-center justify-center md:justify-start gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text">
@@ -261,7 +307,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
)} )}
{layoutType === 'none' && showBreadcrumb && ( {layoutType === 'none' && showBreadcrumb && (
<div className="mx-auto max-w-7xl px-6 pt-24 md:pt-28"> <div className={`${headerContainerClass} pt-24 md:pt-28`}>
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text"> <nav aria-label="Breadcrumb" className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
<Link href="/" className="transition hover:text-brand-primary">Home</Link> <Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span> <span>/</span>
@@ -273,7 +319,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
)} )}
{showPills && ( {showPills && (
<div className="mx-auto max-w-7xl px-6 pt-4"> <div className={`${headerContainerClass} pt-4`}>
<div className={`flex flex-wrap gap-2.5 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}> <div className={`flex flex-wrap gap-2.5 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}>
{!categoriesResult.ok && ( {!categoriesResult.ok && (
<span className="rounded-full border border-rose-100 bg-surface-card px-4 py-1.5 text-xs font-bold uppercase tracking-widest text-rose-500"> <span className="rounded-full border border-rose-100 bg-surface-card px-4 py-1.5 text-xs font-bold uppercase tracking-widest text-rose-500">
@@ -304,17 +350,48 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
) )
} }
const renderCatalogGrid = () => ( const renderCatalogGrid = (payload?: Record<string, unknown>) => {
<div className="mx-auto max-w-7xl space-y-5 px-6 pb-12"> const isBannerFullWidth = Boolean(category.featuredSeries?.fullWidth)
// Merge section payload (from Page Builder CATALOG_GRID block) with category.filterConfig
const effectiveFilterConfig: CategoryFilterConfig = {
...(category.filterConfig || {}),
...(payload as any || {}),
containerWidth: (payload?.containerWidth as any) || category.filterConfig?.containerWidth,
fullWidth: payload?.fullWidth !== undefined
? Boolean(payload.fullWidth)
: Boolean(category.filterConfig?.fullWidth || category.filterConfig?.containerWidth === 'full'),
cardStyle: (payload?.cardStyle as any) || category.filterConfig?.cardStyle || 'standard',
cardSize: (payload?.cardSize as any) || category.filterConfig?.cardSize || 'medium',
filterPosition: (payload?.filterPosition as any) || category.filterConfig?.filterPosition || 'sidebar',
gridColumns: (payload?.gridColumns as any) !== undefined ? (payload?.gridColumns as any) : (payload?.cardSize ? undefined : category.filterConfig?.gridColumns),
showPrice: payload?.showPrice !== undefined ? Boolean(payload.showPrice) : (category.filterConfig?.showPrice ?? true),
showAddToCart: payload?.showAddToCart !== undefined ? Boolean(payload.showAddToCart) : (category.filterConfig?.showAddToCart ?? true),
showFilters: payload?.showFilters !== undefined ? Boolean(payload.showFilters) : (category.filterConfig?.showFilters ?? true),
showToolbar: payload?.showToolbar !== undefined ? Boolean(payload.showToolbar) : (category.filterConfig?.showToolbar ?? true),
}
const isCatalogFullWidth = Boolean(effectiveFilterConfig.fullWidth || effectiveFilterConfig.containerWidth === 'full')
const catalogContainerClass = isCatalogFullWidth
? 'mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-8 pb-12'
: effectiveFilterConfig.containerWidth === 'wide'
? 'mx-auto w-full max-w-[1600px] px-4 sm:px-6 pb-12'
: 'mx-auto max-w-7xl px-6 pb-12'
return (
<div className="space-y-5">
{/* Featured Series Showcase Banner */} {/* Featured Series Showcase Banner */}
{Boolean(category.featuredSeries?.enabled) && ( {Boolean(category.featuredSeries?.enabled) && (
<div className={isBannerFullWidth ? 'mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-8' : 'mx-auto max-w-7xl px-6'}>
<CategoryFeaturedSeries <CategoryFeaturedSeries
featured={category.featuredSeries!} featured={category.featuredSeries!}
categoryName={categoryName} categoryName={categoryName}
/> />
</div>
)} )}
{/* Catalog Container (Filter Sidebar + Toolbar + Grid) */} {/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
<div className={catalogContainerClass}>
{products.length === 0 ? ( {products.length === 0 ? (
<div className="ui-panel px-6 py-20 text-center"> <div className="ui-panel px-6 py-20 text-center">
<p className="text-lg font-semibold text-text-main">No products in this category yet.</p> <p className="text-lg font-semibold text-text-main">No products in this category yet.</p>
@@ -324,20 +401,28 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<CategoryCatalogContainer <CategoryCatalogContainer
products={products} products={products}
categoryName={categoryName} categoryName={categoryName}
filterConfig={category.filterConfig} filterConfig={effectiveFilterConfig}
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []} availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
layoutGridClass={getProductGridClass()} layoutGridClass={getProductGridClass(effectiveFilterConfig, isCatalogFullWidth)}
showFilters={effectiveFilterConfig.showFilters !== false}
showToolbar={effectiveFilterConfig.showToolbar !== false}
itemsPerPage={typeof payload?.itemsPerPage === 'number' ? payload.itemsPerPage : undefined}
lang={lang} lang={lang}
/> />
)} )}
</div> </div>
</div>
) )
}
const renderUspBar = () => ( const renderUspBar = () => {
<div className="mx-auto max-w-7xl px-6 pb-12"> const isFullWidth = Boolean(category.filterConfig?.fullWidth || category.featuredSeries?.fullWidth)
return (
<div className={isFullWidth ? 'mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-8 pb-12' : 'mx-auto max-w-7xl px-6 pb-12'}>
<CategoryUspBar benefits={category.uspBenefits} /> <CategoryUspBar benefits={category.uspBenefits} />
</div> </div>
) )
}
return ( return (
<div className="min-h-screen bg-surface-page text-text-main"> <div className="min-h-screen bg-surface-page text-text-main">
@@ -357,7 +442,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
return <div key={sec.id || idx}>{renderHeaderSection(secPayload)}</div> return <div key={sec.id || idx}>{renderHeaderSection(secPayload)}</div>
} }
if (sec.type === 'CATALOG_GRID') { if (sec.type === 'CATALOG_GRID') {
return <div key={sec.id || idx}>{renderCatalogGrid()}</div> return <div key={sec.id || idx}>{renderCatalogGrid(secPayload)}</div>
} }
if (sec.type === 'USP_BAR') { if (sec.type === 'USP_BAR') {
return <div key={sec.id || idx}>{renderUspBar()}</div> return <div key={sec.id || idx}>{renderUspBar()}</div>
+15 -8
View File
@@ -5,18 +5,22 @@ import CheckoutClient from '@/components/CheckoutClient'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms' import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections' import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { getServerTranslations } from '@/lib/translations'
// lang is extracted from params // lang is extracted from params
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
const settings = await getStoreSettings(lang); const [settings, t] = await Promise.all([
getStoreSettings(lang),
Promise.resolve(getServerTranslations(lang)),
]);
const brandName = settings?.brandName || ''; const brandName = settings?.brandName || '';
const description = brandName const description = brandName
? `ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ${brandName}` ? (t.checkout?.seoDescription ? t.checkout.seoDescription.replace('{{brand}}', brandName) : `Review items and place order with ${brandName}`)
: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ'; : (t.checkout?.seoDescriptionDefault || 'Review items and place order');
return generateSeoMetadata({ return generateSeoMetadata({
title: 'Checkout', title: t.checkout?.pageHeading || 'Checkout',
description, description,
path: '/checkout', path: '/checkout',
lang, lang,
@@ -26,6 +30,7 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
export default async function CheckoutPage({ params }: { params: Promise<{ lang: string }> }) { export default async function CheckoutPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params const { lang } = await params
const t = getServerTranslations(lang)
const [categoriesResult, cmsLayoutResult] = await Promise.all([ const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang), getCategoriesResult(lang),
getCmsPageResult('checkout-layout', lang), getCmsPageResult('checkout-layout', lang),
@@ -51,16 +56,18 @@ export default async function CheckoutPage({ params }: { params: Promise<{ lang:
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-brand-primary shadow-card"> <div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-brand-primary shadow-card">
<CreditCard className="h-8 w-8" /> <CreditCard className="h-8 w-8" />
</div> </div>
<p className="ui-eyebrow">Secure Checkout</p> <p className="ui-eyebrow">{t.checkout?.title || 'Secure Checkout'}</p>
<h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl">ยืนยันคำสั่งซื้อ</h1> <h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl">
{t.checkout?.pageHeading || 'Confirm Order'}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-muted-text"> <p className="mx-auto mt-4 max-w-2xl text-muted-text">
ตรวจสอบรายการสินค้า ใช้คูปองหรือคะแนนสะสม และสร้างคำสั่งซื้อเพื่อไปยังหน้ารายละเอียดออเดอร์ {t.checkout?.pageDescription || 'Review your items and place your order.'}
</p> </p>
</section> </section>
)} )}
<section className="mx-auto max-w-7xl px-6 py-14 md:py-20"> <section className="mx-auto max-w-7xl px-6 py-14 md:py-20">
<CheckoutClient /> <CheckoutClient lang={lang} />
</section> </section>
{bottomSections.length > 0 && ( {bottomSections.length > 0 && (
+1
View File
@@ -181,6 +181,7 @@ export default async function RootLayout({
exploreMenu={footerMenus.exploreMenu} exploreMenu={footerMenus.exploreMenu}
supportMenu={footerMenus.supportMenu} supportMenu={footerMenus.supportMenu}
settings={settings} settings={settings}
lang={lang}
/> />
</ClientProviders> </ClientProviders>
</div> </div>
+42 -42
View File
@@ -7,28 +7,16 @@ import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
import { formatPrice } from '@homaroma/types' import { formatPrice } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import OrderPaymentClient from '@/components/OrderPaymentClient'
import { getCategoriesResult, getCmsPageResult, getOrderResult, getStoreSettings } from '@/lib/cms' import { getCategoriesResult, getCmsPageResult, getOrderResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections' import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { orderStatusBadgeClass, toneClass } from '@/lib/theme' import { orderStatusBadgeClass, toneClass } from '@/lib/theme'
import { getServerTranslations } from '@/lib/translations'
// lang is extracted from params
function orderLabel(id: number) { function orderLabel(id: number) {
return `#${id.toString().padStart(4, '0')}` return `#${id.toString().padStart(4, '0')}`
} }
function statusLabel(status: string) {
const labels: Record<string, string> = {
PENDING_PAYMENT: 'รอชำระเงิน',
PAID: 'ชำระเงินแล้ว',
PROCESSING: 'กำลังจัดเตรียมสินค้า',
SHIPPED: 'จัดส่งแล้ว',
DELIVERED: 'จัดส่งสำเร็จ',
CANCELLED: 'ยกเลิกแล้ว',
}
return labels[status] ?? status.replaceAll('_', ' ')
}
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params const { id, lang } = await params
const [result, settings] = await Promise.all([ const [result, settings] = await Promise.all([
@@ -38,10 +26,11 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
if (!result.ok) return { title: 'Order Not Found' } if (!result.ok) return { title: 'Order Not Found' }
const t = getServerTranslations(lang)
const brandName = settings?.brandName || '' const brandName = settings?.brandName || ''
const description = brandName const description = brandName
? `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก ${brandName}` ? `${t.order.header.orderStatus} ${orderLabel(result.data.id)} - ${brandName}`
: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)}` : `${t.order.header.orderStatus} ${orderLabel(result.data.id)}`
return generateSeoMetadata({ return generateSeoMetadata({
title: `Order ${orderLabel(result.data.id)}`, title: `Order ${orderLabel(result.data.id)}`,
@@ -54,10 +43,12 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function OrderStatusPage({ params }: { params: Promise<{ id: string, lang: string }> }) { export default async function OrderStatusPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
const { id, lang } = await params const { id, lang } = await params
const [orderResult, categoriesResult, cmsLayoutResult] = await Promise.all([ const t = getServerTranslations(lang)
const [orderResult, categoriesResult, cmsLayoutResult, settings] = await Promise.all([
getOrderResult(id), getOrderResult(id),
getCategoriesResult(lang), getCategoriesResult(lang),
getCmsPageResult('order-success-layout', lang), getCmsPageResult('order-success-layout', lang),
getStoreSettings(lang),
]) ])
const categories = categoriesResult.ok ? categoriesResult.data : [] const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : [] const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
@@ -77,9 +68,11 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
)} )}
<section className="mx-auto max-w-3xl px-6 py-24 text-center"> <section className="mx-auto max-w-3xl px-6 py-24 text-center">
<div className={`ui-panel p-10 ${toneClass('error')}`}> <div className={`ui-panel p-10 ${toneClass('error')}`}>
<h1 className="text-2xl font-semibold text-text-main">ไม่สามารถโหลดคำสั่งซื้อนี้ได้</h1> <h1 className="text-2xl font-semibold text-text-main">{t.order.navigation.loadFailed}</h1>
<p className="mt-3 text-sm text-rose-500">{orderResult.error}</p> <p className="mt-3 text-sm text-rose-500">{orderResult.error}</p>
<Link href="/track" className="mt-6 inline-flex font-semibold text-brand-primary">กลับไปค้นหาคำสั่งซื้อ</Link> <Link href="/track" className="mt-6 inline-flex font-semibold text-brand-primary">
{t.order.navigation.backToTrack}
</Link>
</div> </div>
</section> </section>
</main> </main>
@@ -88,6 +81,11 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
const order = orderResult.data const order = orderResult.data
const subtotal = order.subtotal ?? Math.max(0, order.totalAmount - order.shippingFee) const subtotal = order.subtotal ?? Math.max(0, order.totalAmount - order.shippingFee)
const statusLabel = t.order?.status?.[order.status as keyof typeof t.order.status] ?? order.status.replaceAll('_', ' ')
const formattedDate = new Date(order.createdAt).toLocaleDateString(
lang === 'th' ? 'th-TH' : lang === 'ja' ? 'ja-JP' : lang === 'zh' ? 'zh-CN' : 'en-US',
{ year: 'numeric', month: 'short', day: 'numeric' },
)
return ( return (
<main className="min-h-[70vh] bg-surface-page"> <main className="min-h-[70vh] bg-surface-page">
@@ -100,38 +98,38 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-surface-card text-brand-primary shadow-card"> <div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-surface-card text-brand-primary shadow-card">
<CheckCircle2 className="h-10 w-10" /> <CheckCircle2 className="h-10 w-10" />
</div> </div>
<p className="ui-eyebrow">Order Status</p> <p className="ui-eyebrow">{t.order.header.orderStatus}</p>
<h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl"> <h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl">
{orderLabel(order.id)} {orderLabel(order.id)}
</h1> </h1>
<p className="mt-3 text-muted-text">คำสั่งซื้อของคุณถูกบันทึกแล้ว สามารถตรวจสอบสถานะล่าสุดได้จากหน้านี้</p> <p className="mt-3 text-muted-text">{t.order.header.orderReceived}</p>
</div> </div>
<section className="grid gap-5 md:grid-cols-3"> <section className="grid gap-5 md:grid-cols-3">
<article className="ui-panel p-6"> <article className="ui-panel p-6">
<Clock className="mb-4 h-6 w-6 text-brand-primary" /> <Clock className="mb-4 h-6 w-6 text-brand-primary" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">Created</p> <p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">{t.order.cards.created}</p>
<p className="mt-2 font-semibold text-text-main"> <p className="mt-2 font-semibold text-text-main">
{new Date(order.createdAt).toLocaleDateString('th-TH')} {formattedDate}
</p> </p>
</article> </article>
<article className="ui-panel p-6"> <article className="ui-panel p-6">
<PackageCheck className="mb-4 h-6 w-6 text-brand-primary" /> <PackageCheck className="mb-4 h-6 w-6 text-brand-primary" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">Status</p> <p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">{t.order.cards.status}</p>
<span className={`mt-3 inline-flex rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.14em] ${orderStatusBadgeClass(order.status)}`}> <span className={`mt-3 inline-flex rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.14em] ${orderStatusBadgeClass(order.status)}`}>
{statusLabel(order.status)} {statusLabel}
</span> </span>
</article> </article>
<article className="ui-panel p-6"> <article className="ui-panel p-6">
<Truck className="mb-4 h-6 w-6 text-brand-primary" /> <Truck className="mb-4 h-6 w-6 text-brand-primary" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">Tracking No.</p> <p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">{t.order.cards.trackingNo}</p>
<p className="mt-2 font-semibold text-text-main">{order.trackingNo || 'อยู่ระหว่างจัดเตรียมพัสดุ'}</p> <p className="mt-2 font-semibold text-text-main">{order.trackingNo || t.order.cards.pendingTracking}</p>
</article> </article>
</section> </section>
<section className="ui-panel p-8"> <section className="ui-panel p-8">
<h2 className="border-b border-line-primary/20 pb-4 text-sm font-bold uppercase tracking-[0.22em] text-brand-primary"> <h2 className="border-b border-line-primary/20 pb-4 text-sm font-bold uppercase tracking-[0.22em] text-brand-primary">
Order Items {t.order.items.title}
</h2> </h2>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{order.items.map((item) => ( {order.items.map((item) => (
@@ -158,41 +156,43 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
<div className="mt-6 space-y-3 border-t border-line-primary/20 pt-5 text-sm"> <div className="mt-6 space-y-3 border-t border-line-primary/20 pt-5 text-sm">
<div className="flex justify-between text-muted-text"> <div className="flex justify-between text-muted-text">
<span>Subtotal</span> <span>{t.order.items.subtotal}</span>
<span>{formatPrice(subtotal)}</span> <span>{formatPrice(subtotal)}</span>
</div> </div>
<div className="flex justify-between text-muted-text"> <div className="flex justify-between text-muted-text">
<span>Shipping Fee</span> <span>{t.order.items.shippingFee}</span>
<span>{formatPrice(order.shippingFee)}</span> <span>{formatPrice(order.shippingFee)}</span>
</div> </div>
{(order.discountAmount ?? 0) > 0 && ( {(order.discountAmount ?? 0) > 0 && (
<div className="flex justify-between text-emerald-600"> <div className="flex justify-between text-emerald-600">
<span>Discount{order.couponCode ? ` (${order.couponCode})` : ''}</span> <span>{t.order.items.discount}{order.couponCode ? ` (${order.couponCode})` : ''}</span>
<span>-{formatPrice(order.discountAmount)}</span> <span>-{formatPrice(order.discountAmount)}</span>
</div> </div>
)} )}
<div className="flex justify-between border-t border-line-primary/20 pt-4 text-lg font-bold text-text-main"> <div className="flex justify-between border-t border-line-primary/20 pt-4 text-lg font-bold text-text-main">
<span>Total</span> <span>{t.order.items.total}</span>
<span className="text-brand-primary">{formatPrice(order.totalAmount)}</span> <span className="text-brand-primary">{formatPrice(order.totalAmount)}</span>
</div> </div>
</div> </div>
</section> </section>
{order.status === 'PENDING_PAYMENT' && ( {/* Interactive Payment & Slip Upload Section */}
<section className={toneClass('warning')}> <OrderPaymentClient
<h2 className="font-semibold">คำสั่งซื้อนี้ยังรอการชำระเงิน</h2> initialOrder={order}
<p className="mt-2 text-sm leading-relaxed"> bankSettings={settings ? {
หน้านี้รองรับการตรวจสอบสถานะคำสั่งซื้อแล้ว ส่วนการชำระเงินและอัปโหลดสลิปบน website จะย้ายมาในรอบถัดไป เพื่อไม่ทำ workaround ที่เสี่ยงกับ payment flow bankName: settings.bankName,
</p> bankAccountName: settings.bankAccountName,
</section> bankAccountNumber: settings.bankAccountNumber,
)} } : null}
lang={lang}
/>
<div className="flex flex-wrap justify-center gap-3"> <div className="flex flex-wrap justify-center gap-3">
<Link href="/track" className="rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main shadow-card transition hover:text-brand-primary"> <Link href="/track" className="rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main shadow-card transition hover:text-brand-primary">
ค้นหาออเดอร์อื่น {t.order.navigation.trackAnother}
</Link> </Link>
<Link href="/shop" className="rounded-full bg-brand-primary px-6 py-3 font-semibold text-surface-card transition hover:bg-brand-primary-hover"> <Link href="/shop" className="rounded-full bg-brand-primary px-6 py-3 font-semibold text-surface-card transition hover:bg-brand-primary-hover">
กลับไปช้อปต่อ {t.order.navigation.continueShopping}
</Link> </Link>
</div> </div>
</section> </section>
+94 -56
View File
@@ -3,7 +3,8 @@
import Image from 'next/image' import Image from 'next/image'
import Link from './LocalizedLink' import Link from './LocalizedLink'
import { useRouter } from 'next/navigation' 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 { AlertCircle, CheckCircle2, Loader2, PackageCheck, ShoppingBag, Ticket, UserRound } from 'lucide-react'
import { formatPrice, type CartItem, type CustomerAddress } from '@homaroma/types' 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) return Math.min(promotion.value, amount)
} }
function formatAddress(address: CustomerAddress) { 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}`
}
return `${address.addressLine}, ${address.subdistrict}, ${address.district}, ${address.province} ${address.zipcode}`
} }
async function readJson<T>(path: string, init?: RequestInit): Promise<T> { 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 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 { customer } = useAuthStore()
const customerId = customer?.id
const { items, clearCart, getCartTotal } = useCartStore() const { items, clearCart, getCartTotal } = useCartStore()
const [hydrated, setHydrated] = useState(false) const [hydrated, setHydrated] = useState(false)
const [shippingFee, setShippingFee] = useState(35) const [shippingFee, setShippingFee] = useState(35)
@@ -171,6 +193,7 @@ export default function CheckoutClient() {
}, []) }, [])
useEffect(() => { useEffect(() => {
let cancelled = false
const loadCheckoutData = async () => { const loadCheckoutData = async () => {
setLoadingSettings(true) setLoadingSettings(true)
try { try {
@@ -179,16 +202,23 @@ export default function CheckoutClient() {
readJson<Promotion[]>('/promotions/active'), readJson<Promotion[]>('/promotions/active'),
]) ])
if (cancelled) return
if (typeof settings.shippingFee === 'number') setShippingFee(settings.shippingFee) if (typeof settings.shippingFee === 'number') setShippingFee(settings.shippingFee)
setActivePromotions(Array.isArray(promotions) ? promotions : []) setActivePromotions(Array.isArray(promotions) ? promotions : [])
} catch (error) { } catch (error) {
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถโหลดข้อมูล checkout ได้') if (cancelled) return
setSubmitError(error instanceof Error ? error.message : t('checkout.errors.loadFailed'))
} finally { } finally {
if (!cancelled) {
setLoadingSettings(false) setLoadingSettings(false)
} }
} }
}
void loadCheckoutData() void loadCheckoutData()
return () => {
cancelled = true
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -237,7 +267,7 @@ export default function CheckoutClient() {
} }
void loadCustomerData() void loadCustomerData()
}, [customer]) }, [customerId])
useEffect(() => { useEffect(() => {
if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return
@@ -283,7 +313,7 @@ export default function CheckoutClient() {
}) })
if (!data.valid || !data.coupon?.code) { if (!data.valid || !data.coupon?.code) {
throw new Error(data.error || 'คูปองนี้ไม่สามารถใช้ได้') throw new Error(data.error || t('checkout.errors.invalidCoupon'))
} }
setAppliedCoupon(data.coupon.code) setAppliedCoupon(data.coupon.code)
@@ -294,7 +324,7 @@ export default function CheckoutClient() {
setAppliedCoupon(null) setAppliedCoupon(null)
setDiscountAmount(0) setDiscountAmount(0)
setIsFreeShipping(false) setIsFreeShipping(false)
setCouponError(error instanceof Error ? error.message : 'ไม่สามารถตรวจสอบคูปองได้') setCouponError(error instanceof Error ? error.message : t('checkout.errors.validateCouponFailed'))
} finally { } finally {
setCouponLoading(false) setCouponLoading(false)
} }
@@ -326,26 +356,29 @@ export default function CheckoutClient() {
const resolveShippingAddress = () => { const resolveShippingAddress = () => {
if (selectedAddressId && selectedAddressId !== 'new') { if (selectedAddressId && selectedAddressId !== 'new') {
const address = savedAddresses.find((item) => item.id === selectedAddressId) 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()) const values = Object.values(addressForm).map((value) => value.trim())
if (values.some((value) => !value)) return '' if (values.some((value) => !value)) return ''
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()}`
} }
return `${addressForm.addressLine.trim()}, ${addressForm.subdistrict.trim()}, ${addressForm.district.trim()}, ${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
}
const createOrder = async (event: React.FormEvent<HTMLFormElement>) => { const createOrder = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
setSubmitError(null) setSubmitError(null)
if (items.length === 0) { if (items.length === 0) {
setSubmitError('ตะกร้าสินค้าว่าง กรุณาเลือกสินค้าก่อน checkout') setSubmitError(t('checkout.errors.emptyCart'))
return return
} }
const shippingAddress = resolveShippingAddress() const shippingAddress = resolveShippingAddress()
if (!shippingAddress) { if (!shippingAddress) {
setSubmitError('กรุณากรอกที่อยู่จัดส่งให้ครบถ้วน') setSubmitError(t('checkout.errors.missingAddress'))
return return
} }
@@ -377,10 +410,15 @@ export default function CheckoutClient() {
}) })
clearCart() clearCart()
try {
window.localStorage.removeItem('app_active_coupon')
} catch {
// ignore localStorage errors
}
saveLocalOrder(order.id) saveLocalOrder(order.id)
router.push(getPathWithLocale(`/order/${order.id}`, lang)) router.push(getPathWithLocale(`/order/${order.id}`, lang))
} catch (error) { } catch (error) {
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถสร้างคำสั่งซื้อได้') setSubmitError(error instanceof Error ? error.message : t('checkout.errors.createOrderFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -390,7 +428,7 @@ export default function CheckoutClient() {
return ( return (
<div className="ui-panel p-10 text-center"> <div className="ui-panel p-10 text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand-primary" /> <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> </div>
) )
} }
@@ -399,10 +437,10 @@ export default function CheckoutClient() {
return ( return (
<div className="ui-panel mx-auto max-w-xl p-10 text-center"> <div className="ui-panel mx-auto max-w-xl p-10 text-center">
<ShoppingBag className="mx-auto h-14 w-14 text-brand-primary" /> <ShoppingBag className="mx-auto h-14 w-14 text-brand-primary" />
<h2 className="mt-5 text-3xl font-semibold text-text-main">ตะกร้าสินค้าว่าง</h2> <h2 className="mt-5 text-3xl font-semibold text-text-main">{t('checkout.emptyCartTitle')}</h2>
<p className="mt-3 text-muted-text">เลือกสินค้าที่คุณชอบก่อน แล้วกลับมายืนยันคำสั่งซื้อที่หน้านี้ได้เลย</p> <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"> <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> </Link>
</div> </div>
) )
@@ -418,43 +456,43 @@ export default function CheckoutClient() {
<div className="ui-panel-header"> <div className="ui-panel-header">
<UserRound className="h-5 w-5 text-brand-primary" /> <UserRound className="h-5 w-5 text-brand-primary" />
<div> <div>
<h2 className="ui-panel-title">ข้อมูลผู้รับสินค้า</h2> <h2 className="ui-panel-title">{t('checkout.recipientInfo.title')}</h2>
<p className="ui-panel-subtitle">ข้อมูลนี้จะถูกส่งไปสร้างคำสั่งซื้อตาม contract ของ backend</p> <p className="ui-panel-subtitle">{t('checkout.recipientInfo.subtitle')}</p>
</div> </div>
</div> </div>
<div className="mt-6 grid gap-5 md:grid-cols-2"> <div className="mt-6 grid gap-5 md:grid-cols-2">
<label className="space-y-2"> <label className="space-y-2">
<span className="ui-form-label">ชื่อผู้รับ *</span> <span className="ui-form-label">{t('checkout.recipientInfo.name')}</span>
<input <input
required required
value={formData.name} value={formData.name}
onChange={(event) => setFormData((current) => ({ ...current, name: event.target.value }))} onChange={(event) => setFormData((current) => ({ ...current, name: event.target.value }))}
className="ui-form-shell" className="ui-form-shell"
placeholder="ชื่อ-นามสกุล" placeholder={t('checkout.recipientInfo.namePlaceholder')}
/> />
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="ui-form-label">เบอร์โทรศัพท์ *</span> <span className="ui-form-label">{t('checkout.recipientInfo.phone')}</span>
<input <input
required required
pattern="[0-9]{9,10}" pattern="[0-9]{9,10}"
title="กรุณากรอกเบอร์โทรศัพท์ 9-10 หลัก" title={t('checkout.recipientInfo.phoneValidation')}
value={formData.phone} value={formData.phone}
onChange={(event) => setFormData((current) => ({ ...current, phone: event.target.value }))} onChange={(event) => setFormData((current) => ({ ...current, phone: event.target.value }))}
className="ui-form-shell" className="ui-form-shell"
placeholder="0812345678" placeholder={t('checkout.recipientInfo.phonePlaceholder')}
/> />
</label> </label>
<label className="space-y-2 md:col-span-2"> <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 <input
required required
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(event) => setFormData((current) => ({ ...current, email: event.target.value }))} onChange={(event) => setFormData((current) => ({ ...current, email: event.target.value }))}
className="ui-form-shell" className="ui-form-shell"
placeholder="you@example.com" placeholder={t('checkout.recipientInfo.emailPlaceholder')}
/> />
</label> </label>
</div> </div>
@@ -464,8 +502,8 @@ export default function CheckoutClient() {
<div className="ui-panel-header"> <div className="ui-panel-header">
<PackageCheck className="h-5 w-5 text-brand-primary" /> <PackageCheck className="h-5 w-5 text-brand-primary" />
<div> <div>
<h2 className="ui-panel-title">ที่อยู่จัดส่ง</h2> <h2 className="ui-panel-title">{t('checkout.shippingAddress.title')}</h2>
<p className="ui-panel-subtitle">สมาชิกสามารถเลือกที่อยู่ที่บันทึกไว้ หรือกรอกที่อยู่ใหม่ได้</p> <p className="ui-panel-subtitle">{t('checkout.shippingAddress.subtitle')}</p>
</div> </div>
</div> </div>
@@ -492,12 +530,12 @@ export default function CheckoutClient() {
{address.recipientName} {address.recipientName}
{address.isDefault && ( {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"> <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> </span>
<span className="mt-1 block text-xs text-muted-text">{address.phone}</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> </span>
</label> </label>
))} ))}
@@ -513,7 +551,7 @@ export default function CheckoutClient() {
onChange={() => selectAddress('new')} onChange={() => selectAddress('new')}
className="accent-brand-primary" 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> </label>
</div> </div>
)} )}
@@ -521,17 +559,17 @@ export default function CheckoutClient() {
{(savedAddresses.length === 0 || selectedAddressId === 'new') && ( {(savedAddresses.length === 0 || selectedAddressId === 'new') && (
<div className="mt-6 grid gap-5 md:grid-cols-2"> <div className="mt-6 grid gap-5 md:grid-cols-2">
<label className="space-y-2 md:col-span-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 <input
required={selectedAddressId === 'new' || savedAddresses.length === 0} required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.addressLine} value={addressForm.addressLine}
onChange={(event) => setAddressForm((current) => ({ ...current, addressLine: event.target.value }))} onChange={(event) => setAddressForm((current) => ({ ...current, addressLine: event.target.value }))}
className="ui-form-shell" className="ui-form-shell"
placeholder="บ้านเลขที่ หมู่บ้าน ถนน ซอย" placeholder={t('checkout.shippingAddress.addressLinePlaceholder')}
/> />
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="ui-form-label">ตำบล/แขวง *</span> <span className="ui-form-label">{t('checkout.shippingAddress.subdistrict')}</span>
<input <input
required={selectedAddressId === 'new' || savedAddresses.length === 0} required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.subdistrict} value={addressForm.subdistrict}
@@ -540,7 +578,7 @@ export default function CheckoutClient() {
/> />
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="ui-form-label">อำเภอ/เขต *</span> <span className="ui-form-label">{t('checkout.shippingAddress.district')}</span>
<input <input
required={selectedAddressId === 'new' || savedAddresses.length === 0} required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.district} value={addressForm.district}
@@ -549,7 +587,7 @@ export default function CheckoutClient() {
/> />
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="ui-form-label">จังหวัด *</span> <span className="ui-form-label">{t('checkout.shippingAddress.province')}</span>
<input <input
required={selectedAddressId === 'new' || savedAddresses.length === 0} required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.province} value={addressForm.province}
@@ -558,13 +596,13 @@ export default function CheckoutClient() {
/> />
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="ui-form-label">รหัสไปรษณีย์ *</span> <span className="ui-form-label">{t('checkout.shippingAddress.zipcode')}</span>
<input <input
required={selectedAddressId === 'new' || savedAddresses.length === 0} required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.zipcode} value={addressForm.zipcode}
onChange={(event) => setAddressForm((current) => ({ ...current, zipcode: event.target.value }))} onChange={(event) => setAddressForm((current) => ({ ...current, zipcode: event.target.value }))}
className="ui-form-shell" className="ui-form-shell"
placeholder="10110" placeholder={t('checkout.shippingAddress.zipcodePlaceholder')}
/> />
</label> </label>
</div> </div>
@@ -585,7 +623,7 @@ export default function CheckoutClient() {
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{submitting && <Loader2 className="h-5 w-5 animate-spin" />} {submitting && <Loader2 className="h-5 w-5 animate-spin" />}
สร้างคำสั่งซื้อ {t('checkout.summary.placeOrder')}
</span> </span>
<span>{formatPrice(total)}</span> <span>{formatPrice(total)}</span>
</button> </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" className="flex w-full items-center justify-between border-b border-line-primary pb-5 text-left lg:pointer-events-none"
> >
<span> <span>
<span className="block text-xl font-semibold text-text-main">สรุปรายการ</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">{items.length} รายการในตะกร้า</span> <span className="mt-1 block text-sm text-muted-text">{t('checkout.summary.itemsCount', { count: items.length })}</span>
</span> </span>
<span className="font-bold text-brand-primary lg:hidden">{formatPrice(total)}</span> <span className="font-bold text-brand-primary lg:hidden">{formatPrice(total)}</span>
</button> </button>
@@ -620,7 +658,7 @@ export default function CheckoutClient() {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-bold text-text-main">{item.productName}</p> <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> </div>
<p className="text-sm font-bold text-brand-primary">{formatPrice(item.price * item.quantity)}</p> <p className="text-sm font-bold text-brand-primary">{formatPrice(item.price * item.quantity)}</p>
</div> </div>
@@ -630,12 +668,12 @@ export default function CheckoutClient() {
<div className="border-t border-line-primary pt-5"> <div className="border-t border-line-primary pt-5">
{!appliedCoupon ? ( {!appliedCoupon ? (
<div className="space-y-2"> <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"> <div className="flex gap-2">
<input <input
value={couponCode} value={couponCode}
onChange={(event) => setCouponCode(event.target.value.toUpperCase())} 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" className="ui-form-shell min-w-0 flex-1 uppercase tracking-widest"
/> />
<button <button
@@ -644,7 +682,7 @@ export default function CheckoutClient() {
disabled={couponLoading || !couponCode.trim()} 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" 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> </button>
</div> </div>
{couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>} {couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>}
@@ -656,7 +694,7 @@ export default function CheckoutClient() {
{appliedCoupon} {appliedCoupon}
</span> </span>
<button type="button" onClick={removeCoupon} className="text-xs font-bold uppercase tracking-widest text-muted-text hover:text-rose-500"> <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> </button>
</div> </div>
)} )}
@@ -665,8 +703,8 @@ export default function CheckoutClient() {
{customerPoints > 0 && ( {customerPoints > 0 && (
<div className="space-y-3 border-t border-line-primary pt-5"> <div className="space-y-3 border-t border-line-primary pt-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-muted-text">คะแนนสะสม</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">{customerPoints} points</span> <span className="text-sm font-bold text-text-main">{t('checkout.summary.pointsBalance', { count: customerPoints })}</span>
</div> </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"> <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 <input
@@ -679,7 +717,7 @@ export default function CheckoutClient() {
className="accent-brand-primary" className="accent-brand-primary"
disabled={maxRedeemablePoints <= 0} disabled={maxRedeemablePoints <= 0}
/> />
ใช้คะแนนเป็นส่วนลด {t('checkout.summary.usePoints')}
</label> </label>
{usePoints && ( {usePoints && (
<input <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="space-y-3 border-t border-line-primary pt-5 text-sm">
<div className="flex justify-between text-muted-text"> <div className="flex justify-between text-muted-text">
<span>Subtotal</span> <span>{t('checkout.summary.subtotal')}</span>
<span>{formatPrice(subtotal)}</span> <span>{formatPrice(subtotal)}</span>
</div> </div>
{promoLevel1Disc > 0 && ( {promoLevel1Disc > 0 && (
@@ -713,18 +751,18 @@ export default function CheckoutClient() {
)} )}
{discountAmount > 0 && ( {discountAmount > 0 && (
<div className="flex justify-between text-emerald-600"> <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> <span>-{formatPrice(discountAmount)}</span>
</div> </div>
)} )}
{pointsDiscount > 0 && ( {pointsDiscount > 0 && (
<div className="flex justify-between text-emerald-600"> <div className="flex justify-between text-emerald-600">
<span>Points</span> <span>{t('checkout.summary.points')}</span>
<span>-{formatPrice(pointsDiscount)}</span> <span>-{formatPrice(pointsDiscount)}</span>
</div> </div>
)} )}
<div className="flex justify-between text-muted-text"> <div className="flex justify-between text-muted-text">
<span>Shipping</span> <span>{t('checkout.summary.shipping')}</span>
{loadingSettings ? ( {loadingSettings ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
@@ -733,12 +771,12 @@ export default function CheckoutClient() {
</div> </div>
{isFreeShipping && ( {isFreeShipping && (
<div className="flex justify-between text-emerald-600"> <div className="flex justify-between text-emerald-600">
<span>Free shipping</span> <span>{t('checkout.summary.freeShipping')}</span>
<span>-{formatPrice(shippingFee)}</span> <span>-{formatPrice(shippingFee)}</span>
</div> </div>
)} )}
<div className="flex justify-between border-t border-line-primary pt-4 text-lg font-bold text-text-main"> <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> <span className="text-brand-primary">{formatPrice(total)}</span>
</div> </div>
</div> </div>
@@ -747,7 +785,7 @@ export default function CheckoutClient() {
<div className="flex gap-2"> <div className="flex gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" /> <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<p> <p>
รอบนี้จะสร้างคำสั่งซื้อสถานะรอชำระเงิน แล้วพาไปหน้า Order Detail ส่วน payment/slip upload ยังเป็นงานถัดไปตามแผน {t('checkout.summary.notice')}
</p> </p>
</div> </div>
</div> </div>
@@ -760,7 +798,7 @@ export default function CheckoutClient() {
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{submitting && <Loader2 className="h-5 w-5 animate-spin" />} {submitting && <Loader2 className="h-5 w-5 animate-spin" />}
สร้างคำสั่งซื้อ {t('checkout.summary.placeOrder')}
</span> </span>
<span>{formatPrice(total)}</span> <span>{formatPrice(total)}</span>
</button> </button>
+5 -5
View File
@@ -21,6 +21,7 @@ interface FooterProps {
exploreMenu?: MenuItem[] exploreMenu?: MenuItem[]
supportMenu?: MenuItem[] supportMenu?: MenuItem[]
settings?: StoreSettings | null settings?: StoreSettings | null
lang?: string
} }
const layoutClassMaps = { const layoutClassMaps = {
@@ -48,12 +49,11 @@ export default function Footer({
exploreMenu = [], exploreMenu = [],
supportMenu = [], supportMenu = [],
settings = null, settings = null,
lang: propLang,
}: FooterProps) { }: FooterProps) {
const { t } = useTranslation() const { t } = useTranslation()
const lang = useLang() const routeLang = useLang()
const [mounted, setMounted] = useState(false) const effectiveLang = propLang || routeLang || 'th'
useEffect(() => { queueMicrotask(() => setMounted(true)) }, [])
const effectiveLang = mounted ? lang : 'th'
const translateMenuLabel = (item: MenuItem): string => { const translateMenuLabel = (item: MenuItem): string => {
if (effectiveLang !== 'en' && item.translations?.length) { if (effectiveLang !== 'en' && item.translations?.length) {
@@ -72,7 +72,7 @@ export default function Footer({
'Track Order': 'nav.trackOrder', 'Track Order': 'nav.trackOrder',
'Delivery & Returns': 'nav.delivery', '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) 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>
)
}
+83 -2
View File
@@ -17,9 +17,12 @@ interface ProductCardProps {
product: Product product: Product
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch' cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch'
badgeText?: string | null 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 lang = useLang()
const { addToCart } = useCartStore() const { addToCart } = useCartStore()
const selectedVariant = product.variants?.[0] || null 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 ( return (
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2"> <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"> <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} )) : null}
<div className="mt-auto flex flex-col gap-2 pt-3"> <div className="mt-auto flex flex-col gap-2 pt-3">
{showPrice && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-black text-brand-primary md:text-lg"> <span className="text-sm font-black text-brand-primary md:text-lg">
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)} {formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
</span> </span>
</div> </div>
)}
{product.variants && product.variants.length > 1 ? ( {showAddToCart && (
product.variants && product.variants.length > 1 ? (
<Link href={`/products/${productSlug}`} className="block w-full"> <Link href={`/products/${productSlug}`} className="block w-full">
<button <button
type="button" type="button"
@@ -321,6 +401,7 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
<ShoppingBag className="h-3.5 w-3.5" /> <ShoppingBag className="h-3.5 w-3.5" />
{addToCartText} {addToCartText}
</button> </button>
)
)} )}
</div> </div>
</div> </div>
@@ -1,9 +1,10 @@
"use client" "use client"
import React, { useState, useMemo } from 'react' import React, { useState, useMemo, useEffect } from 'react'
import type { Product, CategoryFilterConfig, AttributeDefinition } from '@homaroma/types' import type { Product, CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
import ProductCard from '@/components/ProductCard' import ProductCard from '@/components/ProductCard'
import CategoryFilterSidebar, { type FilterState, type DynamicFacet, type DynamicFacetOption } from './CategoryFilterSidebar' import CategoryFilterSidebar, { type FilterState, type DynamicFacet, type DynamicFacetOption } from './CategoryFilterSidebar'
import CategoryFilterHorizontalBar from './CategoryFilterHorizontalBar'
import CategoryToolbar from './CategoryToolbar' import CategoryToolbar from './CategoryToolbar'
import { FilterX, X } from 'lucide-react' import { FilterX, X } from 'lucide-react'
@@ -14,6 +15,9 @@ interface CategoryCatalogContainerProps {
availableFilters?: AttributeDefinition[] availableFilters?: AttributeDefinition[]
layoutGridClass?: string layoutGridClass?: string
lang?: string lang?: string
showFilters?: boolean
showToolbar?: boolean
itemsPerPage?: number
} }
function resolveLocalizedText(val: unknown, lang: string = 'th'): string { function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
@@ -105,6 +109,9 @@ export default function CategoryCatalogContainer({
availableFilters = [], availableFilters = [],
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8', layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
lang = 'th', lang = 'th',
showFilters = true,
showToolbar = true,
itemsPerPage,
}: CategoryCatalogContainerProps) { }: CategoryCatalogContainerProps) {
// Determine price bounds from products // Determine price bounds from products
const priceBounds = useMemo<[number, number]>(() => { const priceBounds = useMemo<[number, number]>(() => {
@@ -297,25 +304,27 @@ export default function CategoryCatalogContainer({
}) })
}, [products, filters, sortBy, dynamicFacets, lang]) }, [products, filters, sortBy, dynamicFacets, lang])
return ( const shouldShowFilters = showFilters !== false && filterConfig?.showFilters !== false
<div id="products" className="pt-6 pb-12"> const shouldShowToolbar = showToolbar !== false && filterConfig?.showToolbar !== false
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start"> const isTopBarFilter = filterConfig?.filterPosition === 'top_bar'
{/* 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>
{/* Right Column: Catalog Grid & Toolbar */} const [currentPage, setCurrentPage] = useState(1)
<div className="lg:col-span-9 space-y-6">
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 <CategoryToolbar
categoryName={categoryName} categoryName={categoryName}
totalProducts={products.length} totalProducts={products.length}
@@ -327,9 +336,10 @@ export default function CategoryCatalogContainer({
onOpenMobileFilter={() => setIsMobileFilterOpen(true)} onOpenMobileFilter={() => setIsMobileFilterOpen(true)}
activeFilterCount={activeFilterCount} activeFilterCount={activeFilterCount}
/> />
)}
{/* Active Filter Pills Bar */} {/* Active Filter Pills Bar */}
{activeFilterCount > 0 && ( {shouldShowFilters && activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2"> <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> <span className="text-xs text-muted-text font-medium">Active Filters:</span>
{filters.stockStatus.map((s) => ( {filters.stockStatus.map((s) => (
@@ -401,17 +411,111 @@ export default function CategoryCatalogContainer({
</button> </button>
</div> </div>
) : ( ) : (
<>
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}> <div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
{filteredProducts.map((product) => ( {paginatedProducts.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard
key={product.id}
product={product}
layout={viewMode}
cardStyle={filterConfig?.cardStyle || 'standard'}
showPrice={filterConfig?.showPrice ?? true}
showAddToCart={filterConfig?.showAddToCart ?? true}
/>
))} ))}
</div> </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> </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> </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 */} {/* 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="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"> <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 <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 className="absolute right-10 bottom-0 w-96 h-96 rounded-full bg-orange-500/10 blur-3xl pointer-events-none" />
</div> </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 */} {/* Left Column: Content */}
<div className="lg:col-span-7 space-y-6"> <div className="lg:col-span-7 space-y-6">
<div className="space-y-2"> <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>
)
}
+4 -2
View File
@@ -38,8 +38,9 @@ export type ApiResult<T> =
export async function fetchJson<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<T | null> { export async function fetchJson<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<T | null> {
try { try {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store',
...init, ...init,
next: init?.next ?? { revalidate: defaultRevalidate }, next: init?.next ?? { revalidate: 0 },
}) })
if (!res.ok) { if (!res.ok) {
@@ -57,8 +58,9 @@ export async function fetchJson<T>(path: string, init?: RequestInit & { next?: {
export async function fetchJsonResult<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<ApiResult<T>> { export async function fetchJsonResult<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<ApiResult<T>> {
try { try {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store',
...init, ...init,
next: init?.next ?? { revalidate: defaultRevalidate }, next: init?.next ?? { revalidate: 0 },
}) })
if (!res.ok) { if (!res.ok) {
+25
View File
@@ -0,0 +1,25 @@
import enTranslation from '../locales/en/translation.json'
import thTranslation from '../locales/th/translation.json'
import jaTranslation from '../locales/ja/translation.json'
import zhTranslation from '../locales/zh/translation.json'
import viTranslation from '../locales/vi/translation.json'
import koTranslation from '../locales/ko/translation.json'
import loTranslation from '../locales/lo/translation.json'
import itTranslation from '../locales/it/translation.json'
export type TranslationSchema = typeof thTranslation
const translations: Record<string, TranslationSchema> = {
th: thTranslation,
en: enTranslation as TranslationSchema,
ja: jaTranslation as TranslationSchema,
zh: zhTranslation as TranslationSchema,
vi: viTranslation as TranslationSchema,
ko: koTranslation as TranslationSchema,
lo: loTranslation as TranslationSchema,
it: itTranslation as TranslationSchema,
}
export function getServerTranslations(lang = 'th'): TranslationSchema {
return translations[lang] ?? translations.en ?? translations.th
}
+123 -1
View File
@@ -16,7 +16,9 @@
"placeholder": "Search scents, categories...", "placeholder": "Search scents, categories...",
"quickLinks": "Quick Links", "quickLinks": "Quick Links",
"allProducts": "All Products" "allProducts": "All Products"
} },
"trackOrder": "Track Order",
"delivery": "Delivery & Returns"
}, },
"cart": { "cart": {
"title": "Your Sanctuary", "title": "Your Sanctuary",
@@ -64,5 +66,125 @@
"message": "Message", "message": "Message",
"messagePlaceholder": "Type your message here...", "messagePlaceholder": "Type your message here...",
"submit": "Send Message" "submit": "Send Message"
},
"checkout": {
"title": "Secure Checkout",
"pageHeading": "Confirm Order",
"pageDescription": "Review your cart items, apply coupons or reward points, and place your order.",
"seoDescription": "Review your items, enter shipping details, and complete your order with {{brand}}",
"seoDescriptionDefault": "Review your items, enter shipping details, and complete your order",
"loadingCart": "Loading cart...",
"emptyCartTitle": "Your cart is empty",
"emptyCartDesc": "Choose products you love before returning here to complete your checkout.",
"continueShopping": "Return to Shop",
"recipientInfo": {
"title": "Recipient Information",
"subtitle": "This information will be used to create your order.",
"name": "Full Name *",
"namePlaceholder": "Full Name",
"phone": "Phone Number *",
"phonePlaceholder": "0812345678",
"phoneValidation": "Please enter a valid 9-10 digit phone number",
"email": "Email *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "Shipping Address",
"subtitle": "Select a saved address or enter a new one.",
"addNewAddress": "Enter New Address",
"defaultBadge": "Default",
"addressLine": "Address / Street *",
"addressLinePlaceholder": "House no., building, street",
"subdistrict": "Subdistrict *",
"district": "District *",
"province": "Province *",
"zipcode": "Postal Code *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "Order Summary",
"itemsCount": "{{count}} items in cart",
"standardVariant": "Standard",
"couponLabel": "Discount Coupon",
"couponPlaceholder": "COUPON",
"applyCoupon": "Apply",
"removeCoupon": "Remove",
"pointsLabel": "Reward Points",
"pointsBalance": "{{count}} points",
"usePoints": "Use points for discount",
"subtotal": "Subtotal",
"coupon": "Coupon",
"points": "Points",
"shipping": "Shipping",
"freeShipping": "Free shipping",
"total": "Total",
"notice": "This creates an order pending payment and redirects to Order Details. Payment/slip upload is available on the next step.",
"placeOrder": "Place Order"
},
"errors": {
"loadFailed": "Failed to load checkout settings",
"invalidCoupon": "This coupon is invalid",
"validateCouponFailed": "Unable to validate coupon",
"emptyCart": "Your cart is empty. Please select products before checkout.",
"missingAddress": "Please complete your shipping address.",
"createOrderFailed": "Failed to place order"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "Pending Payment",
"PAID": "Paid",
"PROCESSING": "Processing",
"SHIPPED": "Shipped",
"DELIVERED": "Delivered",
"CANCELLED": "Cancelled"
},
"header": {
"orderStatus": "Order Status",
"orderReceived": "Your order has been placed. You can check the latest status or complete payment below."
},
"cards": {
"created": "Order Date",
"status": "Status",
"trackingNo": "Tracking No.",
"pendingTracking": "Preparing shipment"
},
"items": {
"title": "Order Items",
"subtotal": "Subtotal",
"shippingFee": "Shipping Fee",
"discount": "Discount",
"total": "Total"
},
"payment": {
"required": "Payment Required",
"methodBank": "Bank Transfer",
"methodPromptPay": "PromptPay QR",
"bank": "Bank",
"accountNumber": "Account Number",
"accountName": "Account Name",
"copied": "Copied to clipboard",
"copyAccount": "Copy Account Number",
"transferInstruction": "Please transfer the exact amount and upload your transfer slip below.",
"slipVerification": "Payment Slip Verification",
"slipUploaded": "Slip Uploaded Successfully",
"slipUploadedDesc": "We will verify your payment and process your order shortly.",
"uploading": "Uploading...",
"dropSlip": "Click or drag to upload payment slip",
"supportFormat": "Supports JPG, PNG (Max 5MB)",
"uploadFailed": "Failed to upload payment slip. Please try again.",
"promptPayTitle": "PromptPay QR Code",
"generatePromptPay": "Generate PromptPay QR",
"generatingQr": "Generating QR Code...",
"scanPromptPay": "Scan with your banking app to complete payment",
"paymentComplete": "Payment completed. Thank you for your purchase!",
"failedGenerateQr": "Failed to generate PromptPay QR"
},
"navigation": {
"trackAnother": "Track Another Order",
"continueShopping": "Continue Shopping",
"loadFailed": "Unable to load this order",
"backToTrack": "Back to Order Search"
}
} }
} }
+123 -1
View File
@@ -16,7 +16,9 @@
"placeholder": "Cerca fragranze, categorie...", "placeholder": "Cerca fragranze, categorie...",
"quickLinks": "Link rapidi", "quickLinks": "Link rapidi",
"allProducts": "Tutti i prodotti" "allProducts": "Tutti i prodotti"
} },
"trackOrder": "Traccia Ordine",
"delivery": "Spedizione & Resi"
}, },
"cart": { "cart": {
"title": "Il tuo carrello", "title": "Il tuo carrello",
@@ -64,5 +66,125 @@
"message": "Messaggio", "message": "Messaggio",
"messagePlaceholder": "Scrivi qui il tuo messaggio...", "messagePlaceholder": "Scrivi qui il tuo messaggio...",
"submit": "Invia messaggio" "submit": "Invia messaggio"
},
"checkout": {
"title": "Checkout Sicuro",
"pageHeading": "Conferma Ordine",
"pageDescription": "Verifica gli articoli, applica coupon o punti fedeltà e invia il tuo ordine.",
"seoDescription": "Verifica gli articoli, inserisci i dati di spedizione e completa il tuo ordine con {{brand}}",
"seoDescriptionDefault": "Verifica gli articoli, inserisci i dati di spedizione e completa il tuo ordine",
"loadingCart": "Caricamento del carrello...",
"emptyCartTitle": "Il carrello è vuoto",
"emptyCartDesc": "Seleziona i tuoi prodotti preferiti prima di procedere al checkout.",
"continueShopping": "Torna allo shop",
"recipientInfo": {
"title": "Dati Destinatario",
"subtitle": "Questi dati verranno usati per creare il tuo ordine.",
"name": "Nome e Cognome *",
"namePlaceholder": "Nome e Cognome",
"phone": "Numero di Telefono *",
"phonePlaceholder": "0812345678",
"phoneValidation": "Inserisci un numero di telefono valido di 9-10 cifre",
"email": "Email *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "Indirizzo di Spedizione",
"subtitle": "Seleziona un indirizzo salvato o inseriscine uno nuovo.",
"addNewAddress": "Inserisci Nuovo Indirizzo",
"defaultBadge": "Predefinito",
"addressLine": "Indirizzo / Via / N. Civico *",
"addressLinePlaceholder": "Via, numero civico, scala",
"subdistrict": "Frazione / Zona *",
"district": "Comune *",
"province": "Provincia *",
"zipcode": "CAP *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "Riepilogo Ordine",
"itemsCount": "{{count}} articoli nel carrello",
"standardVariant": "Standard",
"couponLabel": "Coupon Sconto",
"couponPlaceholder": "COUPON",
"applyCoupon": "Applica",
"removeCoupon": "Rimuovi",
"pointsLabel": "Punti Fedeltà",
"pointsBalance": "{{count}} points",
"usePoints": "Usa i punti come sconto",
"subtotal": "Subtotale",
"coupon": "Coupon",
"points": "Punti",
"shipping": "Spedizione",
"freeShipping": "Spedizione gratuita",
"total": "Totale",
"notice": "Verrà creato un ordine in attesa di pagamento e verrai reindirizzato ai Dettagli Ordine.",
"placeOrder": "Conferma Ordine"
},
"errors": {
"loadFailed": "Impossibile caricare le impostazioni di checkout",
"invalidCoupon": "Questo coupon non è valido",
"validateCouponFailed": "Impossibile verificare il coupon",
"emptyCart": "Il carrello è vuoto. Seleziona i prodotti prima del checkout.",
"missingAddress": "Inserisci l'indirizzo di spedizione completo.",
"createOrderFailed": "Impossibile creare l'ordine"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "In attesa di pagamento",
"PAID": "Pagato",
"PROCESSING": "In elaborazione",
"SHIPPED": "Spedito",
"DELIVERED": "Consegnato",
"CANCELLED": "Annullato"
},
"header": {
"orderStatus": "Stato Ordine",
"orderReceived": "Il tuo ordine è stato registrato. Puoi verificare lo stato o completare il pagamento di seguito."
},
"cards": {
"created": "Data Ordine",
"status": "Stato",
"trackingNo": "Numero di tracciamento",
"pendingTracking": "In preparazione"
},
"items": {
"title": "Articoli dell'ordine",
"subtotal": "Subtotale",
"shippingFee": "Spese di spedizione",
"discount": "Sconto",
"total": "Totale"
},
"payment": {
"required": "Importo da pagare",
"methodBank": "Bonifico Bancario",
"methodPromptPay": "PromptPay QR",
"bank": "Banca",
"accountNumber": "Numero di conto (IBAN)",
"accountName": "Intestatario del conto",
"copied": "Numero di conto copiato",
"copyAccount": "Copia numero di conto",
"transferInstruction": "Si prega di effettuare il bonifico dell'importo esatto e caricare la ricevuta di seguito.",
"slipVerification": "Verifica della ricevuta di pagamento",
"slipUploaded": "Ricevuta caricata con successo",
"slipUploadedDesc": "Verificheremo il pagamento ed elaboreremo il tuo ordine al più presto.",
"uploading": "Caricamento in corso...",
"dropSlip": "Fai clic o trascina la ricevuta qui",
"supportFormat": "Formati supportati: JPG, PNG (Max 5MB)",
"uploadFailed": "Caricamento fallito. Riprova.",
"promptPayTitle": "Codice QR PromptPay",
"generatePromptPay": "Genera QR PromptPay",
"generatingQr": "Generazione QR in corso...",
"scanPromptPay": "Scansiona con l'app della tua banca per pagare",
"paymentComplete": "Pagamento completato. Grazie per il tuo acquisto!",
"failedGenerateQr": "Impossibile generare il QR PromptPay"
},
"navigation": {
"trackAnother": "Cerca un altro ordine",
"continueShopping": "Continua lo shopping",
"loadFailed": "Impossibile caricare questo ordine",
"backToTrack": "Torna alla ricerca ordine"
}
} }
} }
@@ -87,5 +87,125 @@
"message": "メッセージ", "message": "メッセージ",
"messagePlaceholder": "ここにメッセージを入力してください...", "messagePlaceholder": "ここにメッセージを入力してください...",
"submit": "送信" "submit": "送信"
},
"checkout": {
"title": "安全なチェックアウト",
"pageHeading": "注文内容の確認",
"pageDescription": "商品を確認し、クーポンやポイントを適用してご注文を確定してください。",
"seoDescription": "{{brand}} で注文内容を確認し、配送先を入力して注文を完了します。",
"seoDescriptionDefault": "注文内容を確認し、配送先を入力して注文を完了します。",
"loadingCart": "カートを読み込み中...",
"emptyCartTitle": "カートは空です",
"emptyCartDesc": "お気に入りの商品を選んでからチェックアウトにお進みください。",
"continueShopping": "ショッピングを続ける",
"recipientInfo": {
"title": "受取人情報",
"subtitle": "この情報は注文作成に使用されます。",
"name": "氏名 *",
"namePlaceholder": "山田 太郎",
"phone": "電話番号 *",
"phonePlaceholder": "0812345678",
"phoneValidation": "有効な電話番号(9〜10桁)を入力してください",
"email": "メールアドレス *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "配送先住所",
"subtitle": "登録済みの住所を選択するか、新しい住所を入力してください。",
"addNewAddress": "新しい住所を入力",
"defaultBadge": "デフォルト",
"addressLine": "番地・建物名・通り *",
"addressLinePlaceholder": "番地・建物名など",
"subdistrict": "町名・区 *",
"district": "市町村 *",
"province": "都道府県 *",
"zipcode": "郵便番号 *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "注文内容",
"itemsCount": "カート内に {{count}} 件",
"standardVariant": "標準",
"couponLabel": "割引クーポン",
"couponPlaceholder": "COUPON",
"applyCoupon": "適用",
"removeCoupon": "削除",
"pointsLabel": "保有ポイント",
"pointsBalance": "{{count}} ポイント",
"usePoints": "ポイントを割引に利用",
"subtotal": "小計",
"coupon": "クーポン",
"points": "ポイント",
"shipping": "送料",
"freeShipping": "送料無料",
"total": "合計",
"notice": "注文を作成して支払い待ち状態にします。決済・明細アップロードは注文詳細画面で行えます。",
"placeOrder": "注文を確定する"
},
"errors": {
"loadFailed": "チェックアウト設定の読み込みに失敗しました",
"invalidCoupon": "このクーポンは利用できません",
"validateCouponFailed": "クーポンを確認できませんでした",
"emptyCart": "カートが空です。商品を選択してください。",
"missingAddress": "配送先住所をすべて入力してください。",
"createOrderFailed": "注文を作成できませんでした"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "支払い待ち",
"PAID": "支払い完了",
"PROCESSING": "発送準備中",
"SHIPPED": "発送済み",
"DELIVERED": "配達完了",
"CANCELLED": "キャンセル済み"
},
"header": {
"orderStatus": "注文状況",
"orderReceived": "ご注文を受け付けました。最新の注文状況の確認やお支払いは下記より行えます。"
},
"cards": {
"created": "注文日",
"status": "ステータス",
"trackingNo": "追跡番号",
"pendingTracking": "発送準備中"
},
"items": {
"title": "注文商品",
"subtotal": "小計",
"shippingFee": "送料",
"discount": "割引",
"total": "合計"
},
"payment": {
"required": "お支払い金額",
"methodBank": "銀行振込",
"methodPromptPay": "PromptPay QR",
"bank": "銀行名",
"accountNumber": "口座番号",
"accountName": "口座名義",
"copied": "口座番号をコピーしました",
"copyAccount": "口座番号をコピー",
"transferInstruction": "指定の金額をお振込みの上、下記より振込明細をアップロードしてください。",
"slipVerification": "振込明細の送信",
"slipUploaded": "明細をアップロードしました",
"slipUploadedDesc": "入金確認後、速やかに発送の手続きを進めます。",
"uploading": "アップロード中...",
"dropSlip": "クリックまたはファイルをドロップして明細を送信",
"supportFormat": "JPG, PNG 対応(最大5MB)",
"uploadFailed": "アップロードに失敗しました。もう一度お試しください。",
"promptPayTitle": "PromptPay QRコード",
"generatePromptPay": "PromptPay QRコードを発行",
"generatingQr": "QRコード生成中...",
"scanPromptPay": "銀行アプリでスキャンしてお支払いください",
"paymentComplete": "お支払いが完了しました。ご購入ありがとうございます!",
"failedGenerateQr": "PromptPay QRコードの発行に失敗しました"
},
"navigation": {
"trackAnother": "他の注文を確認",
"continueShopping": "買い物を続ける",
"loadFailed": "注文情報を読み込めませんでした",
"backToTrack": "注文検索に戻る"
}
} }
} }
@@ -87,5 +87,125 @@
"message": "메시지", "message": "메시지",
"messagePlaceholder": "여기에 메시지를 입력하세요...", "messagePlaceholder": "여기에 메시지를 입력하세요...",
"submit": "메시지 보내기" "submit": "메시지 보내기"
},
"checkout": {
"title": "안전한 결제",
"pageHeading": "주문 확인",
"pageDescription": "장바구니 상품을 확인하고 쿠폰 및 포인트를 적용하여 주문을 완료하세요.",
"seoDescription": "{{brand}} 에서 상품을 확인하고 배송 정보를 입력하여 주문을 완료하세요.",
"seoDescriptionDefault": "상품을 확인하고 배송 정보를 입력하여 주문을 완료하세요.",
"loadingCart": "장바구니 불러오는 중...",
"emptyCartTitle": "장바구니가 비어 있습니다",
"emptyCartDesc": "원하는 상품을 담은 후 다시 주문을 진행해 주세요.",
"continueShopping": "쇼핑 계속하기",
"recipientInfo": {
"title": "수령인 정보",
"subtitle": "이 정보는 주문 생성 시 사용됩니다.",
"name": "수령인 성명 *",
"namePlaceholder": "홍길동",
"phone": "연락처 *",
"phonePlaceholder": "0812345678",
"phoneValidation": "9-10자리 유효한 전화번호를 입력하세요",
"email": "이메일 *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "배송지 주소",
"subtitle": "저장된 주소를 선택하거나 새 주소를 입력하세요.",
"addNewAddress": "새 주소 입력",
"defaultBadge": "기본",
"addressLine": "주소 / 도로명 / 상세주소 *",
"addressLinePlaceholder": "동/호수, 건물명, 도로명",
"subdistrict": "읍/면/동 *",
"district": "시/군/구 *",
"province": "시/도 *",
"zipcode": "우편번호 *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "주문 요약",
"itemsCount": "장바구니 상품 {{count}}개",
"standardVariant": "기본",
"couponLabel": "할인 쿠폰",
"couponPlaceholder": "COUPON",
"applyCoupon": "적용",
"removeCoupon": "삭제",
"pointsLabel": "적립 포인트",
"pointsBalance": "{{count}} 포인트",
"usePoints": "포인트 할인 사용",
"subtotal": "Subtotal",
"coupon": "Coupon",
"points": "Points",
"shipping": "Shipping",
"freeShipping": "Free shipping",
"total": "Total",
"notice": "주문이 결제 대기 상태로 생성되며 주문 상세 페이지로 이동합니다.",
"placeOrder": "주문하기"
},
"errors": {
"loadFailed": "결제 설정을 불러오지 못했습니다",
"invalidCoupon": "유효하지 않은 쿠폰입니다",
"validateCouponFailed": "쿠폰을 확인할 수 없습니다",
"emptyCart": "장바구니가 비어 있습니다. 상품을 먼저 선택해 주세요.",
"missingAddress": "배송지 주소를 모두 입력해 주세요.",
"createOrderFailed": "주문을 생성하지 못했습니다"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "결제 대기",
"PAID": "결제 완료",
"PROCESSING": "상품 준비 중",
"SHIPPED": "배송 중",
"DELIVERED": "배송 완료",
"CANCELLED": "주문 취소"
},
"header": {
"orderStatus": "주문 상태",
"orderReceived": "주문이 정상적으로 접수되었습니다. 아래에서 최신 상태를 확인하거나 결제를 진행해 주세요."
},
"cards": {
"created": "주문일시",
"status": "상태",
"trackingNo": "운송장 번호",
"pendingTracking": "배송 준비 중"
},
"items": {
"title": "주문 상품",
"subtotal": "소계",
"shippingFee": "배송비",
"discount": "할인",
"total": "총 결제금액"
},
"payment": {
"required": "결제 필요 금액",
"methodBank": "계좌 이체",
"methodPromptPay": "PromptPay QR",
"bank": "은행명",
"accountNumber": "계좌번호",
"accountName": "예금주",
"copied": "계좌번호가 복사되었습니다",
"copyAccount": "계좌번호 복사",
"transferInstruction": "해당 금액을 입금하신 후 아래에서 입금 확인증(영수증)을 첨부해 주세요.",
"slipVerification": "입금 내역 확인",
"slipUploaded": "입금증이 성공적으로 등록되었습니다",
"slipUploadedDesc": "입금 확인 후 신속하게 배송 절차를 진행하겠습니다.",
"uploading": "업로드 중...",
"dropSlip": "클릭하거나 파일을 드래그하여 입금증 업로드",
"supportFormat": "JPG, PNG 지원 (최대 5MB)",
"uploadFailed": "입금증 업로드에 실패했습니다. 다시 시도해 주세요.",
"promptPayTitle": "PromptPay QR 코드",
"generatePromptPay": "PromptPay QR 코드 생성",
"generatingQr": "QR 코드 생성 중...",
"scanPromptPay": "은행 앱으로 QR 코드를 스캔하여 결제해 주세요",
"paymentComplete": "결제가 완료되었습니다. 주문해 주셔서 감사합니다!",
"failedGenerateQr": "PromptPay QR 코드 생성 실패"
},
"navigation": {
"trackAnother": "다른 주문 조회",
"continueShopping": "쇼핑 계속하기",
"loadFailed": "주문 정보를 불러오지 못했습니다",
"backToTrack": "주문 조회로 돌아가기"
}
} }
} }
+123 -1
View File
@@ -16,7 +16,9 @@
"placeholder": "ຄົ້ນຫາກິ່ນ, ໝວດສິນຄ້າ...", "placeholder": "ຄົ້ນຫາກິ່ນ, ໝວດສິນຄ້າ...",
"quickLinks": "ລິ້ງດ່ວນ", "quickLinks": "ລິ້ງດ່ວນ",
"allProducts": "ສິນຄ້າທັງໝົດ" "allProducts": "ສິນຄ້າທັງໝົດ"
} },
"trackOrder": "ຕິດຕາມຄຳສັ່ງຊື້",
"delivery": "ການຈັດສົ່ງ ແລະ ການສົ່ງຄືນ"
}, },
"cart": { "cart": {
"title": "ຕະກ້າສິນຄ້າຂອງທ່ານ", "title": "ຕະກ້າສິນຄ້າຂອງທ່ານ",
@@ -64,5 +66,125 @@
"message": "ຂໍ້ຄວາມ", "message": "ຂໍ້ຄວາມ",
"messagePlaceholder": "ພິມຂໍ້ຄວາມຂອງທ່ານທີ່ນີ້...", "messagePlaceholder": "ພິມຂໍ້ຄວາມຂອງທ່ານທີ່ນີ້...",
"submit": "ສົ່ງຂໍ້ຄວາມ" "submit": "ສົ່ງຂໍ້ຄວາມ"
},
"checkout": {
"title": "ລະບົບສັ່ງຊື້ປອດໄພ",
"pageHeading": "ຢືນຢັນການສັ່ງຊື້",
"pageDescription": "ກວດສອບລາຍການສິນຄ້າ, ໃຊ້ຄູປອງ ຫຼື ຄະແນນສະສົມ ແລະ ສ້າງຄຳສັ່ງຊື້.",
"seoDescription": "ກວດສອບລາຍການສິນຄ້າ, ປ້ອນຂໍ້ມູນການຈັດສົ່ງ ແລະ ສັ່ງຊື້ສິນຄ້າ {{brand}}",
"seoDescriptionDefault": "ກວດສອບລາຍການສິນຄ້າ, ປ້ອນຂໍ້ມູນການຈັດສົ່ງ ແລະ ສັ່ງຊື້ສິນຄ້າ",
"loadingCart": "ກຳລັງໂຫຼດຕະກ້າສິນຄ້າ...",
"emptyCartTitle": "ຕະກ້າສິນຄ້າວ່າງເປົ່າ",
"emptyCartDesc": "ເລືອກສິນຄ້າທີ່ທ່ານມັກກ່ອນ ແລ້ວກັບມາຢືນຢັນການສັ່ງຊື້ທີ່ໜ້ານີ້.",
"continueShopping": "ກັບໄປເລືອກສິນຄ້າ",
"recipientInfo": {
"title": "ຂໍ້ມູນຜູ້ຮັບສິນຄ້າ",
"subtitle": "ຂໍ້ມູນນີ້ຈະຖືກນຳໃຊ້ເພື່ອສ້າງຄຳສັ່ງຊື້ຂອງທ່ານ.",
"name": "ຊື່ຜູ້ຮັບ *",
"namePlaceholder": "ຊື່ ແລະ ນາມສະກຸນ",
"phone": "ເບີໂທລະສັບ *",
"phonePlaceholder": "0812345678",
"phoneValidation": "ກະລຸນາປ້ອນເບີໂທລະສັບ 9-10 ຕົວເລກ",
"email": "ອີເມວ *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "ທີ່ຢູ່ຈັດສົ່ງ",
"subtitle": "ເລືອກທີ່ຢູ່ທີ່ບັນທຶກໄວ້ ຫຼື ປ້ອນທີ່ຢູ່ໃໝ່.",
"addNewAddress": "ປ້ອນທີ່ຢູ່ໃໝ່",
"defaultBadge": "ຄ່າເລີ່ມຕົ້ນ",
"addressLine": "ເຮືອນເລກທີ / ຖະໜົນ / ຮ່ອມ *",
"addressLinePlaceholder": "ເຮືອນເລກທີ, ບ້ານ, ຖະໜົນ",
"subdistrict": "ຕາແສງ / ບ້ານ *",
"district": "ເມືອງ *",
"province": "ແຂວງ *",
"zipcode": "ລະຫັດໄປສະນີ *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "ສະຫຼຸບລາຍການ",
"itemsCount": "{{count}} ລາຍການໃນຕະກ້າ",
"standardVariant": "ມາດຕະຖານ",
"couponLabel": "ຄູປອງສ່ວນຫຼຸດ",
"couponPlaceholder": "COUPON",
"applyCoupon": "ໃຊ້",
"removeCoupon": "ລຶບ",
"pointsLabel": "ຄະແນນສະສົມ",
"pointsBalance": "{{count}} points",
"usePoints": "ໃຊ້ຄະແນນເປັນສ່ວນຫຼຸດ",
"subtotal": "Subtotal",
"coupon": "Coupon",
"points": "Points",
"shipping": "Shipping",
"freeShipping": "Free shipping",
"total": "Total",
"notice": "ລະບົບຈະສ້າງຄຳສັ່ງຊື້ສະຖານະ ລໍຖ້າຊຳລະເງິນ ແລະ ພາໄປໜ້າລາຍລະອຽດອໍເດີ້.",
"placeOrder": "ສ້າງຄຳສັ່ງຊື້"
},
"errors": {
"loadFailed": "ບໍ່ສາມາດໂຫຼດຂໍ້ມູນ checkout ໄດ້",
"invalidCoupon": "ຄູປອງນີ້ບໍ່ສາມາດໃຊ້ໄດ້",
"validateCouponFailed": "ບໍ່ສາມາດກວດສອບຄູປອງໄດ້",
"emptyCart": "ຕະກ້າສິນຄ້າວ່າງ ກະລຸນາເລືອກສິນຄ້າກ່ອນ",
"missingAddress": "ກະລຸນາປ້ອນທີ່ຢູ່ຈັດສົ່ງໃຫ້ຄົບຖ້ວນ",
"createOrderFailed": "ບໍ່ສາມາດສ້າງຄຳສັ່ງຊື້ໄດ້"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "ລໍຖ້າຊຳລະເງິນ",
"PAID": "ຊຳລະເງິນແລ້ວ",
"PROCESSING": "ກຳລັງກຽມສິນຄ້າ",
"SHIPPED": "ຈັດສົ່ງແລ້ວ",
"DELIVERED": "ຈັດສົ່ງສຳເລັດ",
"CANCELLED": "ຍົກເລີກແລ້ວ"
},
"header": {
"orderStatus": "ສະຖານະຄຳສັ່ງຊື້",
"orderReceived": "ຄຳສັ່ງຊື້ຂອງທ່ານໄດ້ຮັບການບັນທຶກແລ້ວ. ທ່ານສາມາດກວດສອບສະຖານະລ່າສຸດ ຫຼື ຊຳລະເງິນໄດ້ຈາກດ້ານລຸ່ມ."
},
"cards": {
"created": "ວັນທີສັ່ງຊື້",
"status": "ສະຖານະ",
"trackingNo": "ເລກພັດສະດຸ",
"pendingTracking": "ຢູ່ລະຫວ່າງກຽມຈັດສົ່ງ"
},
"items": {
"title": "ລາຍການສິນຄ້າ",
"subtotal": "ຍອດລວມສິນຄ້າ",
"shippingFee": "ຄ່າຈັດສົ່ງ",
"discount": "ສ່ວນຫຼຸດ",
"total": "ຍອດລວມທັງໝົດ"
},
"payment": {
"required": "ຍອດທີ່ຕ້ອງຊຳລະ",
"methodBank": "ໂອນເງິນຜ່ານທະນາຄານ",
"methodPromptPay": "PromptPay QR",
"bank": "ທະນາຄານ",
"accountNumber": "ເລກບັນຊີ",
"accountName": "ຊື່ບັນຊີ",
"copied": "ສຳເນົາເລກບັນຊີແລ້ວ",
"copyAccount": "ສຳເນົາເລກບັນຊີ",
"transferInstruction": "ກະລຸນາໂອນເງິນຕາມຍອດທີ່ລະບຸ ແລະ ແນບສະລິບດ້ານລຸ່ມ.",
"slipVerification": "ແນບຫຼັກຖານການໂອນເງິນ (ສະລິບ)",
"slipUploaded": "ແນບສະລິບສຳເລັດແລ້ວ",
"slipUploadedDesc": "ພວກເຮົາຈະກວດສອບຍອດເງິນ ແລະ ດຳເນີນການຈັດສົ່ງໂດຍໄວ.",
"uploading": "ກຳລັງອັບໂຫຼດ...",
"dropSlip": "ຄລິກ ຫຼື ລາກໄຟລ໌ສະລິບມາວາງທີ່ນີ້",
"supportFormat": "ຮອງຮັບ JPG, PNG (ບໍ່ເກີນ 5MB)",
"uploadFailed": "ອັບໂຫຼດສະລິບບໍ່ສຳເລັດ ກະລຸນາລອງໃໝ່.",
"promptPayTitle": "QR Code PromptPay",
"generatePromptPay": "ສ້າງ QR Code PromptPay",
"generatingQr": "ກຳລັງສ້າງ QR Code...",
"scanPromptPay": "ສະແກນ QR Code ດ້ວຍແອັບທະນາຄານເພື່ອຊຳລະເງິນ",
"paymentComplete": "ຊຳລະເງິນສຳເລັດແລ້ວ ຂອບໃຈທີ່ສັ່ງຊື້ສິນຄ້າ!",
"failedGenerateQr": "ບໍ່ສາມາດສ້າງ PromptPay QR ໄດ້"
},
"navigation": {
"trackAnother": "ຄົ້ນຫາອໍເດີ້ອື່ນ",
"continueShopping": "ກັບໄປເລືອກຊື້ສິນຄ້າຕໍ່",
"loadFailed": "ບໍ່ສາມາດໂຫຼດຂໍ້ມູນຄຳສັ່ງຊື້ໄດ້",
"backToTrack": "ກັບໄປຄົ້ນຫາຄຳສັ່ງຊື້"
}
} }
} }
+123 -1
View File
@@ -16,7 +16,9 @@
"placeholder": "ค้นหากลิ่น, คอลเลกชัน...", "placeholder": "ค้นหากลิ่น, คอลเลกชัน...",
"quickLinks": "ลิงก์ด่วน", "quickLinks": "ลิงก์ด่วน",
"allProducts": "สินค้าทั้งหมด" "allProducts": "สินค้าทั้งหมด"
} },
"trackOrder": "ติดตามคำสั่งซื้อ",
"delivery": "การจัดส่งและการคืนสินค้า"
}, },
"cart": { "cart": {
"title": "ตะกร้าสินค้า", "title": "ตะกร้าสินค้า",
@@ -64,5 +66,125 @@
"message": "ข้อความ", "message": "ข้อความ",
"messagePlaceholder": "พิมพ์ข้อความของคุณที่นี่...", "messagePlaceholder": "พิมพ์ข้อความของคุณที่นี่...",
"submit": "ส่งข้อความ" "submit": "ส่งข้อความ"
},
"checkout": {
"title": "ระบบสั่งซื้อปลอดภัย",
"pageHeading": "ยืนยันคำสั่งซื้อ",
"pageDescription": "ตรวจสอบรายการสินค้า ใช้คูปองหรือคะแนนสะสม และสร้างคำสั่งซื้อเพื่อไปยังหน้ารายละเอียดออเดอร์",
"seoDescription": "ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ {{brand}}",
"seoDescriptionDefault": "ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ",
"loadingCart": "กำลังโหลดตะกร้าสินค้า...",
"emptyCartTitle": "ตะกร้าสินค้าว่าง",
"emptyCartDesc": "เลือกสินค้าที่คุณชอบก่อน แล้วกลับมายืนยันคำสั่งซื้อที่หน้านี้ได้เลย",
"continueShopping": "กลับไปเลือกสินค้า",
"recipientInfo": {
"title": "ข้อมูลผู้รับสินค้า",
"subtitle": "ข้อมูลนี้จะถูกส่งไปสร้างคำสั่งซื้อตาม contract ของ backend",
"name": "ชื่อผู้รับ *",
"namePlaceholder": "ชื่อ-นามสกุล",
"phone": "เบอร์โทรศัพท์ *",
"phonePlaceholder": "0812345678",
"phoneValidation": "กรุณากรอกเบอร์โทรศัพท์ 9-10 หลัก",
"email": "อีเมล *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "ที่อยู่จัดส่ง",
"subtitle": "สมาชิกสามารถเลือกที่อยู่ที่บันทึกไว้ หรือกรอกที่อยู่ใหม่ได้",
"addNewAddress": "กรอกที่อยู่ใหม่",
"defaultBadge": "ค่าเริ่มต้น",
"addressLine": "บ้านเลขที่ / ถนน / ซอย *",
"addressLinePlaceholder": "บ้านเลขที่ หมู่บ้าน ถนน ซอย",
"subdistrict": "ตำบล/แขวง *",
"district": "อำเภอ/เขต *",
"province": "จังหวัด *",
"zipcode": "รหัสไปรษณีย์ *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "สรุปรายการ",
"itemsCount": "{{count}} รายการในตะกร้า",
"standardVariant": "Standard",
"couponLabel": "คูปองส่วนลด",
"couponPlaceholder": "COUPON",
"applyCoupon": "ใช้",
"removeCoupon": "ลบ",
"pointsLabel": "คะแนนสะสม",
"pointsBalance": "{{count}} points",
"usePoints": "ใช้คะแนนเป็นส่วนลด",
"subtotal": "Subtotal",
"coupon": "Coupon",
"points": "Points",
"shipping": "Shipping",
"freeShipping": "Free shipping",
"total": "Total",
"notice": "รอบนี้จะสร้างคำสั่งซื้อสถานะรอชำระเงิน แล้วพาไปหน้า Order Detail ส่วน payment/slip upload ยังเป็นงานถัดไปตามแผน",
"placeOrder": "สร้างคำสั่งซื้อ"
},
"errors": {
"loadFailed": "ไม่สามารถโหลดข้อมูล checkout ได้",
"invalidCoupon": "คูปองนี้ไม่สามารถใช้ได้",
"validateCouponFailed": "ไม่สามารถตรวจสอบคูปองได้",
"emptyCart": "ตะกร้าสินค้าว่าง กรุณาเลือกสินค้าก่อน checkout",
"missingAddress": "กรุณากรอกที่อยู่จัดส่งให้ครบถ้วน",
"createOrderFailed": "ไม่สามารถสร้างคำสั่งซื้อได้"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "รอชำระเงิน",
"PAID": "ชำระเงินแล้ว",
"PROCESSING": "กำลังจัดเตรียมสินค้า",
"SHIPPED": "จัดส่งแล้ว",
"DELIVERED": "จัดส่งสำเร็จ",
"CANCELLED": "ยกเลิกแล้ว"
},
"header": {
"orderStatus": "สถานะคำสั่งซื้อ",
"orderReceived": "คำสั่งซื้อของคุณถูกบันทึกแล้ว สามารถตรวจสอบสถานะล่าสุดหรือชำระเงินได้จากหน้านี้"
},
"cards": {
"created": "วันที่สั่งซื้อ",
"status": "สถานะ",
"trackingNo": "เลขพัสดุ",
"pendingTracking": "อยู่ระหว่างจัดเตรียมพัสดุ"
},
"items": {
"title": "รายการสินค้า",
"subtotal": "ยอดรวมสินค้า",
"shippingFee": "ค่าจัดส่ง",
"discount": "ส่วนลด",
"total": "ยอดรวมสุทธิ"
},
"payment": {
"required": "ยอดที่ต้องชำระ",
"methodBank": "โอนเงินธนาคาร",
"methodPromptPay": "PromptPay QR",
"bank": "ธนาคาร",
"accountNumber": "เลขที่บัญชี",
"accountName": "ชื่อบัญชี",
"copied": "คัดลอกเลขบัญชีแล้ว",
"copyAccount": "คัดลอกเลขบัญชี",
"transferInstruction": "กรุณาโอนเงินตามยอดที่ระบุ และแนบสลิปด้านล่าง",
"slipVerification": "แนบหลักฐานการโอนเงิน (สลิป)",
"slipUploaded": "แนบสลิปสำเร็จแล้ว",
"slipUploadedDesc": "เราจะตรวจสอบยอดเงินและดำเนินการจัดส่งให้โดยเร็ว",
"uploading": "กำลังอัปโหลด...",
"dropSlip": "คลิกหรือลากไฟล์สลิปมาวางที่นี่",
"supportFormat": "รองรับไฟล์ JPG, PNG (ไม่เกิน 5MB)",
"uploadFailed": "อัปโหลดสลิปไม่สำเร็จ กรุณาลองใหม่อีกครั้ง",
"promptPayTitle": "คิวอาร์โค้ด PromptPay",
"generatePromptPay": "สร้าง QR Code PromptPay",
"generatingQr": "กำลังสร้าง QR Code...",
"scanPromptPay": "สแกน QR Code ด้วยแอปธนาคารเพื่อชำระเงิน",
"paymentComplete": "ชำระเงินเรียบร้อยแล้ว ขอบคุณที่สั่งซื้อสินค้ากับเรา",
"failedGenerateQr": "ไม่สามารถสร้าง PromptPay QR ได้"
},
"navigation": {
"trackAnother": "ค้นหาออเดอร์อื่น",
"continueShopping": "กลับไปช้อปต่อ",
"loadFailed": "ไม่สามารถโหลดคำสั่งซื้อนี้ได้",
"backToTrack": "กลับไปค้นหาคำสั่งซื้อ"
}
} }
} }
@@ -87,5 +87,125 @@
"message": "Tin nhắn", "message": "Tin nhắn",
"messagePlaceholder": "Nhập tin nhắn của bạn tại đây...", "messagePlaceholder": "Nhập tin nhắn của bạn tại đây...",
"submit": "Gửi tin nhắn" "submit": "Gửi tin nhắn"
},
"checkout": {
"title": "Thanh toán an toàn",
"pageHeading": "Xác nhận đơn hàng",
"pageDescription": "Kiểm tra sản phẩm, áp dụng mã giảm giá hoặc điểm tích lũy và đặt hàng.",
"seoDescription": "Kiểm tra đơn hàng, điền thông tin giao hàng và hoàn tất đơn tại {{brand}}",
"seoDescriptionDefault": "Kiểm tra đơn hàng, điền thông tin giao hàng và hoàn tất đơn hàng",
"loadingCart": "Đang tải giỏ hàng...",
"emptyCartTitle": "Giỏ hàng của bạn đang trống",
"emptyCartDesc": "Vui lòng chọn sản phẩm yêu thích trước khi tiến hành thanh toán.",
"continueShopping": "Tiếp tục mua sắm",
"recipientInfo": {
"title": "Thông tin người nhận",
"subtitle": "Thông tin này sẽ được dùng để tạo đơn hàng của bạn.",
"name": "Họ và tên *",
"namePlaceholder": "Nguyễn Văn A",
"phone": "Số điện thoại *",
"phonePlaceholder": "0812345678",
"phoneValidation": "Vui lòng nhập số điện thoại hợp lệ 9-10 chữ số",
"email": "Email *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "Địa chỉ giao hàng",
"subtitle": "Chọn địa chỉ đã lưu hoặc nhập địa chỉ mới.",
"addNewAddress": "Nhập địa chỉ mới",
"defaultBadge": "Mặc định",
"addressLine": "Số nhà / Đường / Ngõ *",
"addressLinePlaceholder": "Số nhà, tên đường, khu vực",
"subdistrict": "Phường / Xã *",
"district": "Quận / Huyện *",
"province": "Tỉnh / Thành phố *",
"zipcode": "Mã bưu điện *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "Tóm tắt đơn hàng",
"itemsCount": "{{count}} sản phẩm trong giỏ",
"standardVariant": "Tiêu chuẩn",
"couponLabel": "Mã giảm giá",
"couponPlaceholder": "COUPON",
"applyCoupon": "Áp dụng",
"removeCoupon": "Xóa",
"pointsLabel": "Điểm tích lũy",
"pointsBalance": "{{count}} điểm",
"usePoints": "Dùng điểm để giảm giá",
"subtotal": "Subtotal",
"coupon": "Coupon",
"points": "Points",
"shipping": "Shipping",
"freeShipping": "Free shipping",
"total": "Total",
"notice": "Đơn hàng sẽ được tạo ở trạng thái chờ thanh toán. Bạn có thể thanh toán và tải hóa đơn tại trang chi tiết đơn.",
"placeOrder": "Đặt hàng"
},
"errors": {
"loadFailed": "Không thể tải cấu hình thanh toán",
"invalidCoupon": "Mã giảm giá không hợp lệ",
"validateCouponFailed": "Không thể xác thực mã giảm giá",
"emptyCart": "Giỏ hàng trống. Vui lòng chọn sản phẩm trước khi thanh toán.",
"missingAddress": "Vui lòng điền đầy đủ địa chỉ giao hàng.",
"createOrderFailed": "Không thể đặt hàng"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "Chờ thanh toán",
"PAID": "Đã thanh toán",
"PROCESSING": "Đang xử lý",
"SHIPPED": "Đã giao hàng",
"DELIVERED": "Đã nhận hàng",
"CANCELLED": "Đã hủy"
},
"header": {
"orderStatus": "Trạng thái đơn hàng",
"orderReceived": "Đơn hàng của bạn đã được ghi nhận. Bạn có thể theo dõi trạng thái hoặc hoàn tất thanh toán bên dưới."
},
"cards": {
"created": "Ngày đặt",
"status": "Trạng thái",
"trackingNo": "Mã vận đơn",
"pendingTracking": "Đang chuẩn bị hàng"
},
"items": {
"title": "Sản phẩm",
"subtotal": "Tạm tính",
"shippingFee": "Phí vận chuyển",
"discount": "Giảm giá",
"total": "Tổng cộng"
},
"payment": {
"required": "Số tiền cần thanh toán",
"methodBank": "Chuyển khoản ngân hàng",
"methodPromptPay": "PromptPay QR",
"bank": "Ngân hàng",
"accountNumber": "Số tài khoản",
"accountName": "Tên chủ tài khoản",
"copied": "Đã sao chép số tài khoản",
"copyAccount": "Sao chép số tài khoản",
"transferInstruction": "Vui lòng chuyển khoản đúng số tiền và tải lên hóa đơn bên dưới.",
"slipVerification": "Tải lên chứng từ thanh toán",
"slipUploaded": "Đã tải lên chứng từ thành công",
"slipUploadedDesc": "Chúng tôi sẽ xác minh thanh toán và xử lý đơn hàng sớm nhất.",
"uploading": "Đang tải lên...",
"dropSlip": "Nhấp hoặc kéo thả chứng từ thanh toán vào đây",
"supportFormat": "Hỗ trợ định dạng JPG, PNG (Tối đa 5MB)",
"uploadFailed": "Tải chứng từ thất bại. Vui lòng thử lại.",
"promptPayTitle": "Mã QR PromptPay",
"generatePromptPay": "Tạo mã PromptPay QR",
"generatingQr": "Đang tạo mã QR...",
"scanPromptPay": "Quét mã bằng ứng dụng ngân hàng để thanh toán",
"paymentComplete": "Thanh toán thành công. Cảm ơn bạn đã mua sắm!",
"failedGenerateQr": "Không thể tạo mã PromptPay QR"
},
"navigation": {
"trackAnother": "Tra cứu đơn khác",
"continueShopping": "Tiếp tục mua sắm",
"loadFailed": "Không thể tải thông tin đơn hàng",
"backToTrack": "Quay lại tra cứu"
}
} }
} }
@@ -87,5 +87,125 @@
"message": "留言", "message": "留言",
"messagePlaceholder": "在这里输入您的留言...", "messagePlaceholder": "在这里输入您的留言...",
"submit": "发送消息" "submit": "发送消息"
},
"checkout": {
"title": "安全结账",
"pageHeading": "确认订单",
"pageDescription": "核对商品、使用优惠券或积分,并提交您的订单。",
"seoDescription": "在 {{brand}} 核对商品信息,填写收货地址并完成订单。",
"seoDescriptionDefault": "核对商品信息,填写收货地址并完成订单。",
"loadingCart": "正在加载购物车...",
"emptyCartTitle": "购物车为空",
"emptyCartDesc": "请先选购您喜欢的商品,然后返回此处完成结账。",
"continueShopping": "继续选购",
"recipientInfo": {
"title": "收货人信息",
"subtitle": "此信息将用于创建您的订单。",
"name": "收货人姓名 *",
"namePlaceholder": "姓名",
"phone": "联系电话 *",
"phonePlaceholder": "0812345678",
"phoneValidation": "请输入9-10位有效电话号码",
"email": "电子邮箱 *",
"emailPlaceholder": "you@example.com"
},
"shippingAddress": {
"title": "配送地址",
"subtitle": "可选择已保存的地址或填写新地址。",
"addNewAddress": "填写新地址",
"defaultBadge": "默认",
"addressLine": "详细地址/街道/门牌号 *",
"addressLinePlaceholder": "门牌号、小区、道路",
"subdistrict": "街道/乡镇 *",
"district": "区/县 *",
"province": "省/市 *",
"zipcode": "邮政编码 *",
"zipcodePlaceholder": "10110"
},
"summary": {
"title": "订单摘要",
"itemsCount": "购物车共 {{count}} 件商品",
"standardVariant": "标准",
"couponLabel": "优惠券",
"couponPlaceholder": "COUPON",
"applyCoupon": "使用",
"removeCoupon": "移除",
"pointsLabel": "积分",
"pointsBalance": "{{count}} 积分",
"usePoints": "使用积分抵扣",
"subtotal": "商品小计",
"coupon": "优惠券",
"points": "积分抵扣",
"shipping": "配送费",
"freeShipping": "免运费",
"total": "总计",
"notice": "提交后将生成待付款订单,并前往订单详情页处理付款和凭证上传。",
"placeOrder": "提交订单"
},
"errors": {
"loadFailed": "无法加载结账配置",
"invalidCoupon": "此优惠券不可用",
"validateCouponFailed": "无法验证优惠券",
"emptyCart": "购物车为空,请在结账前选购商品。",
"missingAddress": "请填写完整的收货地址。",
"createOrderFailed": "提交订单失败"
}
},
"order": {
"status": {
"PENDING_PAYMENT": "待付款",
"PAID": "已付款",
"PROCESSING": "处理中",
"SHIPPED": "已发货",
"DELIVERED": "已送达",
"CANCELLED": "已取消"
},
"header": {
"orderStatus": "订单状态",
"orderReceived": "您的订单已成功提交。您可以在下方查看最新状态或完成付款。"
},
"cards": {
"created": "下单时间",
"status": "状态",
"trackingNo": "快递单号",
"pendingTracking": "正在准备发货"
},
"items": {
"title": "商品清单",
"subtotal": "商品小计",
"shippingFee": "运费",
"discount": "折扣",
"total": "总计"
},
"payment": {
"required": "待支付金额",
"methodBank": "银行转账",
"methodPromptPay": "PromptPay 二维码",
"bank": "银行",
"accountNumber": "银行账号",
"accountName": "账户姓名",
"copied": "账号已复制",
"copyAccount": "复制账号",
"transferInstruction": "请按应付金额转账,并在下方上传付款水单凭证。",
"slipVerification": "上传付款凭证",
"slipUploaded": "凭证已成功上传",
"slipUploadedDesc": "我们将在核对款项后尽快为您安排发货。",
"uploading": "上传中...",
"dropSlip": "点击或拖拽付款凭证至此处",
"supportFormat": "支持 JPG, PNG (最大 5MB)",
"uploadFailed": "上传凭证失败,请重试。",
"promptPayTitle": "PromptPay 付款码",
"generatePromptPay": "生成 PromptPay 二维码",
"generatingQr": "生成二维码中...",
"scanPromptPay": "请使用支持的银行应用扫码支付",
"paymentComplete": "付款已成功完成,感谢您的购买!",
"failedGenerateQr": "生成 PromptPay 二维码失败"
},
"navigation": {
"trackAnother": "查询其他订单",
"continueShopping": "继续购物",
"loadFailed": "无法加载此订单信息",
"backToTrack": "返回订单查询"
}
} }
} }
+10
View File
@@ -39,6 +39,7 @@ export interface CategoryFeaturedSeries {
imageUrl?: string imageUrl?: string
actionImageUrl?: string actionImageUrl?: string
features?: string[] features?: string[]
fullWidth?: boolean
} }
export interface CategoryUspBenefit { export interface CategoryUspBenefit {
@@ -49,12 +50,21 @@ export interface CategoryUspBenefit {
export interface CategoryFilterConfig { export interface CategoryFilterConfig {
showCategoryNav?: boolean // Toggle Category Pills Bar showCategoryNav?: boolean // Toggle Category Pills Bar
showFilters?: boolean
showToolbar?: boolean
showSeries?: boolean showSeries?: boolean
showType?: boolean showType?: boolean
showColor?: boolean showColor?: boolean
showFitment?: boolean showFitment?: boolean
showPrice?: boolean showPrice?: boolean
showStock?: boolean showStock?: boolean
showAddToCart?: boolean
fullWidth?: boolean
containerWidth?: 'standard' | 'wide' | 'full'
gridColumns?: 2 | 3 | 4 | 5
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch'
cardSize?: 'compact' | 'medium' | 'large'
filterPosition?: 'sidebar' | 'top_bar'
} }
export interface Category { export interface Category {
-156
View File
@@ -1,156 +0,0 @@
#!/bin/bash
# ==============================================================================
# Kittisak Project - Integrated Deployment Script (post-receive hook)
# ==============================================================================
# 1. TARGET PATHS (Nginx & PM2)
TARGET_SPA="/home/ti/projects/MyNginxMain/nginx/html/beauty/spa"
TARGET_ADMIN="/home/ti/projects/MyNginxMain/nginx/html/beauty/admin"
TARGET_BACKEND="/home/ti/projects/beauty-api"
TARGET_WEBSITE="/home/ti/projects/beauty-website"
# 2. REPOSITORY CONFIG
REPO="/home/ti/git/beauty.kittisak.com.git"
BRANCH="master"
TEMP_DIR="/tmp/beauty_build_$(date +%s)"
# 3. BINARY PATHS & PM2
BUN="/home/ti/.bun/bin/bun"
PM2="/home/ti/.nvm/versions/node/v22.17.1/bin/pm2"
PM2_NAME_BACKEND="beauty-backend"
PM2_NAME_WEBSITE="beauty-website"
export PATH="$PATH:/home/ti/.bun/bin:/home/ti/.nvm/versions/node/v22.17.1/bin"
# 4. BUILD ENVIRONMENT
export VITE_API_URL="https://api.beauty.kittisak.com/api"
export NEXT_PUBLIC_API_URL="https://api.beauty.kittisak.com/api"
# Customer-specific values. Fill these in on the server if enabled.
export VITE_LIFF_ID="${VITE_LIFF_ID:-}"
export VITE_OMISE_PUBLIC_KEY="${VITE_OMISE_PUBLIC_KEY:-}"
# Service ports. Nginx proxies to these ports.
BACKEND_PORT=3057
SSR_PORT=3008
# ------------------------------------------------------------------------------
# START DEPLOYMENT
# ------------------------------------------------------------------------------
set -e
echo "------- Starting Full Deployment: beauty Platform -------"
# A. Prepare Temp Source from Checkout
echo "Cloning branch $BRANCH to temporary directory: $TEMP_DIR"
mkdir -p $TEMP_DIR
git --work-tree=$TEMP_DIR --git-dir=$REPO checkout -f $BRANCH
cd $TEMP_DIR
echo "Installing monorepo dependencies..."
$BUN install
# B. Build & Deploy SPA
echo ">>> Building SPA..."
cd apps/spa
$BUN run build
echo "Deploying SPA to $TARGET_SPA..."
mkdir -p $TARGET_SPA
rm -rf $TARGET_SPA/*
cp -rv dist/* $TARGET_SPA/
cd ../..
# C. Build & Deploy ADMIN PANEL
echo ">>> Building Admin Panel..."
cd apps/admin
$BUN run build
echo "Deploying Admin Panel to $TARGET_ADMIN..."
mkdir -p $TARGET_ADMIN
rm -rf $TARGET_ADMIN/*
cp -rv dist/* $TARGET_ADMIN/
cd ../..
# D. Deploy & Manage BACKEND (API)
echo ">>> Updating Backend at $TARGET_BACKEND..."
mkdir -p $TARGET_BACKEND
mkdir -p $TARGET_BACKEND/apps
mkdir -p $TARGET_BACKEND/packages
rsync -av --delete \
--exclude='.env' \
--exclude='public/' \
--exclude='node_modules/' \
apps/backend/ $TARGET_BACKEND/apps/backend/
rsync -av packages/ $TARGET_BACKEND/packages/
cp package.json bun.lock ecosystem.config.js $TARGET_BACKEND/
sed -i 's/homaroma/beauty/g' $TARGET_BACKEND/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3008/g' $TARGET_BACKEND/ecosystem.config.js
cd $TARGET_BACKEND
echo "Installing backend dependencies..."
$BUN install
mkdir -p $TARGET_BACKEND/apps/backend/public
cd $TARGET_BACKEND/apps/backend
echo ">>> Generating Prisma Client..."
$BUN x prisma generate
if [ ! -f "$TARGET_BACKEND/.env" ]; then
echo "Error: .env file is missing at $TARGET_BACKEND/.env. Deployment aborted."
exit 1
fi
cp "$TARGET_BACKEND/.env" "$TARGET_BACKEND/apps/backend/.env"
echo ">>> Running Prisma Migrate Deploy..."
$BUN x prisma migrate deploy
# E. PM2 Management - BACKEND
echo ">>> Managing PM2 Process: $PM2_NAME_BACKEND..."
$PM2 startOrReload $TARGET_BACKEND/ecosystem.config.js --only $PM2_NAME_BACKEND --env production --update-env
echo "PM2 process '$PM2_NAME_BACKEND' is running. Waiting 10s for it to fully start..."
sleep 10
# F. Build & Deploy WEBSITE (Next.js SSR)
echo ">>> Building Next.js SSR App inside monorepo temp dir..."
cd $TEMP_DIR/apps/website
$BUN run build
# G. Prepare Next.js SSR
echo ">>> Preparing Next.js SSR App..."
rsync -av --delete \
$TEMP_DIR/apps/website/.next/standalone/ $TARGET_WEBSITE/
rsync -av \
$TEMP_DIR/apps/website/public/ $TARGET_WEBSITE/apps/website/public/
rsync -av \
$TEMP_DIR/apps/website/.next/static/ $TARGET_WEBSITE/apps/website/.next/static/
cp $TEMP_DIR/ecosystem.config.js $TARGET_WEBSITE/
sed -i 's/homaroma/beauty/g' $TARGET_WEBSITE/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3008/g' $TARGET_WEBSITE/ecosystem.config.js
# H. Fix Permissions
echo ">>> Fixing Permissions..."
chown -R ti:ti $TARGET_BACKEND $TARGET_WEBSITE
# I. PM2 Management - SSR Next.js
cd $TARGET_WEBSITE
echo ">>> Creating symlink for .next..."
ln -sf apps/website/.next .next
echo ">>> Managing PM2 Process: $PM2_NAME_WEBSITE..."
$PM2 startOrReload $TARGET_WEBSITE/ecosystem.config.js --only $PM2_NAME_WEBSITE --env production --update-env
echo "PM2 process '$PM2_NAME_WEBSITE' is running."
# J. Cleanup
echo "Cleaning up build artifacts..."
rm -rf $TEMP_DIR
echo "------- ALL DEPLOYMENTS COMPLETED SUCCESSFULLY -------"
echo ""
echo "Service Summary:"
echo " Website (SSR) -> https://beauty.kittisak.com (PM2: $PM2_NAME_WEBSITE, Port: $SSR_PORT)"
echo " SPA -> https://spa.beauty.kittisak.com"
echo " Admin Panel -> https://admin.beauty.kittisak.com"
echo " API Backend -> https://api.kittisak.com (PM2: $PM2_NAME_BACKEND, Port: $BACKEND_PORT)"
-156
View File
@@ -1,156 +0,0 @@
#!/bin/bash
# ==============================================================================
# Kittisak Project - Integrated Deployment Script (post-receive hook)
# ==============================================================================
# 1. TARGET PATHS (Nginx & PM2)
TARGET_SPA="/home/ti/projects/MyNginxMain/nginx/html/fashion/spa"
TARGET_ADMIN="/home/ti/projects/MyNginxMain/nginx/html/fashion/admin"
TARGET_BACKEND="/home/ti/projects/fashion-api"
TARGET_WEBSITE="/home/ti/projects/fashion-website"
# 2. REPOSITORY CONFIG
REPO="/home/ti/git/fashion.kittisak.com.git"
BRANCH="master"
TEMP_DIR="/tmp/fashion_build_$(date +%s)"
# 3. BINARY PATHS & PM2
BUN="/home/ti/.bun/bin/bun"
PM2="/home/ti/.nvm/versions/node/v22.17.1/bin/pm2"
PM2_NAME_BACKEND="fashion-backend"
PM2_NAME_WEBSITE="fashion-website"
export PATH="$PATH:/home/ti/.bun/bin:/home/ti/.nvm/versions/node/v22.17.1/bin"
# 4. BUILD ENVIRONMENT
export VITE_API_URL="https://api.fashion.kittisak.com/api"
export NEXT_PUBLIC_API_URL="https://api.fashion.kittisak.com/api"
# Customer-specific values. Fill these in on the server if enabled.
export VITE_LIFF_ID="${VITE_LIFF_ID:-}"
export VITE_OMISE_PUBLIC_KEY="${VITE_OMISE_PUBLIC_KEY:-}"
# Service ports. Nginx proxies to these ports.
BACKEND_PORT=3056
SSR_PORT=3007
# ------------------------------------------------------------------------------
# START DEPLOYMENT
# ------------------------------------------------------------------------------
set -e
echo "------- Starting Full Deployment: fashion Platform -------"
# A. Prepare Temp Source from Checkout
echo "Cloning branch $BRANCH to temporary directory: $TEMP_DIR"
mkdir -p $TEMP_DIR
git --work-tree=$TEMP_DIR --git-dir=$REPO checkout -f $BRANCH
cd $TEMP_DIR
echo "Installing monorepo dependencies..."
$BUN install
# B. Build & Deploy SPA
echo ">>> Building SPA..."
cd apps/spa
$BUN run build
echo "Deploying SPA to $TARGET_SPA..."
mkdir -p $TARGET_SPA
rm -rf $TARGET_SPA/*
cp -rv dist/* $TARGET_SPA/
cd ../..
# C. Build & Deploy ADMIN PANEL
echo ">>> Building Admin Panel..."
cd apps/admin
$BUN run build
echo "Deploying Admin Panel to $TARGET_ADMIN..."
mkdir -p $TARGET_ADMIN
rm -rf $TARGET_ADMIN/*
cp -rv dist/* $TARGET_ADMIN/
cd ../..
# D. Deploy & Manage BACKEND (API)
echo ">>> Updating Backend at $TARGET_BACKEND..."
mkdir -p $TARGET_BACKEND
mkdir -p $TARGET_BACKEND/apps
mkdir -p $TARGET_BACKEND/packages
rsync -av --delete \
--exclude='.env' \
--exclude='public/' \
--exclude='node_modules/' \
apps/backend/ $TARGET_BACKEND/apps/backend/
rsync -av packages/ $TARGET_BACKEND/packages/
cp package.json bun.lock ecosystem.config.js $TARGET_BACKEND/
sed -i 's/homaroma/fashion/g' $TARGET_BACKEND/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3007/g' $TARGET_BACKEND/ecosystem.config.js
cd $TARGET_BACKEND
echo "Installing backend dependencies..."
$BUN install
mkdir -p $TARGET_BACKEND/apps/backend/public
cd $TARGET_BACKEND/apps/backend
echo ">>> Generating Prisma Client..."
$BUN x prisma generate
if [ ! -f "$TARGET_BACKEND/.env" ]; then
echo "Error: .env file is missing at $TARGET_BACKEND/.env. Deployment aborted."
exit 1
fi
cp "$TARGET_BACKEND/.env" "$TARGET_BACKEND/apps/backend/.env"
echo ">>> Running Prisma Migrate Deploy..."
$BUN x prisma migrate deploy
# E. PM2 Management - BACKEND
echo ">>> Managing PM2 Process: $PM2_NAME_BACKEND..."
$PM2 startOrReload $TARGET_BACKEND/ecosystem.config.js --only $PM2_NAME_BACKEND --env production --update-env
echo "PM2 process '$PM2_NAME_BACKEND' is running. Waiting 10s for it to fully start..."
sleep 10
# F. Build & Deploy WEBSITE (Next.js SSR)
echo ">>> Building Next.js SSR App inside monorepo temp dir..."
cd $TEMP_DIR/apps/website
$BUN run build
# G. Prepare Next.js SSR
echo ">>> Preparing Next.js SSR App..."
rsync -av --delete \
$TEMP_DIR/apps/website/.next/standalone/ $TARGET_WEBSITE/
rsync -av \
$TEMP_DIR/apps/website/public/ $TARGET_WEBSITE/apps/website/public/
rsync -av \
$TEMP_DIR/apps/website/.next/static/ $TARGET_WEBSITE/apps/website/.next/static/
cp $TEMP_DIR/ecosystem.config.js $TARGET_WEBSITE/
sed -i 's/homaroma/fashion/g' $TARGET_WEBSITE/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3007/g' $TARGET_WEBSITE/ecosystem.config.js
# H. Fix Permissions
echo ">>> Fixing Permissions..."
chown -R ti:ti $TARGET_BACKEND $TARGET_WEBSITE
# I. PM2 Management - SSR Next.js
cd $TARGET_WEBSITE
echo ">>> Creating symlink for .next..."
ln -sf apps/website/.next .next
echo ">>> Managing PM2 Process: $PM2_NAME_WEBSITE..."
$PM2 startOrReload $TARGET_WEBSITE/ecosystem.config.js --only $PM2_NAME_WEBSITE --env production --update-env
echo "PM2 process '$PM2_NAME_WEBSITE' is running."
# J. Cleanup
echo "Cleaning up build artifacts..."
rm -rf $TEMP_DIR
echo "------- ALL DEPLOYMENTS COMPLETED SUCCESSFULLY -------"
echo ""
echo "Service Summary:"
echo " Website (SSR) -> https://fashion.kittisak.com (PM2: $PM2_NAME_WEBSITE, Port: $SSR_PORT)"
echo " SPA -> https://spa.fashion.kittisak.com"
echo " Admin Panel -> https://admin.fashion.kittisak.com"
echo " API Backend -> https://api.kittisak.com (PM2: $PM2_NAME_BACKEND, Port: $BACKEND_PORT)"
-156
View File
@@ -1,156 +0,0 @@
#!/bin/bash
# ==============================================================================
# Kittisak Project - Integrated Deployment Script (post-receive hook)
# ==============================================================================
# 1. TARGET PATHS (Nginx & PM2)
TARGET_SPA="/home/ti/projects/MyNginxMain/nginx/html/moto/spa"
TARGET_ADMIN="/home/ti/projects/MyNginxMain/nginx/html/moto/admin"
TARGET_BACKEND="/home/ti/projects/moto-api"
TARGET_WEBSITE="/home/ti/projects/moto-website"
# 2. REPOSITORY CONFIG
REPO="/home/ti/git/moto.kittisak.com.git"
BRANCH="master"
TEMP_DIR="/tmp/moto_build_$(date +%s)"
# 3. BINARY PATHS & PM2
BUN="/home/ti/.bun/bin/bun"
PM2="/home/ti/.nvm/versions/node/v22.17.1/bin/pm2"
PM2_NAME_BACKEND="moto-backend"
PM2_NAME_WEBSITE="moto-website"
export PATH="$PATH:/home/ti/.bun/bin:/home/ti/.nvm/versions/node/v22.17.1/bin"
# 4. BUILD ENVIRONMENT
export VITE_API_URL="https://api.moto.kittisak.com/api"
export NEXT_PUBLIC_API_URL="https://api.moto.kittisak.com/api"
# Customer-specific values. Fill these in on the server if enabled.
export VITE_LIFF_ID="${VITE_LIFF_ID:-}"
export VITE_OMISE_PUBLIC_KEY="${VITE_OMISE_PUBLIC_KEY:-}"
# Service ports. Nginx proxies to these ports.
BACKEND_PORT=3058
SSR_PORT=3009
# ------------------------------------------------------------------------------
# START DEPLOYMENT
# ------------------------------------------------------------------------------
set -e
echo "------- Starting Full Deployment: moto Platform -------"
# A. Prepare Temp Source from Checkout
echo "Cloning branch $BRANCH to temporary directory: $TEMP_DIR"
mkdir -p $TEMP_DIR
git --work-tree=$TEMP_DIR --git-dir=$REPO checkout -f $BRANCH
cd $TEMP_DIR
echo "Installing monorepo dependencies..."
$BUN install
# B. Build & Deploy SPA
echo ">>> Building SPA..."
cd apps/spa
$BUN run build
echo "Deploying SPA to $TARGET_SPA..."
mkdir -p $TARGET_SPA
rm -rf $TARGET_SPA/*
cp -rv dist/* $TARGET_SPA/
cd ../..
# C. Build & Deploy ADMIN PANEL
echo ">>> Building Admin Panel..."
cd apps/admin
$BUN run build
echo "Deploying Admin Panel to $TARGET_ADMIN..."
mkdir -p $TARGET_ADMIN
rm -rf $TARGET_ADMIN/*
cp -rv dist/* $TARGET_ADMIN/
cd ../..
# D. Deploy & Manage BACKEND (API)
echo ">>> Updating Backend at $TARGET_BACKEND..."
mkdir -p $TARGET_BACKEND
mkdir -p $TARGET_BACKEND/apps
mkdir -p $TARGET_BACKEND/packages
rsync -av --delete \
--exclude='.env' \
--exclude='public/' \
--exclude='node_modules/' \
apps/backend/ $TARGET_BACKEND/apps/backend/
rsync -av packages/ $TARGET_BACKEND/packages/
cp package.json bun.lock ecosystem.config.js $TARGET_BACKEND/
sed -i 's/homaroma/moto/g' $TARGET_BACKEND/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3009/g' $TARGET_BACKEND/ecosystem.config.js
cd $TARGET_BACKEND
echo "Installing backend dependencies..."
$BUN install
mkdir -p $TARGET_BACKEND/apps/backend/public
cd $TARGET_BACKEND/apps/backend
echo ">>> Generating Prisma Client..."
$BUN x prisma generate
if [ ! -f "$TARGET_BACKEND/.env" ]; then
echo "Error: .env file is missing at $TARGET_BACKEND/.env. Deployment aborted."
exit 1
fi
cp "$TARGET_BACKEND/.env" "$TARGET_BACKEND/apps/backend/.env"
echo ">>> Running Prisma Migrate Deploy..."
$BUN x prisma migrate deploy
# E. PM2 Management - BACKEND
echo ">>> Managing PM2 Process: $PM2_NAME_BACKEND..."
$PM2 startOrReload $TARGET_BACKEND/ecosystem.config.js --only $PM2_NAME_BACKEND --env production --update-env
echo "PM2 process '$PM2_NAME_BACKEND' is running. Waiting 10s for it to fully start..."
sleep 10
# F. Build & Deploy WEBSITE (Next.js SSR)
echo ">>> Building Next.js SSR App inside monorepo temp dir..."
cd $TEMP_DIR/apps/website
$BUN run build
# G. Prepare Next.js SSR
echo ">>> Preparing Next.js SSR App..."
rsync -av --delete \
$TEMP_DIR/apps/website/.next/standalone/ $TARGET_WEBSITE/
rsync -av \
$TEMP_DIR/apps/website/public/ $TARGET_WEBSITE/apps/website/public/
rsync -av \
$TEMP_DIR/apps/website/.next/static/ $TARGET_WEBSITE/apps/website/.next/static/
cp $TEMP_DIR/ecosystem.config.js $TARGET_WEBSITE/
sed -i 's/homaroma/moto/g' $TARGET_WEBSITE/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3009/g' $TARGET_WEBSITE/ecosystem.config.js
# H. Fix Permissions
echo ">>> Fixing Permissions..."
chown -R ti:ti $TARGET_BACKEND $TARGET_WEBSITE
# I. PM2 Management - SSR Next.js
cd $TARGET_WEBSITE
echo ">>> Creating symlink for .next..."
ln -sf apps/website/.next .next
echo ">>> Managing PM2 Process: $PM2_NAME_WEBSITE..."
$PM2 startOrReload $TARGET_WEBSITE/ecosystem.config.js --only $PM2_NAME_WEBSITE --env production --update-env
echo "PM2 process '$PM2_NAME_WEBSITE' is running."
# J. Cleanup
echo "Cleaning up build artifacts..."
rm -rf $TEMP_DIR
echo "------- ALL DEPLOYMENTS COMPLETED SUCCESSFULLY -------"
echo ""
echo "Service Summary:"
echo " Website (SSR) -> https://moto.kittisak.com (PM2: $PM2_NAME_WEBSITE, Port: $SSR_PORT)"
echo " SPA -> https://spa.moto.kittisak.com"
echo " Admin Panel -> https://admin.moto.kittisak.com"
echo " API Backend -> https://api.kittisak.com (PM2: $PM2_NAME_BACKEND, Port: $BACKEND_PORT)"
-156
View File
@@ -1,156 +0,0 @@
#!/bin/bash
# ==============================================================================
# Kittisak Project - Integrated Deployment Script (post-receive hook)
# ==============================================================================
# 1. TARGET PATHS (Nginx & PM2)
TARGET_SPA="/home/ti/projects/MyNginxMain/nginx/html/wellness/spa"
TARGET_ADMIN="/home/ti/projects/MyNginxMain/nginx/html/wellness/admin"
TARGET_BACKEND="/home/ti/projects/wellness-api"
TARGET_WEBSITE="/home/ti/projects/wellness-website"
# 2. REPOSITORY CONFIG
REPO="/home/ti/git/wellness.kittisak.com.git"
BRANCH="master"
TEMP_DIR="/tmp/wellness_build_$(date +%s)"
# 3. BINARY PATHS & PM2
BUN="/home/ti/.bun/bin/bun"
PM2="/home/ti/.nvm/versions/node/v22.17.1/bin/pm2"
PM2_NAME_BACKEND="wellness-backend"
PM2_NAME_WEBSITE="wellness-website"
export PATH="$PATH:/home/ti/.bun/bin:/home/ti/.nvm/versions/node/v22.17.1/bin"
# 4. BUILD ENVIRONMENT
export VITE_API_URL="https://api.wellness.kittisak.com/api"
export NEXT_PUBLIC_API_URL="https://api.wellness.kittisak.com/api"
# Customer-specific values. Fill these in on the server if enabled.
export VITE_LIFF_ID="${VITE_LIFF_ID:-}"
export VITE_OMISE_PUBLIC_KEY="${VITE_OMISE_PUBLIC_KEY:-}"
# Service ports. Nginx proxies to these ports.
BACKEND_PORT=3055
SSR_PORT=3006
# ------------------------------------------------------------------------------
# START DEPLOYMENT
# ------------------------------------------------------------------------------
set -e
echo "------- Starting Full Deployment: wellness Platform -------"
# A. Prepare Temp Source from Checkout
echo "Cloning branch $BRANCH to temporary directory: $TEMP_DIR"
mkdir -p $TEMP_DIR
git --work-tree=$TEMP_DIR --git-dir=$REPO checkout -f $BRANCH
cd $TEMP_DIR
echo "Installing monorepo dependencies..."
$BUN install
# B. Build & Deploy SPA
echo ">>> Building SPA..."
cd apps/spa
$BUN run build
echo "Deploying SPA to $TARGET_SPA..."
mkdir -p $TARGET_SPA
rm -rf $TARGET_SPA/*
cp -rv dist/* $TARGET_SPA/
cd ../..
# C. Build & Deploy ADMIN PANEL
echo ">>> Building Admin Panel..."
cd apps/admin
$BUN run build
echo "Deploying Admin Panel to $TARGET_ADMIN..."
mkdir -p $TARGET_ADMIN
rm -rf $TARGET_ADMIN/*
cp -rv dist/* $TARGET_ADMIN/
cd ../..
# D. Deploy & Manage BACKEND (API)
echo ">>> Updating Backend at $TARGET_BACKEND..."
mkdir -p $TARGET_BACKEND
mkdir -p $TARGET_BACKEND/apps
mkdir -p $TARGET_BACKEND/packages
rsync -av --delete \
--exclude='.env' \
--exclude='public/' \
--exclude='node_modules/' \
apps/backend/ $TARGET_BACKEND/apps/backend/
rsync -av packages/ $TARGET_BACKEND/packages/
cp package.json bun.lock ecosystem.config.js $TARGET_BACKEND/
sed -i 's/homaroma/wellness/g' $TARGET_BACKEND/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3006/g' $TARGET_BACKEND/ecosystem.config.js
cd $TARGET_BACKEND
echo "Installing backend dependencies..."
$BUN install
mkdir -p $TARGET_BACKEND/apps/backend/public
cd $TARGET_BACKEND/apps/backend
echo ">>> Generating Prisma Client..."
$BUN x prisma generate
if [ ! -f "$TARGET_BACKEND/.env" ]; then
echo "Error: .env file is missing at $TARGET_BACKEND/.env. Deployment aborted."
exit 1
fi
cp "$TARGET_BACKEND/.env" "$TARGET_BACKEND/apps/backend/.env"
echo ">>> Running Prisma Migrate Deploy..."
$BUN x prisma migrate deploy
# E. PM2 Management - BACKEND
echo ">>> Managing PM2 Process: $PM2_NAME_BACKEND..."
$PM2 startOrReload $TARGET_BACKEND/ecosystem.config.js --only $PM2_NAME_BACKEND --env production --update-env
echo "PM2 process '$PM2_NAME_BACKEND' is running. Waiting 10s for it to fully start..."
sleep 10
# F. Build & Deploy WEBSITE (Next.js SSR)
echo ">>> Building Next.js SSR App inside monorepo temp dir..."
cd $TEMP_DIR/apps/website
$BUN run build
# G. Prepare Next.js SSR
echo ">>> Preparing Next.js SSR App..."
rsync -av --delete \
$TEMP_DIR/apps/website/.next/standalone/ $TARGET_WEBSITE/
rsync -av \
$TEMP_DIR/apps/website/public/ $TARGET_WEBSITE/apps/website/public/
rsync -av \
$TEMP_DIR/apps/website/.next/static/ $TARGET_WEBSITE/apps/website/.next/static/
cp $TEMP_DIR/ecosystem.config.js $TARGET_WEBSITE/
sed -i 's/homaroma/wellness/g' $TARGET_WEBSITE/ecosystem.config.js
sed -i 's/PORT: 3002/PORT: 3006/g' $TARGET_WEBSITE/ecosystem.config.js
# H. Fix Permissions
echo ">>> Fixing Permissions..."
chown -R ti:ti $TARGET_BACKEND $TARGET_WEBSITE
# I. PM2 Management - SSR Next.js
cd $TARGET_WEBSITE
echo ">>> Creating symlink for .next..."
ln -sf apps/website/.next .next
echo ">>> Managing PM2 Process: $PM2_NAME_WEBSITE..."
$PM2 startOrReload $TARGET_WEBSITE/ecosystem.config.js --only $PM2_NAME_WEBSITE --env production --update-env
echo "PM2 process '$PM2_NAME_WEBSITE' is running."
# J. Cleanup
echo "Cleaning up build artifacts..."
rm -rf $TEMP_DIR
echo "------- ALL DEPLOYMENTS COMPLETED SUCCESSFULLY -------"
echo ""
echo "Service Summary:"
echo " Website (SSR) -> https://wellness.kittisak.com (PM2: $PM2_NAME_WEBSITE, Port: $SSR_PORT)"
echo " SPA -> https://spa.wellness.kittisak.com"
echo " Admin Panel -> https://admin.wellness.kittisak.com"
echo " API Backend -> https://api.kittisak.com (PM2: $PM2_NAME_BACKEND, Port: $BACKEND_PORT)"