x
This commit is contained in:
1 parent
b7f337fb79
commit
bf9599008e
29 files changed
+3828
-1541
No files matched your search
@@ -13,7 +13,11 @@ import {
|
||||
CheckCircle2,
|
||||
Tag,
|
||||
Sliders,
|
||||
Maximize2
|
||||
Maximize2,
|
||||
LayoutGrid,
|
||||
PanelLeft,
|
||||
PanelsTopBottom,
|
||||
Palette,
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig, type AttributeDefinition } from '@homaroma/types'
|
||||
import CategoryFilterManagerModal from './CategoryFilterManagerModal'
|
||||
@@ -228,6 +232,52 @@ export default function CategoryHybridCmsEditor({
|
||||
|
||||
{featuredSeries.enabled && (
|
||||
<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">
|
||||
{/* Badge Kicker */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -502,6 +552,289 @@ export default function CategoryHybridCmsEditor({
|
||||
{/* ── TAB 3: Filter System Configuration ──────────────────────────── */}
|
||||
{activeTab === 'filters' && (
|
||||
<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 */}
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
@@ -23,9 +22,6 @@ import {
|
||||
Download,
|
||||
Sparkles,
|
||||
LayoutTemplate,
|
||||
Columns,
|
||||
Grid3X3,
|
||||
EyeOff,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||
@@ -118,8 +114,6 @@ export default function CategoryEditPage() {
|
||||
showStock: true,
|
||||
})
|
||||
|
||||
const [mediaTarget, setMediaTarget] = useState<'role' | 'featured_image' | 'featured_action'>('role')
|
||||
|
||||
// Media Library Modal
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
|
||||
@@ -470,11 +464,11 @@ export default function CategoryEditPage() {
|
||||
|
||||
{!isNew && (
|
||||
<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"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" />
|
||||
<span>CMS Blocks</span>
|
||||
<span>CMS & Presentation</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -803,212 +797,78 @@ export default function CategoryEditPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card: Category Layout Archetype */}
|
||||
<div className="admin-card p-6 md:p-8 space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
|
||||
<LayoutTemplate className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Category Layout & Presentation</h2>
|
||||
<p className="text-xs text-slate-400">Choose how this category displays on 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">
|
||||
{/* 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>
|
||||
{/* Card: Category Presentation & CMS Blocks Overview */}
|
||||
{!isNew ? (
|
||||
<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="p-2.5 rounded-xl bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
|
||||
<LayoutTemplate className="w-5 h-5" />
|
||||
</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 คอลัมน์มาตรฐาน
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
Category Presentation & CMS Blocks
|
||||
<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">
|
||||
CMS Page
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
ปรับแต่ง Layout Archetype, แบนเนอร์ Featured Series, USP Benefits และ Dynamic Blocks ในหน้า CMS แบบเต็มจอ (Wide Full)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/categories/${id}/cms?tab=presentation`}
|
||||
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"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" />
|
||||
<span>จัดการ Presentation & CMS Blocks</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</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">
|
||||
{/* 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="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>
|
||||
{/* Status Chips */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<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">Layout</p>
|
||||
<p className="mt-1 text-xs font-bold text-slate-200 capitalize">
|
||||
{categoryMeta?.layoutType || layoutType || 'split_showcase'}
|
||||
</p>
|
||||
</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">
|
||||
{/* 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>
|
||||
</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">Featured Series</p>
|
||||
<p className={`mt-1 text-xs font-bold ${categoryMeta?.featuredSeries?.enabled ? 'text-emerald-400' : 'text-slate-500'}`}>
|
||||
{categoryMeta?.featuredSeries?.enabled ? 'เปิดใช้งาน' : 'ปิดอยู่'}
|
||||
</p>
|
||||
</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">
|
||||
{/* 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>
|
||||
</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">USP Benefits</p>
|
||||
<p className="mt-1 text-xs font-bold text-slate-200">
|
||||
{(categoryMeta?.uspBenefits as any[])?.length || (uspBenefits as any[])?.length || 0} รายการ
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* 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'}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Card 3: Associated Products & Quick Link */}
|
||||
{!isNew && categoryMeta && (
|
||||
@@ -1046,7 +906,6 @@ export default function CategoryEditPage() {
|
||||
uploadingRole={uploadingRole}
|
||||
onChange={setMedia}
|
||||
onBrowse={(role) => {
|
||||
setMediaTarget('role')
|
||||
setActiveMediaRole(role)
|
||||
setIsMediaModalOpen(true)
|
||||
}}
|
||||
@@ -1085,13 +944,7 @@ export default function CategoryEditPage() {
|
||||
isOpen={isMediaModalOpen}
|
||||
onClose={() => setIsMediaModalOpen(false)}
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
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 DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
@@ -14,6 +14,9 @@ import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult,
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { resolveLocalized } from '@/lib/localization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
function toPageSection(section: CategorySection): PageSection {
|
||||
return {
|
||||
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 layoutType = category.layoutType || 'split_showcase'
|
||||
|
||||
// Determine Product Grid Class based on layout archetype
|
||||
const getProductGridClass = () => {
|
||||
// Determine Product Grid Class based on layout archetype, cardSize, and width mode
|
||||
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) {
|
||||
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':
|
||||
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':
|
||||
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 showPills = payload?.showCategoryNav !== false && category.filterConfig?.showCategoryNav !== 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 (
|
||||
<div className="w-full">
|
||||
@@ -201,7 +247,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
|
||||
) : layoutType === 'dense_catalog' ? (
|
||||
/* ── 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">
|
||||
<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">
|
||||
{showBreadcrumb && (
|
||||
<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) ── */
|
||||
<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">
|
||||
{showBreadcrumb && (
|
||||
<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 && (
|
||||
<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">
|
||||
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
|
||||
<span>/</span>
|
||||
@@ -273,7 +319,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
|
||||
)}
|
||||
|
||||
{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'}`}>
|
||||
{!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">
|
||||
@@ -304,40 +350,79 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
|
||||
)
|
||||
}
|
||||
|
||||
const renderCatalogGrid = () => (
|
||||
<div className="mx-auto max-w-7xl space-y-5 px-6 pb-12">
|
||||
{/* Featured Series Showcase Banner */}
|
||||
{Boolean(category.featuredSeries?.enabled) && (
|
||||
<CategoryFeaturedSeries
|
||||
featured={category.featuredSeries!}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
)}
|
||||
const renderCatalogGrid = (payload?: Record<string, unknown>) => {
|
||||
const isBannerFullWidth = Boolean(category.featuredSeries?.fullWidth)
|
||||
|
||||
{/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
|
||||
{products.length === 0 ? (
|
||||
<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>
|
||||
<Link href="/shop" className="mt-4 inline-flex font-semibold text-brand-primary">Back to Shop</Link>
|
||||
// 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 */}
|
||||
{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
|
||||
featured={category.featuredSeries!}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
|
||||
<div className={catalogContainerClass}>
|
||||
{products.length === 0 ? (
|
||||
<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>
|
||||
<Link href="/shop" className="mt-4 inline-flex font-semibold text-brand-primary">Back to Shop</Link>
|
||||
</div>
|
||||
) : (
|
||||
<CategoryCatalogContainer
|
||||
products={products}
|
||||
categoryName={categoryName}
|
||||
filterConfig={effectiveFilterConfig}
|
||||
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
|
||||
layoutGridClass={getProductGridClass(effectiveFilterConfig, isCatalogFullWidth)}
|
||||
showFilters={effectiveFilterConfig.showFilters !== false}
|
||||
showToolbar={effectiveFilterConfig.showToolbar !== false}
|
||||
itemsPerPage={typeof payload?.itemsPerPage === 'number' ? payload.itemsPerPage : undefined}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CategoryCatalogContainer
|
||||
products={products}
|
||||
categoryName={categoryName}
|
||||
filterConfig={category.filterConfig}
|
||||
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
|
||||
layoutGridClass={getProductGridClass()}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderUspBar = () => (
|
||||
<div className="mx-auto max-w-7xl px-6 pb-12">
|
||||
<CategoryUspBar benefits={category.uspBenefits} />
|
||||
</div>
|
||||
)
|
||||
const renderUspBar = () => {
|
||||
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} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
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') {
|
||||
return <div key={sec.id || idx}>{renderUspBar()}</div>
|
||||
|
||||
@@ -5,18 +5,22 @@ import CheckoutClient from '@/components/CheckoutClient'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { getServerTranslations } from '@/lib/translations'
|
||||
|
||||
// lang is extracted from params
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
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 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({
|
||||
title: 'Checkout',
|
||||
title: t.checkout?.pageHeading || 'Checkout',
|
||||
description,
|
||||
path: '/checkout',
|
||||
lang,
|
||||
@@ -26,6 +30,7 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
|
||||
|
||||
export default async function CheckoutPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
const t = getServerTranslations(lang)
|
||||
const [categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||
getCategoriesResult(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">
|
||||
<CreditCard className="h-8 w-8" />
|
||||
</div>
|
||||
<p className="ui-eyebrow">Secure Checkout</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl">ยืนยันคำสั่งซื้อ</h1>
|
||||
<p className="ui-eyebrow">{t.checkout?.title || 'Secure Checkout'}</p>
|
||||
<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">
|
||||
ตรวจสอบรายการสินค้า ใช้คูปองหรือคะแนนสะสม และสร้างคำสั่งซื้อเพื่อไปยังหน้ารายละเอียดออเดอร์
|
||||
{t.checkout?.pageDescription || 'Review your items and place your order.'}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 py-14 md:py-20">
|
||||
<CheckoutClient />
|
||||
<CheckoutClient lang={lang} />
|
||||
</section>
|
||||
|
||||
{bottomSections.length > 0 && (
|
||||
|
||||
@@ -181,6 +181,7 @@ export default async function RootLayout({
|
||||
exploreMenu={footerMenus.exploreMenu}
|
||||
supportMenu={footerMenus.supportMenu}
|
||||
settings={settings}
|
||||
lang={lang}
|
||||
/>
|
||||
</ClientProviders>
|
||||
</div>
|
||||
|
||||
@@ -7,28 +7,16 @@ import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
|
||||
import { formatPrice } from '@homaroma/types'
|
||||
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import OrderPaymentClient from '@/components/OrderPaymentClient'
|
||||
import { getCategoriesResult, getCmsPageResult, getOrderResult, getStoreSettings } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { orderStatusBadgeClass, toneClass } from '@/lib/theme'
|
||||
import { getServerTranslations } from '@/lib/translations'
|
||||
|
||||
// lang is extracted from params
|
||||
function orderLabel(id: number) {
|
||||
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> {
|
||||
const { id, lang } = await params
|
||||
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' }
|
||||
|
||||
const t = getServerTranslations(lang)
|
||||
const brandName = settings?.brandName || ''
|
||||
const description = brandName
|
||||
? `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก ${brandName}`
|
||||
: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)}`
|
||||
? `${t.order.header.orderStatus} ${orderLabel(result.data.id)} - ${brandName}`
|
||||
: `${t.order.header.orderStatus} ${orderLabel(result.data.id)}`
|
||||
|
||||
return generateSeoMetadata({
|
||||
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 }> }) {
|
||||
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),
|
||||
getCategoriesResult(lang),
|
||||
getCmsPageResult('order-success-layout', lang),
|
||||
getStoreSettings(lang),
|
||||
])
|
||||
const categories = categoriesResult.ok ? categoriesResult.data : []
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
@@ -88,6 +81,11 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
|
||||
|
||||
const order = orderResult.data
|
||||
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 (
|
||||
<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">
|
||||
<CheckCircle2 className="h-10 w-10" />
|
||||
</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">
|
||||
{orderLabel(order.id)}
|
||||
</h1>
|
||||
<p className="mt-3 text-muted-text">คำสั่งซื้อของคุณถูกบันทึกแล้ว สามารถตรวจสอบสถานะล่าสุดได้จากหน้านี้</p>
|
||||
<p className="mt-3 text-muted-text">{t.order.header.orderReceived}</p>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-5 md:grid-cols-3">
|
||||
<article className="ui-panel p-6">
|
||||
<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">
|
||||
{new Date(order.createdAt).toLocaleDateString('th-TH')}
|
||||
{formattedDate}
|
||||
</p>
|
||||
</article>
|
||||
<article className="ui-panel p-6">
|
||||
<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)}`}>
|
||||
{statusLabel(order.status)}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</article>
|
||||
<article className="ui-panel p-6">
|
||||
<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="mt-2 font-semibold text-text-main">{order.trackingNo || 'อยู่ระหว่างจัดเตรียมพัสดุ'}</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 || t.order.cards.pendingTracking}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
Order Items
|
||||
{t.order.items.title}
|
||||
</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{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="flex justify-between text-muted-text">
|
||||
<span>Subtotal</span>
|
||||
<span>{t.order.items.subtotal}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-text">
|
||||
<span>Shipping Fee</span>
|
||||
<span>{t.order.items.shippingFee}</span>
|
||||
<span>{formatPrice(order.shippingFee)}</span>
|
||||
</div>
|
||||
{(order.discountAmount ?? 0) > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{order.status === 'PENDING_PAYMENT' && (
|
||||
<section className={toneClass('warning')}>
|
||||
<h2 className="font-semibold">คำสั่งซื้อนี้ยังรอการชำระเงิน</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed">
|
||||
หน้านี้รองรับการตรวจสอบสถานะคำสั่งซื้อแล้ว ส่วนการชำระเงินและอัปโหลดสลิปบน website จะย้ายมาในรอบถัดไป เพื่อไม่ทำ workaround ที่เสี่ยงกับ payment flow
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{/* Interactive Payment & Slip Upload Section */}
|
||||
<OrderPaymentClient
|
||||
initialOrder={order}
|
||||
bankSettings={settings ? {
|
||||
bankName: settings.bankName,
|
||||
bankAccountName: settings.bankAccountName,
|
||||
bankAccountNumber: settings.bankAccountNumber,
|
||||
} : null}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<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">
|
||||
ค้นหาออเดอร์อื่น
|
||||
{t.order.navigation.trackAnother}
|
||||
</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">
|
||||
กลับไปช้อปต่อ
|
||||
{t.order.navigation.continueShopping}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertCircle, CheckCircle2, Loader2, PackageCheck, ShoppingBag, Ticket, UserRound } from 'lucide-react'
|
||||
import { formatPrice, type CartItem, type CustomerAddress } from '@homaroma/types'
|
||||
|
||||
@@ -73,8 +74,11 @@ function calculatePromotionDiscount(promotion: Promotion | undefined, amount: nu
|
||||
return Math.min(promotion.value, amount)
|
||||
}
|
||||
|
||||
function formatAddress(address: CustomerAddress) {
|
||||
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
|
||||
function formatAddress(address: CustomerAddress, isThai = true) {
|
||||
if (isThai) {
|
||||
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
|
||||
}
|
||||
return `${address.addressLine}, ${address.subdistrict}, ${address.district}, ${address.province} ${address.zipcode}`
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -117,10 +121,28 @@ function saveLocalOrder(orderId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function CheckoutClient() {
|
||||
export default function CheckoutClient({ lang: propLang }: { lang?: string } = {}) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const routeLang = useLang()
|
||||
const lang = propLang || routeLang
|
||||
const { t: i18nT, i18n } = useTranslation()
|
||||
const isThai = lang === 'th'
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && i18n.language !== lang) {
|
||||
void i18n.changeLanguage(lang)
|
||||
}
|
||||
}, [lang, i18n])
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
return i18nT(key, { lng: lang, ...options })
|
||||
},
|
||||
[i18nT, lang],
|
||||
)
|
||||
|
||||
const { customer } = useAuthStore()
|
||||
const customerId = customer?.id
|
||||
const { items, clearCart, getCartTotal } = useCartStore()
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [shippingFee, setShippingFee] = useState(35)
|
||||
@@ -171,6 +193,7 @@ export default function CheckoutClient() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadCheckoutData = async () => {
|
||||
setLoadingSettings(true)
|
||||
try {
|
||||
@@ -179,16 +202,23 @@ export default function CheckoutClient() {
|
||||
readJson<Promotion[]>('/promotions/active'),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (typeof settings.shippingFee === 'number') setShippingFee(settings.shippingFee)
|
||||
setActivePromotions(Array.isArray(promotions) ? promotions : [])
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถโหลดข้อมูล checkout ได้')
|
||||
if (cancelled) return
|
||||
setSubmitError(error instanceof Error ? error.message : t('checkout.errors.loadFailed'))
|
||||
} finally {
|
||||
setLoadingSettings(false)
|
||||
if (!cancelled) {
|
||||
setLoadingSettings(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCheckoutData()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -237,7 +267,7 @@ export default function CheckoutClient() {
|
||||
}
|
||||
|
||||
void loadCustomerData()
|
||||
}, [customer])
|
||||
}, [customerId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return
|
||||
@@ -283,7 +313,7 @@ export default function CheckoutClient() {
|
||||
})
|
||||
|
||||
if (!data.valid || !data.coupon?.code) {
|
||||
throw new Error(data.error || 'คูปองนี้ไม่สามารถใช้ได้')
|
||||
throw new Error(data.error || t('checkout.errors.invalidCoupon'))
|
||||
}
|
||||
|
||||
setAppliedCoupon(data.coupon.code)
|
||||
@@ -294,7 +324,7 @@ export default function CheckoutClient() {
|
||||
setAppliedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
setCouponError(error instanceof Error ? error.message : 'ไม่สามารถตรวจสอบคูปองได้')
|
||||
setCouponError(error instanceof Error ? error.message : t('checkout.errors.validateCouponFailed'))
|
||||
} finally {
|
||||
setCouponLoading(false)
|
||||
}
|
||||
@@ -326,12 +356,15 @@ export default function CheckoutClient() {
|
||||
const resolveShippingAddress = () => {
|
||||
if (selectedAddressId && selectedAddressId !== 'new') {
|
||||
const address = savedAddresses.find((item) => item.id === selectedAddressId)
|
||||
if (address) return formatAddress(address)
|
||||
if (address) return formatAddress(address, isThai)
|
||||
}
|
||||
|
||||
const values = Object.values(addressForm).map((value) => value.trim())
|
||||
if (values.some((value) => !value)) return ''
|
||||
return `${addressForm.addressLine.trim()} ต.${addressForm.subdistrict.trim()} อ.${addressForm.district.trim()} จ.${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
|
||||
if (isThai) {
|
||||
return `${addressForm.addressLine.trim()} ต.${addressForm.subdistrict.trim()} อ.${addressForm.district.trim()} จ.${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
|
||||
}
|
||||
return `${addressForm.addressLine.trim()}, ${addressForm.subdistrict.trim()}, ${addressForm.district.trim()}, ${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
|
||||
}
|
||||
|
||||
const createOrder = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -339,13 +372,13 @@ export default function CheckoutClient() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (items.length === 0) {
|
||||
setSubmitError('ตะกร้าสินค้าว่าง กรุณาเลือกสินค้าก่อน checkout')
|
||||
setSubmitError(t('checkout.errors.emptyCart'))
|
||||
return
|
||||
}
|
||||
|
||||
const shippingAddress = resolveShippingAddress()
|
||||
if (!shippingAddress) {
|
||||
setSubmitError('กรุณากรอกที่อยู่จัดส่งให้ครบถ้วน')
|
||||
setSubmitError(t('checkout.errors.missingAddress'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,10 +410,15 @@ export default function CheckoutClient() {
|
||||
})
|
||||
|
||||
clearCart()
|
||||
try {
|
||||
window.localStorage.removeItem('app_active_coupon')
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
saveLocalOrder(order.id)
|
||||
router.push(getPathWithLocale(`/order/${order.id}`, lang))
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถสร้างคำสั่งซื้อได้')
|
||||
setSubmitError(error instanceof Error ? error.message : t('checkout.errors.createOrderFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -390,7 +428,7 @@ export default function CheckoutClient() {
|
||||
return (
|
||||
<div className="ui-panel p-10 text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand-primary" />
|
||||
<p className="mt-4 text-sm font-semibold text-muted-text">กำลังโหลดตะกร้าสินค้า...</p>
|
||||
<p className="mt-4 text-sm font-semibold text-muted-text">{t('checkout.loadingCart')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -399,10 +437,10 @@ export default function CheckoutClient() {
|
||||
return (
|
||||
<div className="ui-panel mx-auto max-w-xl p-10 text-center">
|
||||
<ShoppingBag className="mx-auto h-14 w-14 text-brand-primary" />
|
||||
<h2 className="mt-5 text-3xl font-semibold text-text-main">ตะกร้าสินค้าว่าง</h2>
|
||||
<p className="mt-3 text-muted-text">เลือกสินค้าที่คุณชอบก่อน แล้วกลับมายืนยันคำสั่งซื้อที่หน้านี้ได้เลย</p>
|
||||
<h2 className="mt-5 text-3xl font-semibold text-text-main">{t('checkout.emptyCartTitle')}</h2>
|
||||
<p className="mt-3 text-muted-text">{t('checkout.emptyCartDesc')}</p>
|
||||
<Link href="/shop" className="mt-8 inline-flex rounded-full bg-text-main px-8 py-4 text-sm font-bold uppercase tracking-[0.16em] text-surface-card transition hover:bg-brand-primary">
|
||||
กลับไปเลือกสินค้า
|
||||
{t('checkout.continueShopping')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
@@ -418,43 +456,43 @@ export default function CheckoutClient() {
|
||||
<div className="ui-panel-header">
|
||||
<UserRound className="h-5 w-5 text-brand-primary" />
|
||||
<div>
|
||||
<h2 className="ui-panel-title">ข้อมูลผู้รับสินค้า</h2>
|
||||
<p className="ui-panel-subtitle">ข้อมูลนี้จะถูกส่งไปสร้างคำสั่งซื้อตาม contract ของ backend</p>
|
||||
<h2 className="ui-panel-title">{t('checkout.recipientInfo.title')}</h2>
|
||||
<p className="ui-panel-subtitle">{t('checkout.recipientInfo.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">ชื่อผู้รับ *</span>
|
||||
<span className="ui-form-label">{t('checkout.recipientInfo.name')}</span>
|
||||
<input
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, name: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="ชื่อ-นามสกุล"
|
||||
placeholder={t('checkout.recipientInfo.namePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">เบอร์โทรศัพท์ *</span>
|
||||
<span className="ui-form-label">{t('checkout.recipientInfo.phone')}</span>
|
||||
<input
|
||||
required
|
||||
pattern="[0-9]{9,10}"
|
||||
title="กรุณากรอกเบอร์โทรศัพท์ 9-10 หลัก"
|
||||
title={t('checkout.recipientInfo.phoneValidation')}
|
||||
value={formData.phone}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, phone: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="0812345678"
|
||||
placeholder={t('checkout.recipientInfo.phonePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="ui-form-label">อีเมล *</span>
|
||||
<span className="ui-form-label">{t('checkout.recipientInfo.email')}</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(event) => setFormData((current) => ({ ...current, email: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('checkout.recipientInfo.emailPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -464,8 +502,8 @@ export default function CheckoutClient() {
|
||||
<div className="ui-panel-header">
|
||||
<PackageCheck className="h-5 w-5 text-brand-primary" />
|
||||
<div>
|
||||
<h2 className="ui-panel-title">ที่อยู่จัดส่ง</h2>
|
||||
<p className="ui-panel-subtitle">สมาชิกสามารถเลือกที่อยู่ที่บันทึกไว้ หรือกรอกที่อยู่ใหม่ได้</p>
|
||||
<h2 className="ui-panel-title">{t('checkout.shippingAddress.title')}</h2>
|
||||
<p className="ui-panel-subtitle">{t('checkout.shippingAddress.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -492,12 +530,12 @@ export default function CheckoutClient() {
|
||||
{address.recipientName}
|
||||
{address.isDefault && (
|
||||
<span className="ml-2 rounded-full bg-brand-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-brand-primary">
|
||||
default
|
||||
{t('checkout.shippingAddress.defaultBadge')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-text">{address.phone}</span>
|
||||
<span className="mt-1 block text-sm text-text-main/80">{formatAddress(address)}</span>
|
||||
<span className="mt-1 block text-sm text-text-main/80">{formatAddress(address, isThai)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -513,7 +551,7 @@ export default function CheckoutClient() {
|
||||
onChange={() => selectAddress('new')}
|
||||
className="accent-brand-primary"
|
||||
/>
|
||||
<span className="text-sm font-bold text-text-main">กรอกที่อยู่ใหม่</span>
|
||||
<span className="text-sm font-bold text-text-main">{t('checkout.shippingAddress.addNewAddress')}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -521,17 +559,17 @@ export default function CheckoutClient() {
|
||||
{(savedAddresses.length === 0 || selectedAddressId === 'new') && (
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="ui-form-label">บ้านเลขที่ / ถนน / ซอย *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.addressLine')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.addressLine}
|
||||
onChange={(event) => setAddressForm((current) => ({ ...current, addressLine: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="บ้านเลขที่ หมู่บ้าน ถนน ซอย"
|
||||
placeholder={t('checkout.shippingAddress.addressLinePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">ตำบล/แขวง *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.subdistrict')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.subdistrict}
|
||||
@@ -540,7 +578,7 @@ export default function CheckoutClient() {
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">อำเภอ/เขต *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.district')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.district}
|
||||
@@ -549,7 +587,7 @@ export default function CheckoutClient() {
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">จังหวัด *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.province')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.province}
|
||||
@@ -558,13 +596,13 @@ export default function CheckoutClient() {
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="ui-form-label">รหัสไปรษณีย์ *</span>
|
||||
<span className="ui-form-label">{t('checkout.shippingAddress.zipcode')}</span>
|
||||
<input
|
||||
required={selectedAddressId === 'new' || savedAddresses.length === 0}
|
||||
value={addressForm.zipcode}
|
||||
onChange={(event) => setAddressForm((current) => ({ ...current, zipcode: event.target.value }))}
|
||||
className="ui-form-shell"
|
||||
placeholder="10110"
|
||||
placeholder={t('checkout.shippingAddress.zipcodePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -585,7 +623,7 @@ export default function CheckoutClient() {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{submitting && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
สร้างคำสั่งซื้อ
|
||||
{t('checkout.summary.placeOrder')}
|
||||
</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</button>
|
||||
@@ -599,8 +637,8 @@ export default function CheckoutClient() {
|
||||
className="flex w-full items-center justify-between border-b border-line-primary pb-5 text-left lg:pointer-events-none"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-xl font-semibold text-text-main">สรุปรายการ</span>
|
||||
<span className="mt-1 block text-sm text-muted-text">{items.length} รายการในตะกร้า</span>
|
||||
<span className="block text-xl font-semibold text-text-main">{t('checkout.summary.title')}</span>
|
||||
<span className="mt-1 block text-sm text-muted-text">{t('checkout.summary.itemsCount', { count: items.length })}</span>
|
||||
</span>
|
||||
<span className="font-bold text-brand-primary lg:hidden">{formatPrice(total)}</span>
|
||||
</button>
|
||||
@@ -620,7 +658,7 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-text-main">{item.productName}</p>
|
||||
<p className="mt-1 text-xs text-muted-text">{item.variantName || 'Standard'} x {item.quantity}</p>
|
||||
<p className="mt-1 text-xs text-muted-text">{item.variantName || t('checkout.summary.standardVariant')} x {item.quantity}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-brand-primary">{formatPrice(item.price * item.quantity)}</p>
|
||||
</div>
|
||||
@@ -630,12 +668,12 @@ export default function CheckoutClient() {
|
||||
<div className="border-t border-line-primary pt-5">
|
||||
{!appliedCoupon ? (
|
||||
<div className="space-y-2">
|
||||
<label className="ui-form-label">คูปองส่วนลด</label>
|
||||
<label className="ui-form-label">{t('checkout.summary.couponLabel')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={couponCode}
|
||||
onChange={(event) => setCouponCode(event.target.value.toUpperCase())}
|
||||
placeholder="COUPON"
|
||||
placeholder={t('checkout.summary.couponPlaceholder')}
|
||||
className="ui-form-shell min-w-0 flex-1 uppercase tracking-widest"
|
||||
/>
|
||||
<button
|
||||
@@ -644,7 +682,7 @@ export default function CheckoutClient() {
|
||||
disabled={couponLoading || !couponCode.trim()}
|
||||
className="rounded-2xl bg-text-main px-4 py-3 text-xs font-bold uppercase tracking-widest text-surface-card transition hover:bg-brand-primary disabled:opacity-50"
|
||||
>
|
||||
{couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ใช้'}
|
||||
{couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : t('checkout.summary.applyCoupon')}
|
||||
</button>
|
||||
</div>
|
||||
{couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>}
|
||||
@@ -656,7 +694,7 @@ export default function CheckoutClient() {
|
||||
{appliedCoupon}
|
||||
</span>
|
||||
<button type="button" onClick={removeCoupon} className="text-xs font-bold uppercase tracking-widest text-muted-text hover:text-rose-500">
|
||||
ลบ
|
||||
{t('checkout.summary.removeCoupon')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -665,8 +703,8 @@ export default function CheckoutClient() {
|
||||
{customerPoints > 0 && (
|
||||
<div className="space-y-3 border-t border-line-primary pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-muted-text">คะแนนสะสม</span>
|
||||
<span className="text-sm font-bold text-text-main">{customerPoints} points</span>
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-muted-text">{t('checkout.summary.pointsLabel')}</span>
|
||||
<span className="text-sm font-bold text-text-main">{t('checkout.summary.pointsBalance', { count: customerPoints })}</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line-primary bg-surface-page p-4 text-sm font-semibold text-text-main">
|
||||
<input
|
||||
@@ -679,7 +717,7 @@ export default function CheckoutClient() {
|
||||
className="accent-brand-primary"
|
||||
disabled={maxRedeemablePoints <= 0}
|
||||
/>
|
||||
ใช้คะแนนเป็นส่วนลด
|
||||
{t('checkout.summary.usePoints')}
|
||||
</label>
|
||||
{usePoints && (
|
||||
<input
|
||||
@@ -696,7 +734,7 @@ export default function CheckoutClient() {
|
||||
|
||||
<div className="space-y-3 border-t border-line-primary pt-5 text-sm">
|
||||
<div className="flex justify-between text-muted-text">
|
||||
<span>Subtotal</span>
|
||||
<span>{t('checkout.summary.subtotal')}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
{promoLevel1Disc > 0 && (
|
||||
@@ -713,18 +751,18 @@ export default function CheckoutClient() {
|
||||
)}
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>Coupon {appliedCoupon ? `(${appliedCoupon})` : ''}</span>
|
||||
<span>{t('checkout.summary.coupon')} {appliedCoupon ? `(${appliedCoupon})` : ''}</span>
|
||||
<span>-{formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{pointsDiscount > 0 && (
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>Points</span>
|
||||
<span>{t('checkout.summary.points')}</span>
|
||||
<span>-{formatPrice(pointsDiscount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-muted-text">
|
||||
<span>Shipping</span>
|
||||
<span>{t('checkout.summary.shipping')}</span>
|
||||
{loadingSettings ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -733,12 +771,12 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
{isFreeShipping && (
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>Free shipping</span>
|
||||
<span>{t('checkout.summary.freeShipping')}</span>
|
||||
<span>-{formatPrice(shippingFee)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t border-line-primary pt-4 text-lg font-bold text-text-main">
|
||||
<span>Total</span>
|
||||
<span>{t('checkout.summary.total')}</span>
|
||||
<span className="text-brand-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -747,7 +785,7 @@ export default function CheckoutClient() {
|
||||
<div className="flex gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
รอบนี้จะสร้างคำสั่งซื้อสถานะรอชำระเงิน แล้วพาไปหน้า Order Detail ส่วน payment/slip upload ยังเป็นงานถัดไปตามแผน
|
||||
{t('checkout.summary.notice')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -760,7 +798,7 @@ export default function CheckoutClient() {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{submitting && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
สร้างคำสั่งซื้อ
|
||||
{t('checkout.summary.placeOrder')}
|
||||
</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface FooterProps {
|
||||
exploreMenu?: MenuItem[]
|
||||
supportMenu?: MenuItem[]
|
||||
settings?: StoreSettings | null
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const layoutClassMaps = {
|
||||
@@ -48,12 +49,11 @@ export default function Footer({
|
||||
exploreMenu = [],
|
||||
supportMenu = [],
|
||||
settings = null,
|
||||
lang: propLang,
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const lang = useLang()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { queueMicrotask(() => setMounted(true)) }, [])
|
||||
const effectiveLang = mounted ? lang : 'th'
|
||||
const routeLang = useLang()
|
||||
const effectiveLang = propLang || routeLang || 'th'
|
||||
|
||||
const translateMenuLabel = (item: MenuItem): string => {
|
||||
if (effectiveLang !== 'en' && item.translations?.length) {
|
||||
@@ -72,7 +72,7 @@ export default function Footer({
|
||||
'Track Order': 'nav.trackOrder',
|
||||
'Delivery & Returns': 'nav.delivery',
|
||||
}
|
||||
return map[item.label] ? t(map[item.label]) : item.label
|
||||
return map[item.label] ? t(map[item.label], { lng: effectiveLang }) : item.label
|
||||
}
|
||||
|
||||
const footerLayout = normalizeFooterLayout(settings?.footerLayout)
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Landmark,
|
||||
Loader2,
|
||||
QrCode,
|
||||
UploadCloud,
|
||||
} from 'lucide-react'
|
||||
import { formatPrice, type Order } from '@homaroma/types'
|
||||
|
||||
import { API_BASE } from '../lib/api'
|
||||
import { toneClass } from '../lib/theme'
|
||||
|
||||
interface BankSettings {
|
||||
bankName?: string
|
||||
bankAccountName?: string
|
||||
bankAccountNumber?: string
|
||||
}
|
||||
|
||||
interface OrderPaymentClientProps {
|
||||
initialOrder: Order
|
||||
bankSettings: BankSettings | null
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export default function OrderPaymentClient({
|
||||
initialOrder,
|
||||
bankSettings,
|
||||
lang = 'th',
|
||||
}: OrderPaymentClientProps) {
|
||||
const { t: i18nT } = useTranslation()
|
||||
const t = useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
return i18nT(key, { lng: lang, ...options })
|
||||
},
|
||||
[i18nT, lang],
|
||||
)
|
||||
|
||||
const [order, setOrder] = useState<Order>(initialOrder)
|
||||
const [paymentMethod, setPaymentMethod] = useState<'bank' | 'promptpay'>('bank')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
|
||||
const [generatingQr, setGeneratingQr] = useState(false)
|
||||
const [qrError, setQrError] = useState<string | null>(null)
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
// Polling for automated payment status verification (e.g. PromptPay or Admin approval)
|
||||
useEffect(() => {
|
||||
if (!polling || order.status !== 'PENDING_PAYMENT') return
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/orders/${order.id}`)
|
||||
if (!res.ok) return
|
||||
const updatedOrder = (await res.json()) as Order
|
||||
if (updatedOrder && updatedOrder.status === 'PAID') {
|
||||
setOrder(updatedOrder)
|
||||
setPolling(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[website-order] Polling status check error', err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [polling, order.id, order.status])
|
||||
|
||||
const copyAccountNumber = async () => {
|
||||
if (!bankSettings?.bankAccountNumber) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(bankSettings.bankAccountNumber)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.warn('[website-order] Failed to copy account number', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError(t('order.payment.supportFormat'))
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
// 1. Upload the image file
|
||||
const uploadRes = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error(t('order.payment.uploadFailed'))
|
||||
}
|
||||
|
||||
const uploadData = (await uploadRes.json()) as { url?: string }
|
||||
if (!uploadData.url) {
|
||||
throw new Error(t('order.payment.uploadFailed'))
|
||||
}
|
||||
|
||||
// 2. Attach slip to the order
|
||||
const updateRes = await fetch(`${API_BASE}/orders/${order.id}/slip`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentSlipUrl: uploadData.url }),
|
||||
})
|
||||
|
||||
if (!updateRes.ok) {
|
||||
throw new Error(t('order.payment.uploadFailed'))
|
||||
}
|
||||
|
||||
const updatedOrder = (await updateRes.json()) as Order
|
||||
setOrder(updatedOrder)
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : t('order.payment.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGeneratePromptPay = async () => {
|
||||
setGeneratingQr(true)
|
||||
setQrError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/payments/charge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orderId: order.id,
|
||||
amount: order.totalAmount,
|
||||
type: 'promptpay',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = (await res.json()) as { success?: boolean; qrCodeUrl?: string; error?: string }
|
||||
if (res.ok && data.success && data.qrCodeUrl) {
|
||||
setQrCodeUrl(data.qrCodeUrl)
|
||||
setPolling(true)
|
||||
} else {
|
||||
setQrError(data.error || t('order.payment.failedGenerateQr'))
|
||||
}
|
||||
} catch (err) {
|
||||
setQrError(err instanceof Error ? err.message : t('order.payment.failedGenerateQr'))
|
||||
} finally {
|
||||
setGeneratingQr(false)
|
||||
}
|
||||
}
|
||||
|
||||
// If already paid, show confirmation block
|
||||
if (order.status === 'PAID') {
|
||||
return (
|
||||
<section className="ui-panel p-8 text-center border border-emerald-500/20 bg-emerald-500/5">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-600">
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-text-main">{t('order.payment.paymentComplete')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-text">Status: {t('order.status.PAID')}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
{/* Method Selector Tabs */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('bank')}
|
||||
className={`flex items-center justify-center gap-3 rounded-2xl p-4 text-sm font-bold transition-all border ${
|
||||
paymentMethod === 'bank'
|
||||
? 'border-brand-primary bg-surface-card text-brand-primary shadow-card ring-1 ring-brand-primary'
|
||||
: 'border-line-primary bg-surface-card text-muted-text hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Landmark className="h-5 w-5" />
|
||||
<span>{t('order.payment.methodBank')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('promptpay')}
|
||||
className={`flex items-center justify-center gap-3 rounded-2xl p-4 text-sm font-bold transition-all border ${
|
||||
paymentMethod === 'promptpay'
|
||||
? 'border-brand-primary bg-surface-card text-brand-primary shadow-card ring-1 ring-brand-primary'
|
||||
: 'border-line-primary bg-surface-card text-muted-text hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<QrCode className="h-5 w-5" />
|
||||
<span>{t('order.payment.methodPromptPay')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Panel */}
|
||||
{paymentMethod === 'bank' && (
|
||||
<div className="space-y-6">
|
||||
<div className="ui-panel p-6 md:p-8 space-y-6 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-muted-text">
|
||||
{t('order.payment.required')}
|
||||
</p>
|
||||
<div className="text-3xl md:text-4xl font-extrabold text-brand-primary">
|
||||
{formatPrice(order.totalAmount)}
|
||||
</div>
|
||||
|
||||
{bankSettings ? (
|
||||
<div className="mx-auto max-w-md rounded-2xl border border-line-primary/40 bg-surface-page p-6 text-left space-y-4">
|
||||
{bankSettings.bankName && (
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-text block">
|
||||
{t('order.payment.bank')}
|
||||
</span>
|
||||
<span className="text-base font-bold text-text-main block mt-0.5">
|
||||
{bankSettings.bankName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bankSettings.bankAccountNumber && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-text block">
|
||||
{t('order.payment.accountNumber')}
|
||||
</span>
|
||||
<span className="font-mono text-xl md:text-2xl font-bold tracking-wider text-brand-primary block mt-0.5">
|
||||
{bankSettings.bankAccountNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAccountNumber}
|
||||
title={t('order.payment.copyAccount')}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-line-primary bg-surface-card px-3.5 py-2 text-xs font-semibold text-text-main transition hover:border-brand-primary hover:text-brand-primary shadow-sm"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-emerald-600">{t('order.payment.copied')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{t('order.payment.copyAccount')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bankSettings.bankAccountName && (
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-text block">
|
||||
{t('order.payment.accountName')}
|
||||
</span>
|
||||
<span className="text-base font-bold text-text-main block mt-0.5">
|
||||
{bankSettings.bankAccountName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-text">
|
||||
{t('order.payment.transferInstruction')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Slip Upload Area */}
|
||||
<div className="ui-panel p-6 md:p-8 space-y-4">
|
||||
<h3 className="border-b border-line-primary/20 pb-3 text-sm font-bold uppercase tracking-[0.2em] text-brand-primary">
|
||||
{t('order.payment.slipVerification')}
|
||||
</h3>
|
||||
|
||||
{uploadError && (
|
||||
<div className={`p-4 rounded-xl text-sm flex items-center gap-3 ${toneClass('error')}`}>
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.paymentSlipUrl ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 py-4 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-emerald-600">
|
||||
{t('order.payment.slipUploaded')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-text">
|
||||
{t('order.payment.slipUploadedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative mt-2 max-w-xs overflow-hidden rounded-xl border border-line-primary shadow-card">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={order.paymentSlipUrl}
|
||||
alt="Payment Slip Proof"
|
||||
className="max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<label className="cursor-pointer text-xs font-bold text-brand-primary underline hover:opacity-80">
|
||||
<span>{t('order.payment.dropSlip')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="relative flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-line-primary/80 bg-surface-card p-10 text-center transition hover:border-brand-primary group">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
/>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-brand-primary" />
|
||||
) : (
|
||||
<UploadCloud className="h-10 w-10 text-muted-text transition group-hover:text-brand-primary" />
|
||||
)}
|
||||
<span className="mt-3 block text-sm font-bold text-text-main">
|
||||
{uploading ? t('order.payment.uploading') : t('order.payment.dropSlip')}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-text">
|
||||
{t('order.payment.supportFormat')}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PromptPay QR Panel */}
|
||||
{paymentMethod === 'promptpay' && (
|
||||
<div className="ui-panel p-6 md:p-8 space-y-6 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-muted-text">
|
||||
{t('order.payment.promptPayTitle')}
|
||||
</p>
|
||||
<div className="text-3xl md:text-4xl font-extrabold text-brand-primary">
|
||||
{formatPrice(order.totalAmount)}
|
||||
</div>
|
||||
|
||||
{qrError && (
|
||||
<div className={`p-4 rounded-xl text-sm flex items-center justify-center gap-3 ${toneClass('error')}`}>
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<span>{qrError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!qrCodeUrl ? (
|
||||
<div className="py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePromptPay}
|
||||
disabled={generatingQr}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand-primary px-8 py-3.5 text-sm font-bold text-surface-card shadow-card transition hover:bg-brand-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{generatingQr ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t('order.payment.generatingQr')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<QrCode className="h-5 w-5" />
|
||||
<span>{t('order.payment.generatePromptPay')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-sm mx-auto">
|
||||
<div className="inline-block rounded-2xl border border-line-primary/60 bg-surface-card p-4 shadow-card">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="PromptPay QR Code"
|
||||
className="h-64 w-64 object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-main">
|
||||
{t('order.payment.scanPromptPay')}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-text">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
<span>Checking payment status...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,12 @@ interface ProductCardProps {
|
||||
product: Product
|
||||
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch'
|
||||
badgeText?: string | null
|
||||
layout?: 'grid' | 'list'
|
||||
showPrice?: boolean
|
||||
showAddToCart?: boolean
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, cardStyle = 'standard', badgeText }: ProductCardProps) {
|
||||
export default function ProductCard({ product, cardStyle = 'standard', badgeText, layout = 'grid', showPrice = true, showAddToCart = true }: ProductCardProps) {
|
||||
const lang = useLang()
|
||||
const { addToCart } = useCartStore()
|
||||
const selectedVariant = product.variants?.[0] || null
|
||||
@@ -192,6 +195,80 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'list') {
|
||||
return (
|
||||
<div className="ui-card ui-card-hover group flex cursor-pointer flex-col sm:flex-row overflow-hidden p-3 gap-5 items-stretch">
|
||||
<div className="relative aspect-square sm:w-56 sm:h-56 shrink-0 overflow-hidden rounded-xl bg-surface-page">
|
||||
<div className="absolute right-2 top-2 z-20">
|
||||
<WishlistButton product={product} />
|
||||
</div>
|
||||
{categoryName && (
|
||||
<span className="ui-chip absolute left-3 top-3 z-20 border-line-primary/20 bg-surface-card/90 px-2.5 py-1 text-[10px] text-text-main shadow-sm backdrop-blur-sm pointer-events-none">
|
||||
{categoryName}
|
||||
</span>
|
||||
)}
|
||||
<Link href={`/products/${productSlug}`} className="block h-full w-full">
|
||||
<Image
|
||||
src={images[0]}
|
||||
alt={productName}
|
||||
fill
|
||||
unoptimized
|
||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between py-1">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-bold leading-tight text-text-main transition-colors group-hover:text-brand-primary">
|
||||
<Link href={`/products/${productSlug}`}>{productName}</Link>
|
||||
</h3>
|
||||
{categoryName && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-widest text-muted-text sm:text-xs">
|
||||
{categoryName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-base sm:text-xl font-black text-brand-primary shrink-0">
|
||||
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{colorSwatches.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
{colorSwatches.slice(0, 8).map(({ colorCode, variant, label }) => (
|
||||
<span
|
||||
key={variant.id || colorCode}
|
||||
className="relative h-4 w-4 rounded-full border border-black/15 dark:border-white/20 shadow-sm shrink-0"
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={label || colorCode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center justify-between border-t border-line-primary/10 mt-4">
|
||||
<span className="text-xs text-muted-text">
|
||||
{inStock ? '✓ พร้อมส่ง (In Stock)' : '⏳ สั่งผลิต (Pre-Order)'}
|
||||
</span>
|
||||
<Link href={`/products/${productSlug}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-button-primary px-5 py-2 text-xs font-bold gap-1.5 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
<span>{addToCartText}</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-surface-page">
|
||||
@@ -282,13 +359,16 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
|
||||
)) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||
{showPrice && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-black text-brand-primary md:text-lg">
|
||||
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product.variants && product.variants.length > 1 ? (
|
||||
{showAddToCart && (
|
||||
product.variants && product.variants.length > 1 ? (
|
||||
<Link href={`/products/${productSlug}`} className="block w-full">
|
||||
<button
|
||||
type="button"
|
||||
@@ -321,7 +401,8 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
{addToCartText}
|
||||
</button>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import type { Product, CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
import CategoryFilterSidebar, { type FilterState, type DynamicFacet, type DynamicFacetOption } from './CategoryFilterSidebar'
|
||||
import CategoryFilterHorizontalBar from './CategoryFilterHorizontalBar'
|
||||
import CategoryToolbar from './CategoryToolbar'
|
||||
import { FilterX, X } from 'lucide-react'
|
||||
|
||||
@@ -14,6 +15,9 @@ interface CategoryCatalogContainerProps {
|
||||
availableFilters?: AttributeDefinition[]
|
||||
layoutGridClass?: string
|
||||
lang?: string
|
||||
showFilters?: boolean
|
||||
showToolbar?: boolean
|
||||
itemsPerPage?: number
|
||||
}
|
||||
|
||||
function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
|
||||
@@ -105,6 +109,9 @@ export default function CategoryCatalogContainer({
|
||||
availableFilters = [],
|
||||
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
|
||||
lang = 'th',
|
||||
showFilters = true,
|
||||
showToolbar = true,
|
||||
itemsPerPage,
|
||||
}: CategoryCatalogContainerProps) {
|
||||
// Determine price bounds from products
|
||||
const priceBounds = useMemo<[number, number]>(() => {
|
||||
@@ -297,121 +304,218 @@ export default function CategoryCatalogContainer({
|
||||
})
|
||||
}, [products, filters, sortBy, dynamicFacets, lang])
|
||||
|
||||
return (
|
||||
<div id="products" className="pt-6 pb-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
{/* Left Column: Filter Sidebar (Desktop) */}
|
||||
<div className="hidden lg:block lg:col-span-3 sticky top-24">
|
||||
<CategoryFilterSidebar
|
||||
filters={filters}
|
||||
onChangeFilters={setFilters}
|
||||
onReset={() => setFilters(initialFilters)}
|
||||
config={filterConfig}
|
||||
priceBounds={priceBounds}
|
||||
stockCounts={stockCounts}
|
||||
dynamicFacets={dynamicFacets}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
const shouldShowFilters = showFilters !== false && filterConfig?.showFilters !== false
|
||||
const shouldShowToolbar = showToolbar !== false && filterConfig?.showToolbar !== false
|
||||
const isTopBarFilter = filterConfig?.filterPosition === 'top_bar'
|
||||
|
||||
{/* Right Column: Catalog Grid & Toolbar */}
|
||||
<div className="lg:col-span-9 space-y-6">
|
||||
<CategoryToolbar
|
||||
categoryName={categoryName}
|
||||
totalProducts={products.length}
|
||||
displayedProducts={filteredProducts.length}
|
||||
sortBy={sortBy}
|
||||
onChangeSortBy={setSortBy}
|
||||
viewMode={viewMode}
|
||||
onChangeViewMode={setViewMode}
|
||||
onOpenMobileFilter={() => setIsMobileFilterOpen(true)}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
{/* Active Filter Pills Bar */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2">
|
||||
<span className="text-xs text-muted-text font-medium">Active Filters:</span>
|
||||
{filters.stockStatus.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-primary/10 text-brand-primary border border-brand-primary/20"
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [filters, sortBy])
|
||||
|
||||
const effectivePageSize = itemsPerPage || 24
|
||||
const totalPages = Math.ceil(filteredProducts.length / effectivePageSize)
|
||||
const paginatedProducts = useMemo(() => {
|
||||
if (!itemsPerPage) return filteredProducts
|
||||
const start = (currentPage - 1) * effectivePageSize
|
||||
return filteredProducts.slice(start, start + effectivePageSize)
|
||||
}, [filteredProducts, currentPage, effectivePageSize, itemsPerPage])
|
||||
|
||||
const renderProductList = () => (
|
||||
<>
|
||||
{shouldShowToolbar && (
|
||||
<CategoryToolbar
|
||||
categoryName={categoryName}
|
||||
totalProducts={products.length}
|
||||
displayedProducts={filteredProducts.length}
|
||||
sortBy={sortBy}
|
||||
onChangeSortBy={setSortBy}
|
||||
viewMode={viewMode}
|
||||
onChangeViewMode={setViewMode}
|
||||
onOpenMobileFilter={() => setIsMobileFilterOpen(true)}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Active Filter Pills Bar */}
|
||||
{shouldShowFilters && activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2">
|
||||
<span className="text-xs text-muted-text font-medium">Active Filters:</span>
|
||||
{filters.stockStatus.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-primary/10 text-brand-primary border border-brand-primary/20"
|
||||
>
|
||||
<span>{s === 'in_stock' ? 'In Stock' : 'Pre-Order'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
stockStatus: filters.stockStatus.filter((item) => item !== s),
|
||||
})
|
||||
}
|
||||
className="hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{Object.entries(filters.attributes).map(([code, vals]) =>
|
||||
vals.map((v) => (
|
||||
<span
|
||||
key={`${code}-${v}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-surface-card border border-line-primary/30 text-text-main"
|
||||
>
|
||||
<span className="text-[10px] text-muted-text font-mono uppercase">{code}:</span>
|
||||
<span>{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveActiveAttribute(code, v)}
|
||||
className="text-muted-text hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<span>{s === 'in_stock' ? 'In Stock' : 'Pre-Order'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
stockStatus: filters.stockStatus.filter((item) => item !== s),
|
||||
})
|
||||
}
|
||||
className="hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{Object.entries(filters.attributes).map(([code, vals]) =>
|
||||
vals.map((v) => (
|
||||
<span
|
||||
key={`${code}-${v}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-surface-card border border-line-primary/30 text-text-main"
|
||||
>
|
||||
<span className="text-[10px] text-muted-text font-mono uppercase">{code}:</span>
|
||||
<span>{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveActiveAttribute(code, v)}
|
||||
className="text-muted-text hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="text-xs text-muted-text hover:text-brand-primary underline ml-2 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-line-primary/20 rounded-3xl space-y-3 bg-surface-card/30">
|
||||
<FilterX className="w-10 h-10 text-muted-text/50 mx-auto" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-bold text-text-main">No products found matching active filters</p>
|
||||
<p className="text-xs text-muted-text">
|
||||
Try adjusting your price range or clearing selected attribute filters.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="mt-3 px-4 py-2 rounded-xl bg-brand-primary text-surface-card font-bold text-xs hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Reset All Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="text-xs text-muted-text hover:text-brand-primary underline ml-2 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-line-primary/20 rounded-3xl space-y-3 bg-surface-card/30">
|
||||
<FilterX className="w-10 h-10 text-muted-text/50 mx-auto" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-bold text-text-main">No products found matching active filters</p>
|
||||
<p className="text-xs text-muted-text">
|
||||
Try adjusting your price range or clearing selected attribute filters.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(initialFilters)}
|
||||
className="mt-3 px-4 py-2 rounded-xl bg-brand-primary text-surface-card font-bold text-xs hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Reset All Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
|
||||
{paginatedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
layout={viewMode}
|
||||
cardStyle={filterConfig?.cardStyle || 'standard'}
|
||||
showPrice={filterConfig?.showPrice ?? true}
|
||||
showAddToCart={filterConfig?.showAddToCart ?? true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{itemsPerPage && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
className="px-3 py-1.5 rounded-lg border border-line-primary/20 text-xs font-bold disabled:opacity-30 disabled:cursor-not-allowed hover:bg-surface-card"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={`w-8 h-8 rounded-lg text-xs font-bold transition-all ${
|
||||
currentPage === i + 1
|
||||
? 'bg-brand-primary text-surface-card shadow-sm'
|
||||
: 'border border-line-primary/20 hover:bg-surface-card text-muted-text'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
className="px-3 py-1.5 rounded-lg border border-line-primary/20 text-xs font-bold disabled:opacity-30 disabled:cursor-not-allowed hover:bg-surface-card"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div id="products" className="pt-6 pb-12 space-y-6">
|
||||
{!shouldShowFilters ? (
|
||||
/* ── Full Width Catalog Grid (No Filters) ── */
|
||||
<div className="w-full space-y-6">
|
||||
{renderProductList()}
|
||||
</div>
|
||||
) : isTopBarFilter ? (
|
||||
/* ── Top Bar Filter Layout (Full 100% Width) ── */
|
||||
<div className="space-y-6">
|
||||
<div className="hidden lg:block">
|
||||
<CategoryFilterHorizontalBar
|
||||
filters={filters}
|
||||
onChangeFilters={setFilters}
|
||||
onReset={() => setFilters(initialFilters)}
|
||||
config={filterConfig}
|
||||
priceBounds={priceBounds}
|
||||
stockCounts={stockCounts}
|
||||
dynamicFacets={dynamicFacets}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-6">
|
||||
{renderProductList()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Standard Sidebar Filter Layout ── */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
{/* Left Column: Filter Sidebar (Desktop) */}
|
||||
<div className={`hidden lg:block ${filterConfig?.fullWidth ? 'lg:col-span-3 xl:col-span-3 2xl:col-span-2' : 'lg:col-span-3'} sticky top-24`}>
|
||||
<CategoryFilterSidebar
|
||||
filters={filters}
|
||||
onChangeFilters={setFilters}
|
||||
onReset={() => setFilters(initialFilters)}
|
||||
config={filterConfig}
|
||||
priceBounds={priceBounds}
|
||||
stockCounts={stockCounts}
|
||||
dynamicFacets={dynamicFacets}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Catalog Grid & Toolbar */}
|
||||
<div className={`${filterConfig?.fullWidth ? 'lg:col-span-9 xl:col-span-9 2xl:col-span-10' : 'lg:col-span-9'} space-y-6`}>
|
||||
{renderProductList()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Drawer Filter Modal */}
|
||||
{isMobileFilterOpen && (
|
||||
{shouldShowFilters && isMobileFilterOpen && (
|
||||
<div className="fixed inset-0 z-[120] lg:hidden bg-black/70 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xs h-full bg-surface-card overflow-y-auto shadow-2xl animate-in slide-in-from-right duration-200">
|
||||
<CategoryFilterSidebar
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function CategoryFeaturedSeries({
|
||||
<div className="absolute right-10 bottom-0 w-96 h-96 rounded-full bg-orange-500/10 blur-3xl pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-8 items-center p-6 md:p-10 lg:p-12">
|
||||
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-8 items-center ${featured.fullWidth ? 'p-6 md:p-10 lg:p-12 xl:p-16' : 'p-6 md:p-10 lg:p-12'}`}>
|
||||
{/* Left Column: Content */}
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { RotateCcw, ChevronDown, Check, SlidersHorizontal, X } from 'lucide-react'
|
||||
import type { CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
|
||||
import type { FilterState, DynamicFacet } from './CategoryFilterSidebar'
|
||||
|
||||
interface CategoryFilterHorizontalBarProps {
|
||||
filters: FilterState
|
||||
onChangeFilters: (filters: FilterState) => void
|
||||
onReset: () => void
|
||||
config?: CategoryFilterConfig | null
|
||||
priceBounds: [number, number]
|
||||
stockCounts: { inStock: number; preOrder: number }
|
||||
dynamicFacets?: DynamicFacet[]
|
||||
lang?: string
|
||||
}
|
||||
|
||||
function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') {
|
||||
const trimmed = val.trim()
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return resolveLocalizedText(parsed, lang)
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
const rec = val as Record<string, unknown>
|
||||
if (rec.value && rec.label) {
|
||||
return resolveLocalizedText(rec.label, lang) || String(rec.value)
|
||||
}
|
||||
const candidate = rec[lang] || rec.th || rec.en || Object.values(rec).find((v) => typeof v === 'string')
|
||||
if (candidate) return resolveLocalizedText(candidate, lang)
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
export default function CategoryFilterHorizontalBar({
|
||||
filters,
|
||||
onChangeFilters,
|
||||
onReset,
|
||||
config,
|
||||
priceBounds,
|
||||
stockCounts,
|
||||
dynamicFacets = [],
|
||||
lang = 'th',
|
||||
}: CategoryFilterHorizontalBarProps) {
|
||||
const showPrice = config?.showPrice ?? true
|
||||
const showStock = config?.showStock ?? true
|
||||
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setActiveDropdown(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const toggleDropdown = (key: string) => {
|
||||
setActiveDropdown((prev) => (prev === key ? null : key))
|
||||
}
|
||||
|
||||
const toggleStock = (s: string) => {
|
||||
const next = filters.stockStatus.includes(s)
|
||||
? filters.stockStatus.filter((item) => item !== s)
|
||||
: [...filters.stockStatus, s]
|
||||
onChangeFilters({ ...filters, stockStatus: next })
|
||||
}
|
||||
|
||||
const toggleAttribute = (code: string, val: string) => {
|
||||
const current = filters.attributes[code] || []
|
||||
const next = current.includes(val)
|
||||
? current.filter((v) => v !== val)
|
||||
: [...current, val]
|
||||
|
||||
onChangeFilters({
|
||||
...filters,
|
||||
attributes: {
|
||||
...filters.attributes,
|
||||
[code]: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getFacetTitle = (attr: AttributeDefinition): string => {
|
||||
if (attr.label) {
|
||||
const text = resolveLocalizedText(attr.label, lang)
|
||||
if (text) return text
|
||||
}
|
||||
return attr.code
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full rounded-2xl bg-surface-card border border-line-primary/10 p-4 shadow-sm space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Left: Filters Label + Facet Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex items-center gap-2 pr-2 border-r border-line-primary/10">
|
||||
<SlidersHorizontal className="w-4 h-4 text-brand-primary" />
|
||||
<span className="text-xs font-black uppercase tracking-wider text-text-main">Filters</span>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Attribute Facet Dropdowns */}
|
||||
{dynamicFacets.map((facet) => {
|
||||
const title = getFacetTitle(facet.attr)
|
||||
const selectedVals = filters.attributes[facet.attr.code] || []
|
||||
const isOpen = activeDropdown === facet.attr.code
|
||||
|
||||
return (
|
||||
<div key={facet.attr.code} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDropdown(facet.attr.code)}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
selectedVals.length > 0
|
||||
? 'bg-brand-primary/10 border-brand-primary text-brand-primary'
|
||||
: isOpen
|
||||
? 'bg-surface-page border-text-main text-text-main'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{selectedVals.length > 0 && (
|
||||
<span className="w-4 h-4 rounded-full bg-brand-primary text-surface-card text-[10px] font-bold flex items-center justify-center">
|
||||
{selectedVals.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 top-full mt-2 z-50 w-64 max-h-72 overflow-y-auto rounded-xl bg-surface-card border border-line-primary/20 p-3 shadow-xl space-y-1.5">
|
||||
{facet.options.length === 0 ? (
|
||||
<p className="text-xs text-muted-text py-2 text-center">No options available</p>
|
||||
) : (
|
||||
facet.options.map((opt) => {
|
||||
const isChecked = selectedVals.includes(opt.value)
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer p-1.5 rounded-lg hover:bg-surface-page transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleAttribute(facet.attr.code, opt.value)}
|
||||
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5 accent-brand-primary"
|
||||
/>
|
||||
<span className={isChecked ? 'font-bold text-brand-primary' : ''}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-text font-mono">({opt.count})</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Price Range Dropdown */}
|
||||
{showPrice && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDropdown('price')}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
filters.priceRange[1] < priceBounds[1]
|
||||
? 'bg-brand-primary/10 border-brand-primary text-brand-primary'
|
||||
: activeDropdown === 'price'
|
||||
? 'bg-surface-page border-text-main text-text-main'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>Price</span>
|
||||
{filters.priceRange[1] < priceBounds[1] && (
|
||||
<span className="text-[10px] font-mono font-bold text-brand-primary">
|
||||
≤฿{filters.priceRange[1].toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${activeDropdown === 'price' ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{activeDropdown === 'price' && (
|
||||
<div className="absolute left-0 top-full mt-2 z-50 w-72 rounded-xl bg-surface-card border border-line-primary/20 p-4 shadow-xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-text-main">Max Price</span>
|
||||
<span className="text-xs font-mono font-bold text-brand-primary">
|
||||
฿{filters.priceRange[1].toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds[0]}
|
||||
max={priceBounds[1]}
|
||||
value={filters.priceRange[1]}
|
||||
onChange={(e) =>
|
||||
onChangeFilters({
|
||||
...filters,
|
||||
priceRange: [filters.priceRange[0], Number(e.target.value)],
|
||||
})
|
||||
}
|
||||
className="w-full h-1.5 bg-surface-page rounded-lg appearance-none cursor-pointer accent-brand-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-text font-mono">
|
||||
<span>฿{priceBounds[0].toLocaleString()}</span>
|
||||
<span>฿{priceBounds[1].toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stock Availability Pills */}
|
||||
{showStock && (
|
||||
<div className="flex items-center gap-1.5 pl-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStock('in_stock')}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
filters.stockStatus.includes('in_stock')
|
||||
? 'bg-emerald-500/10 border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>In Stock ({stockCounts.inStock})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStock('pre_order')}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
filters.stockStatus.includes('pre_order')
|
||||
? 'bg-amber-500/10 border-amber-500 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-surface-card border-line-primary/20 text-muted-text hover:text-text-main hover:border-line-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span>Pre-Order ({stockCounts.preOrder})</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Reset All */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-xs font-semibold text-brand-primary hover:underline flex items-center gap-1 cursor-pointer shrink-0"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
<span>Reset All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,8 +38,9 @@ export type ApiResult<T> =
|
||||
export async function fetchJson<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
...init,
|
||||
next: init?.next ?? { revalidate: defaultRevalidate },
|
||||
next: init?.next ?? { revalidate: 0 },
|
||||
})
|
||||
|
||||
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>> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
...init,
|
||||
next: init?.next ?? { revalidate: defaultRevalidate },
|
||||
next: init?.next ?? { revalidate: 0 },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
"placeholder": "Search scents, categories...",
|
||||
"quickLinks": "Quick Links",
|
||||
"allProducts": "All Products"
|
||||
}
|
||||
},
|
||||
"trackOrder": "Track Order",
|
||||
"delivery": "Delivery & Returns"
|
||||
},
|
||||
"cart": {
|
||||
"title": "Your Sanctuary",
|
||||
@@ -64,5 +66,125 @@
|
||||
"message": "Message",
|
||||
"messagePlaceholder": "Type your message here...",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
"placeholder": "Cerca fragranze, categorie...",
|
||||
"quickLinks": "Link rapidi",
|
||||
"allProducts": "Tutti i prodotti"
|
||||
}
|
||||
},
|
||||
"trackOrder": "Traccia Ordine",
|
||||
"delivery": "Spedizione & Resi"
|
||||
},
|
||||
"cart": {
|
||||
"title": "Il tuo carrello",
|
||||
@@ -64,5 +66,125 @@
|
||||
"message": "Messaggio",
|
||||
"messagePlaceholder": "Scrivi qui il tuo 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": "メッセージ",
|
||||
"messagePlaceholder": "ここにメッセージを入力してください...",
|
||||
"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": "메시지",
|
||||
"messagePlaceholder": "여기에 메시지를 입력하세요...",
|
||||
"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": "주문 조회로 돌아가기"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
"placeholder": "ຄົ້ນຫາກິ່ນ, ໝວດສິນຄ້າ...",
|
||||
"quickLinks": "ລິ້ງດ່ວນ",
|
||||
"allProducts": "ສິນຄ້າທັງໝົດ"
|
||||
}
|
||||
},
|
||||
"trackOrder": "ຕິດຕາມຄຳສັ່ງຊື້",
|
||||
"delivery": "ການຈັດສົ່ງ ແລະ ການສົ່ງຄືນ"
|
||||
},
|
||||
"cart": {
|
||||
"title": "ຕະກ້າສິນຄ້າຂອງທ່ານ",
|
||||
@@ -64,5 +66,125 @@
|
||||
"message": "ຂໍ້ຄວາມ",
|
||||
"messagePlaceholder": "ພິມຂໍ້ຄວາມຂອງທ່ານທີ່ນີ້...",
|
||||
"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": "ກັບໄປຄົ້ນຫາຄຳສັ່ງຊື້"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
"placeholder": "ค้นหากลิ่น, คอลเลกชัน...",
|
||||
"quickLinks": "ลิงก์ด่วน",
|
||||
"allProducts": "สินค้าทั้งหมด"
|
||||
}
|
||||
},
|
||||
"trackOrder": "ติดตามคำสั่งซื้อ",
|
||||
"delivery": "การจัดส่งและการคืนสินค้า"
|
||||
},
|
||||
"cart": {
|
||||
"title": "ตะกร้าสินค้า",
|
||||
@@ -64,5 +66,125 @@
|
||||
"message": "ข้อความ",
|
||||
"messagePlaceholder": "พิมพ์ข้อความของคุณที่นี่...",
|
||||
"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",
|
||||
"messagePlaceholder": "Nhập tin nhắn của bạn tại đây...",
|
||||
"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": "留言",
|
||||
"messagePlaceholder": "在这里输入您的留言...",
|
||||
"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": "返回订单查询"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export interface CategoryFeaturedSeries {
|
||||
imageUrl?: string
|
||||
actionImageUrl?: string
|
||||
features?: string[]
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export interface CategoryUspBenefit {
|
||||
@@ -49,12 +50,21 @@ export interface CategoryUspBenefit {
|
||||
|
||||
export interface CategoryFilterConfig {
|
||||
showCategoryNav?: boolean // Toggle Category Pills Bar
|
||||
showFilters?: boolean
|
||||
showToolbar?: boolean
|
||||
showSeries?: boolean
|
||||
showType?: boolean
|
||||
showColor?: boolean
|
||||
showFitment?: boolean
|
||||
showPrice?: 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 {
|
||||
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
Reference in new issue
Block a user