From bf9599008e4e1772a592736283d25ed8505cc81b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 8 Oct 2026 07:12:58 +0700 Subject: [PATCH] x --- .../category/CategoryHybridCmsEditor.tsx | 335 +++- apps/admin/src/pages/CategoryCmsPage.tsx | 1573 ++++++++++++----- apps/admin/src/pages/CategoryEditPage.tsx | 273 +-- .../src/app/[lang]/category/[id]/page.tsx | 169 +- apps/website/src/app/[lang]/checkout/page.tsx | 23 +- apps/website/src/app/[lang]/layout.tsx | 1 + .../src/app/[lang]/order/[id]/page.tsx | 84 +- .../website/src/components/CheckoutClient.tsx | 156 +- apps/website/src/components/Footer.tsx | 10 +- .../src/components/OrderPaymentClient.tsx | 420 +++++ apps/website/src/components/ProductCard.tsx | 87 +- .../category/CategoryCatalogContainer.tsx | 320 ++-- .../category/CategoryFeaturedSeries.tsx | 2 +- .../category/CategoryFilterHorizontalBar.tsx | 275 +++ apps/website/src/lib/cms.ts | 6 +- apps/website/src/lib/translations.ts | 25 + apps/website/src/locales/en/translation.json | 124 +- apps/website/src/locales/it/translation.json | 124 +- apps/website/src/locales/ja/translation.json | 120 ++ apps/website/src/locales/ko/translation.json | 120 ++ apps/website/src/locales/lo/translation.json | 124 +- apps/website/src/locales/th/translation.json | 124 +- apps/website/src/locales/vi/translation.json | 120 ++ apps/website/src/locales/zh/translation.json | 120 ++ packages/types/index.ts | 10 + scratch/hooks/beauty-post-receive | 156 -- scratch/hooks/fashion-post-receive | 156 -- scratch/hooks/moto-post-receive | 156 -- scratch/hooks/wellness-post-receive | 156 -- 29 files changed, 3828 insertions(+), 1541 deletions(-) create mode 100644 apps/website/src/components/OrderPaymentClient.tsx create mode 100644 apps/website/src/components/category/CategoryFilterHorizontalBar.tsx create mode 100644 apps/website/src/lib/translations.ts delete mode 100644 scratch/hooks/beauty-post-receive delete mode 100644 scratch/hooks/fashion-post-receive delete mode 100644 scratch/hooks/moto-post-receive delete mode 100644 scratch/hooks/wellness-post-receive diff --git a/apps/admin/src/components/category/CategoryHybridCmsEditor.tsx b/apps/admin/src/components/category/CategoryHybridCmsEditor.tsx index 6c1db67..62deac5 100644 --- a/apps/admin/src/components/category/CategoryHybridCmsEditor.tsx +++ b/apps/admin/src/components/category/CategoryHybridCmsEditor.tsx @@ -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 && (
+ {/* Full Wide Banner Toggle */} +
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' + }`} + > +
+
+ + Full Wide Banner (ขยายกว้างเต็มจอ) + + {featuredSeries.fullWidth ? 'Full Wide (1920px+)' : 'Boxed (1280px)'} + +
+

+ ขยายขนาดแบนเนอร์ Featured Series ให้กว้างเต็มจอหน้าเว็บของผู้ใช้ (Fluid Full Width แทนที่ Boxed max-w-7xl) +

+
+ + +
+
{/* Badge Kicker */}
@@ -502,6 +552,289 @@ export default function CategoryHybridCmsEditor({ {/* ── TAB 3: Filter System Configuration ──────────────────────────── */} {activeTab === 'filters' && (
+ {/* Container Width (Standard, Wide, Full) */} +
+
+
+
+ + Container Width (ความกว้างพื้นที่แคตตาล็อกสินค้า) +
+

+ เลือกความกว้างของบล็อกสินค้าและตัวกรอง ให้พอดีกับดีไซน์หน้าร้าน +

+
+ + {filterConfig.containerWidth === 'full' || filterConfig.fullWidth + ? 'Full Width (1920px+ Fluid)' + : filterConfig.containerWidth === 'wide' + ? 'Wide (1600px)' + : 'Standard (1280px Boxed)'} + +
+ +
+ {[ + { 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 ( + + ) + })} +
+
+ + {/* Card Presentation & Styling (Parity with Web CMS Product Carousel) */} +
+
+
+ + Product Card Presentation (รูปแบบและการแสดงผลการ์ดสินค้า) +
+

+ ปรับแต่งสไตล์การ์ด ขนาด และองค์ประกอบปุ่มการสั่งซื้อ (Parity กับ CMS Product Carousel) +

+
+ + {/* 1. Card Style Selector */} +
+ +
+ {[ + { + 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 ( + + ) + })} +
+
+ + {/* 2. Card Size / Density */} +
+ +
+ {[ + { 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 ( + + ) + })} +
+
+ + {/* 3. Card Elements Toggles */} +
+ + + +
+
+ + {/* Card Columns & Grid Layout */} +
+
+
+
+ + Product Card Columns (จำนวนคอลัมน์การ์ดสินค้า) +
+

+ กำหนดขนาดและความกว้างของการ์ดสินค้าบน Desktop / Ultra-Wide +

+
+ + {filterConfig.gridColumns ? `${filterConfig.gridColumns} Columns` : 'Adaptive Auto'} + +
+ +
+ {[ + { 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 ( + + ) + })} +
+
+ + {/* Filters UI Layout Position */} +
+
+
+
+ + Filters UI Position (ตำแหน่งและการจัดวางตัวกรอง) +
+

+ เลือกตำแหน่งแถบตัวกรอง เพื่อขยายตารางสินค้าให้กว้างเต็ม 100% หรือจัดเป็น Sidebar ด้านข้าง +

+
+ + {filterConfig.filterPosition === 'top_bar' ? 'Top Bar (Wide Ribbon)' : 'Sidebar (ซ้าย)'} + +
+ +
+ + + +
+
+ {/* Section 1: Standard Global Facet Presets */}
diff --git a/apps/admin/src/pages/CategoryCmsPage.tsx b/apps/admin/src/pages/CategoryCmsPage.tsx index da1a233..5f8ec56 100644 --- a/apps/admin/src/pages/CategoryCmsPage.tsx +++ b/apps/admin/src/pages/CategoryCmsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { AlertCircle, ArrowDown, @@ -25,11 +25,17 @@ import { Sparkles, Trash2, Upload, + Columns, + Sliders, + Maximize2, + Palette, + LayoutGrid, } from 'lucide-react' -import { API_BASE, type Category, type CategorySection } from '@homaroma/types' +import { API_BASE, type Category, type CategorySection, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig } from '@homaroma/types' import MediaLibraryModal from '../components/MediaLibraryModal' import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCarouselBlockForm' import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm' +import CategoryHybridCmsEditor from '../components/category/CategoryHybridCmsEditor' import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation' import { getAdminDisplayString } from '../utils/localization' import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal' @@ -173,6 +179,41 @@ export default function CategoryCmsPage() { const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false) const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export') + const [searchParams, setSearchParams] = useSearchParams() + const initialTab = searchParams.get('tab') === 'blocks' ? 'page_builder' : 'presentation' + const [cmsTab, setCmsTab] = useState<'presentation' | 'page_builder'>(initialTab) + + // Presentation & Hybrid CMS States + const [layoutType, setLayoutType] = useState('split_showcase') + const [featuredSeries, setFeaturedSeries] = useState({ + enabled: false, + badge: 'FEATURED SERIES /', + title: '', + subtitle: '', + description: '', + ctaText: 'EXPLORE SERIES →', + ctaUrl: '#products', + imageUrl: '', + actionImageUrl: '', + features: ['ADJUSTABLE DAMPING', 'SUPERIOR RELIABILITY', 'RACE PROVEN'], + }) + const [uspBenefits, setUspBenefits] = useState([ + { icon: 'shield', title: 'Superior Damping', description: 'Engineered for maximum stability on and off the track' }, + { icon: 'gauge', title: 'Race Proven Performance', description: 'Tested and proven by champions in extreme conditions' }, + { icon: 'wrench', title: 'Durable For Extreme Use', description: 'Premium grade components built to last' }, + ]) + const [filterConfig, setFilterConfig] = useState({ + showCategoryNav: true, + showSeries: true, + showType: true, + showColor: true, + showFitment: true, + showPrice: true, + showStock: true, + }) + const [savingPresentation, setSavingPresentation] = useState(false) + const [presentationSuccess, setPresentationSuccess] = useState(false) + const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] const selectedSection = selectedSectionId === 'new' @@ -228,6 +269,12 @@ export default function CategoryCmsPage() { if (!categoryRes.ok) throw new Error('Category not found') const categoryData = await categoryRes.json() setCategory(categoryData) + if (categoryData.layoutType) setLayoutType(categoryData.layoutType) + if (categoryData.featuredSeries) setFeaturedSeries(categoryData.featuredSeries) + if (Array.isArray(categoryData.uspBenefits) && categoryData.uspBenefits.length > 0) { + setUspBenefits(categoryData.uspBenefits) + } + if (categoryData.filterConfig) setFilterConfig(categoryData.filterConfig) await fetchDetailSections(id) } catch (err: any) { setError(err.message) @@ -239,6 +286,34 @@ export default function CategoryCmsPage() { fetchData() }, [id]) + const handleSavePresentation = async () => { + if (!id) return + setSavingPresentation(true) + setError(null) + setPresentationSuccess(false) + try { + const response = await fetch(`${API_BASE}/categories/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + layoutType, + featuredSeries, + uspBenefits, + filterConfig, + }), + }) + const data = await response.json().catch(() => null) + if (!response.ok) throw new Error(getApiErrorMessage(data, 'Failed to save presentation settings')) + setCategory((prev) => (prev ? { ...prev, ...data } : data)) + setPresentationSuccess(true) + setTimeout(() => setPresentationSuccess(false), 3000) + } catch (err: any) { + setError(err instanceof Error ? err.message : 'Failed to save presentation settings') + } finally { + setSavingPresentation(false) + } + } + const syncNativePayload = (field: string, value: unknown) => { setSectionPayload((prev) => { const next = { ...prev, [field]: value } @@ -472,462 +547,1096 @@ export default function CategoryCmsPage() {
)} - {/* Top 4 Stats Cards */} -
-
-

Total Blocks

-

{detailSections.length}

-
-
-

Native Blocks

-

{nativeSectionCount}

-
-
-

Hidden

-

{inactiveSectionCount}

-
-
-

Mode

-

{selectedSectionId === 'new' ? 'Create block' : 'Edit selected block'}

+ {/* Top Workspace Mode Tabs: Category Presentation & Hybrid CMS (Wide Full) vs Dynamic Page Builder */} +
+
+ +
+ + {/* Tab Quick Action */} + {cmsTab === 'presentation' && ( + + )}
- {/* Main Page Builder Section */} -
- {/* Section Header with Language Switcher and Add Block Button */} -
-
- -
-

Category Detail Page Builder

-

Start with native blocks. Use content blocks only when this category needs extra storytelling.

+ {/* Main Tab Content */} + {cmsTab === 'presentation' ? ( +
+ {/* Presentation Feedback */} + {presentationSuccess && ( +
+ +

บันทึกการตั้งค่า Category Presentation & Hybrid CMS สำเร็จ!

-
-
-
- {languages.map((lang) => ( - - ))} -
- {detailSections.length === 0 && ( - - )} - -
-
+ )} - {/* Native Category Blocks Quick Bar */} -
-
-
-

- - Native Category Blocks -

-

These blocks render live category data and are the recommended starting point.

-
-
- {NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => ( - - ))} -
-
-
- - {/* 2-Column Section Order & Block Form */} -
- {/* Left Column: Section Order */} -
-
-
-

Section Order

-

Move blocks to control the category detail page layout.

-
- -
- - {detailSections.length === 0 && ( -
- -

No category CMS blocks yet.

-

Seed the standard layout or choose a native block above.

-
- )} - - {detailSections.map((section, index) => ( -
- - -
- - - - + {/* Card 1: Category Layout & Presentation Archetypes in Wide View */} +
+
+
+
+
-
- ))} -
- - {/* Right Column: Editing Block */} -
-
-
-

- {selectedSectionId === 'new' ? 'New Block' : 'Editing Block'} -

-

{SECTION_LABELS[sectionType] || sectionType}

-

- {SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'} -

+

+ Category Layout & Presentation Archetype + + Wide Full + +

+

เลือกโครงสร้างส่วนหัวและการแสดงผลสินค้าบน Storefront & Website

-
- {isTranslatableBlock(sectionType) && ( - <> - - - - )} - {selectedSection?.isLocked && ( -
- - Locked +
+ + {layoutType === 'none' ? 'none (ปิดส่วนหัว)' : layoutType} + +
+ +
+ {/* Option 1: Split Showcase */} +
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]' + }`} + > +
+
+
+
+
+
+
+
- )} +
+
+
+
+
+
+ +
+
+

+ + Split Showcase +

+ {layoutType === 'split_showcase' && ( + + )} +
+

ค่าเริ่มต้น (Balanced)

+

+ หัวข้อแบ่ง 2 ฝั่ง (ข้อความซ้าย + รูปเด่นขวา) พร้อมการจัดแสดงสินค้า 3-4 คอลัมน์มาตรฐาน +

+
-
-
- - -
+ {/* Option 2: Editorial Hero */} +
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]' + }`} + > +
+
+
+
+
+
+
+
+
+
+
- +
+
+

+ + Editorial Hero +

+ {layoutType === 'editorial_hero' && ( + + )} +
+

Cinematic & Luxury

+

+ Hero Banner เต็มจอสไตล์นิตยสารหรูหรา พร้อมการ์ดสินค้าขนาดใหญ่ 2-3 คอลัมน์ +

+
+
+
+ + {/* Option 3: Dense Catalog */} +
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]' + }`} + > +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

+ + Dense Catalog +

+ {layoutType === 'dense_catalog' && ( + + )} +
+

High Density Shopper

+

+ Header ย่อกะทัดรัด พร้อมตารางสินค้าความหนาแน่นสูง 4-5 คอลัมน์ เหมาะสำหรับสินค้าจำนวนมาก +

+
+
+
+ + {/* Option 4: None / ปิดส่วนหัว */} +
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]' + }`} + > +
+
+
+
+ No Header +
+
+
+
+
+
+
+ +
+
+

+ + ปิด (No Header) +

+ {layoutType === 'none' && ( + + )} +
+

Clean & Streamlined

+

+ ไม่แสดง Archetype Header ด้านบน เพื่อเริ่มแสดง Featured Series หรือสินค้าทันที ลดความซ้ำซ้อน +

+
+
+
+
+
+ + {/* Card 2: CategoryHybridCmsEditor in Wide Full */} + { + openMediaPicker((url) => { + if (target === 'featured_image') { + setFeaturedSeries((prev) => ({ ...prev, imageUrl: url })) + } else if (target === 'featured_action') { + setFeaturedSeries((prev) => ({ ...prev, actionImageUrl: url })) + } + }) + }} + categoryId={id ? parseInt(id, 10) : undefined} + categoryName={categoryName} + /> + + {/* Sticky Bottom Save Bar */} +
+
+
+ +
+
+

Save Presentation & Hybrid CMS Settings

+

บันทึก Layout Archetype, แบนเนอร์ Featured Series, การ์ด USP Benefits และตัวกรอง

- {/* Block Form Container */} -
- {CATEGORY_NATIVE_SECTION_TYPES.has(sectionType) ? ( -
-
-

Native Category Settings

-

These controls affect headers, navigation, and presentation. Live products come from this category.

-
- - {sectionType === 'CATEGORY_HEADER' && ( -
-
-
- - syncNativeLocalizedPayload('title', e.target.value)} - className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" - placeholder="Leave blank to use category name" - /> -
-
- - syncNativeLocalizedPayload('subtitle', e.target.value)} - className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" - placeholder="Optional tagline" - /> -
-
- -
- - -
-
- )} - - {sectionType === 'CATALOG_GRID' && ( -
-
- - -
- -
- - syncNativePayload('itemsPerPage', parseInt(e.target.value, 10) || 24)} - className="w-40 px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" - /> -
-
- )} - - {sectionType === 'USP_BAR' && ( -
- -

Renders the 3 USP benefit columns configured for this category.

-
- )} -
- ) : ( -
-
-

- Category Content Block -

-

These forms follow the Page Builder block design, but stay scoped to this category.

-
- - {sectionType === 'HERO_CAROUSEL' && ( - { - setSectionPayload(nextPayload) - setSectionJson(JSON.stringify(nextPayload, null, 2)) - }} - onBrowseMedia={openMediaPicker} - /> - )} - - {sectionType === 'DIVIDER' && ( - { - setSectionPayload(nextPayload) - setSectionJson(JSON.stringify(nextPayload, null, 2)) - }} - /> - )} -
+
+ {presentationSuccess && ( + + + บันทึกสำเร็จ! + )} + +
+
+
+ ) : ( + /* Page Builder Blocks Tab */ +
+ {/* Top 4 Stats Cards */} +
+
+

Total Blocks

+

{detailSections.length}

+
+
+

Native Blocks

+

{nativeSectionCount}

+
+
+

Hidden

+

{inactiveSectionCount}

+
+
+

Mode

+

{selectedSectionId === 'new' ? 'Create block' : 'Edit selected block'}

+
+
- {/* Bottom Save Bar */} -
- {sectionSaveError && ( -
- -

{sectionSaveError}

-
+ {/* Main Page Builder Section */} +
+ {/* Section Header with Language Switcher and Add Block Button */} +
+
+ +
+

Category Detail Page Builder

+

Start with native blocks. Use content blocks only when this category needs extra storytelling.

+
+
+
+
+ {languages.map((lang) => ( + + ))} +
+ {detailSections.length === 0 && ( + )} -
-
+ + {/* Native Category Blocks Quick Bar */} +
+
+
+

+ + Native Category Blocks +

+

These blocks render live category data and are the recommended starting point.

+
+
+ {NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => ( + + ))} +
+
+
+ + {/* 2-Column Section Order & Block Form */} +
+ {/* Left Column: Section Order */} +
+
+
+

Section Order

+

Move blocks to control the category detail page layout.

+
+ +
+ + {detailSections.length === 0 && ( +
+ +

No category CMS blocks yet.

+

Seed the standard layout or choose a native block above.

+
+ )} + + {detailSections.map((section, index) => ( +
+ + +
+ + + + +
+
+ ))} +
+ + {/* Right Column: Editing Block */} +
+
+
+
+

+ {selectedSectionId === 'new' ? 'New Block' : 'Editing Block'} +

+

{SECTION_LABELS[sectionType] || sectionType}

+

+ {SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'} +

+
+
+ {isTranslatableBlock(sectionType) && ( + <> + + + + )} + {selectedSection?.isLocked && ( +
+ + Locked +
+ )} +
+
+ +
+
+ + +
+ + +
+
+ + {/* Block Form Container */} +
+ {CATEGORY_NATIVE_SECTION_TYPES.has(sectionType) ? ( +
+
+

Native Category Settings

+

These controls affect headers, navigation, and presentation. Live products come from this category.

+
+ + {sectionType === 'CATEGORY_HEADER' && ( +
+ {/* Layout Archetype banner with shortcut */} +
+
+

+ Active Layout Archetype: + + {layoutType} + +

+

+ เลือก Split Showcase, Editorial Hero, Dense Catalog หรือ ปิดส่วนหัว ได้ที่แท็บ Presentation +

+
+ +
+ +
+
+ + syncNativeLocalizedPayload('title', e.target.value)} + className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" + placeholder="Leave blank to use category name" + /> +
+
+ + syncNativeLocalizedPayload('subtitle', e.target.value)} + className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" + placeholder="Optional tagline" + /> +
+
+ +
+ + +
+
+ )} + + {sectionType === 'CATALOG_GRID' && ( +
+ {/* Facet filter shortcut */} +
+
+

+ + Filter Facets & Product Attributes +

+

+ ปรับแต่งตัวกรองแถบข้าง (Series, Type, Color, Fitment, Price, Stock) และจัดการ Attributes +

+
+ +
+ +
+ + +
+ + {/* Container Width (Standard, Wide, Full) */} +
+
+ + + {(sectionPayload.containerWidth ?? filterConfig.containerWidth) === 'full' || (sectionPayload.fullWidth ?? filterConfig.fullWidth) + ? 'Full Width (1920px+)' + : (sectionPayload.containerWidth ?? filterConfig.containerWidth) === 'wide' + ? 'Wide (1600px)' + : 'Standard (1280px)'} + +
+
+ {[ + { val: 'standard', label: 'Standard Boxed', desc: '1280px กึ่งกลาง' }, + { val: 'wide', label: 'Wide (1600px)', desc: 'ขยายกว้าง 1600px' }, + { val: 'full', label: 'Full Width Fluid', desc: 'เต็มจอ 100% (1920px+)' }, + ].map((item) => { + const currentWidth = (sectionPayload.containerWidth ?? filterConfig.containerWidth) || + ((sectionPayload.fullWidth ?? filterConfig.fullWidth) ? 'full' : 'standard') + const isSelected = currentWidth === item.val + return ( + + ) + })} +
+
+ + {/* Product Card Presentation (Card Style, Density & Elements) */} +
+
+
+ + Product Card Presentation (รูปแบบและการแสดงผลการ์ดสินค้า) +
+

+ ปรับแต่งสไตล์การ์ด ขนาด และองค์ประกอบปุ่มการสั่งซื้อ (Parity กับ CMS Product Carousel) +

+
+ + {/* Card Style Selector */} +
+ +
+ {[ + { val: 'standard', label: 'Standard Retail', desc: 'กรอบ 1:1 Boxed พร้อมปุ่มเพิ่มลงตะกร้า' }, + { val: 'minimal', label: 'Fashion Minimal', desc: 'กรอบแนวตั้ง 3:4 Portrait โหมด Lookbook' }, + { val: 'swatch', label: 'Editorial Swatch', desc: 'การ์ดโชว์กลิ่นโน้ต/สี Fragrance Swatches' }, + ].map((item) => { + const currentStyle = (sectionPayload.cardStyle ?? filterConfig.cardStyle) || 'standard' + const isSelected = currentStyle === item.val + return ( + + ) + })} +
+
+ + {/* Card Size / Density */} +
+ +
+ {[ + { val: 'compact', label: 'Compact Dense', desc: 'กะทัดรัด เห็นของเยอะ' }, + { val: 'medium', label: 'Medium Standard', desc: 'สมดุลมาตรฐาน' }, + { val: 'large', label: 'Large Editorial', desc: 'ใหญ่ ชัดเจน โดดเด่น' }, + ].map((item) => { + const currentSize = (sectionPayload.cardSize ?? filterConfig.cardSize) || 'medium' + const isSelected = currentSize === item.val + return ( + + ) + })} +
+
+ + {/* Quick Element Toggles */} +
+ + + +
+
+ + {/* Card Columns Selector */} +
+ +
+ {[ + { val: undefined, label: 'Auto (ตามธีม)' }, + { val: 2, label: '2 Cols (Wide การ์ดใหญ่)' }, + { val: 3, label: '3 Cols (Standard)' }, + { val: 4, label: '4 Cols (Wide)' }, + { val: 5, label: '5 Cols (Dense)' }, + ].map((item) => { + const isSelected = (sectionPayload.gridColumns ?? filterConfig.gridColumns) === item.val + return ( + + ) + })} +
+
+ + {/* Filter Position */} +
+ +
+ {[ + { val: 'sidebar', label: 'Sidebar (แถบแนวตั้งด้านซ้าย)' }, + { val: 'top_bar', label: 'Top Bar (แนวนอนด้านบน Wide Ribbon)' }, + ].map((item) => { + const isSelected = ((sectionPayload.filterPosition ?? filterConfig.filterPosition) || 'sidebar') === item.val + return ( + + ) + })} +
+
+ +
+ + syncNativePayload('itemsPerPage', parseInt(e.target.value, 10) || 24)} + className="w-40 px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" + /> +
+
+ )} + + {sectionType === 'USP_BAR' && ( +
+ + + {/* Shortcut to USP cards in presentation */} +
+
+

+ + Configured USP Benefits ({uspBenefits.length} รายการ) +

+

+ แก้ไขไอคอน, หัวข้อ และคำอธิบายการ์ดคุณสมบัติเด่นของหมวดหมู่ได้ที่แท็บ Presentation +

+
+ +
+
+ )} +
+ ) : ( +
+
+

+ Category Content Block +

+

These forms follow the Page Builder block design, but stay scoped to this category.

+
+ + {sectionType === 'HERO_CAROUSEL' && ( + { + setSectionPayload(nextPayload) + setSectionJson(JSON.stringify(nextPayload, null, 2)) + }} + onBrowseMedia={openMediaPicker} + /> + )} + + {sectionType === 'DIVIDER' && ( + { + setSectionPayload(nextPayload) + setSectionJson(JSON.stringify(nextPayload, null, 2)) + }} + /> + )} +
+ )} + + {/* Bottom Save Bar */} +
+ {sectionSaveError && ( +
+ +

{sectionSaveError}

+
+ )} + + +
+
+
+
+
- + )} {/* Media Library Modal */} ('role') - // Media Library Modal const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [activeMediaRole, setActiveMediaRole] = useState('thumbnail') @@ -470,11 +464,11 @@ export default function CategoryEditPage() { {!isNew && ( - CMS Blocks + CMS & Presentation )} @@ -803,212 +797,78 @@ export default function CategoryEditPage() { )}
- {/* Card: Category Layout Archetype */} -
-
-
-
- -
-
-

Category Layout & Presentation

-

Choose how this category displays on Storefront & Website

-
-
- - {layoutType === 'none' ? 'none (ปิดส่วนหัว)' : layoutType} - -
- -
- {/* Option 1: Split Showcase */} -
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]' - }`} - > -
- {/* Visual Layout Mockup */} -
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* Card: Category Presentation & CMS Blocks Overview */} + {!isNew ? ( +
+
+
+
+
-
-
-

- - Split Showcase -

- {layoutType === 'split_showcase' && ( - - )} -
-

ค่าเริ่มต้น (Balanced)

-

- หัวข้อแบ่ง 2 ฝั่ง (ข้อความซ้าย + รูปเด่นขวา) พร้อมการจัดแสดงสินค้า 3-4 คอลัมน์มาตรฐาน +

+ Category Presentation & CMS Blocks + + CMS Page + +

+

+ ปรับแต่ง Layout Archetype, แบนเนอร์ Featured Series, USP Benefits และ Dynamic Blocks ในหน้า CMS แบบเต็มจอ (Wide Full)

+ + + + จัดการ Presentation & CMS Blocks + +
- {/* Option 2: Editorial Hero */} -
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]' - }`} - > -
- {/* Visual Layout Mockup */} -
-
-
-
-
-
-
-
-
-
- -
-
-

- - Editorial Hero -

- {layoutType === 'editorial_hero' && ( - - )} -
-

Cinematic & Luxury

-

- Hero Banner เต็มจอสไตล์นิตยสารหรูหรา พร้อมการ์ดสินค้าขนาดใหญ่ 2-3 คอลัมน์ -

-
+ {/* Status Chips */} +
+
+

Layout

+

+ {categoryMeta?.layoutType || layoutType || 'split_showcase'} +

-
- - {/* Option 3: Dense Catalog */} -
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]' - }`} - > -
- {/* Visual Layout Mockup */} -
-
-
-
-
-
-
-
-
-
-
-
- -
-
-

- - Dense Catalog -

- {layoutType === 'dense_catalog' && ( - - )} -
-

High Density Shopper

-

- Header ย่อกะทัดรัด พร้อมตารางสินค้าความหนาแน่นสูง 4-5 คอลัมน์ เหมาะสำหรับสินค้าจำนวนมาก -

-
+
+

Featured Series

+

+ {categoryMeta?.featuredSeries?.enabled ? 'เปิดใช้งาน' : 'ปิดอยู่'} +

-
- - {/* Option 4: None / ปิดส่วนหัว */} -
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]' - }`} - > -
- {/* Visual Layout Mockup */} -
-
-
- No Header -
-
-
-
-
-
-
- -
-
-

- - ปิด (No Header) -

- {layoutType === 'none' && ( - - )} -
-

Clean & Streamlined

-

- ไม่แสดง Archetype Header ด้านบน เพื่อเริ่มแสดง Featured Series หรือสินค้าทันที ลดความซ้ำซ้อน -

-
+
+

USP Benefits

+

+ {(categoryMeta?.uspBenefits as any[])?.length || (uspBenefits as any[])?.length || 0} รายการ +

+
+
+

CMS Builder

+ + Page Blocks + +
-
- - {/* Hybrid CMS Presentation Editor */} - { - setMediaTarget(target) - setIsMediaModalOpen(true) - }} - categoryId={!isNew && id ? parseInt(id) : undefined} - categoryName={name.th || name.en || 'Category'} - /> + ) : ( +
+ +

Category Presentation & CMS Blocks

+

+ บันทึกสร้างหมวดหมู่นี้ก่อน จากนั้นจะสามารถไปปรับแต่ง Presentation & Dynamic CMS Blocks ได้ที่หน้า CMS +

+
+ )} {/* 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) }} /> diff --git a/apps/website/src/app/[lang]/category/[id]/page.tsx b/apps/website/src/app/[lang]/category/[id]/page.tsx index a79fde6..ae40575 100644 --- a/apps/website/src/app/[lang]/category/[id]/page.tsx +++ b/apps/website/src/app/[lang]/category/[id]/page.tsx @@ -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) => { 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 (
@@ -201,7 +247,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s ) : layoutType === 'dense_catalog' ? ( /* ── Archetype 3: Dense Catalog Layout ── */
-
+
{showBreadcrumb && (
@@ -229,7 +275,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s ) : ( /* ── Archetype 1: Split Showcase Layout (Default) ── */
-
+
{showBreadcrumb && (
@@ -261,7 +307,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s )} {layoutType === 'none' && showBreadcrumb && ( -
+
)}
- +
{bottomSections.length > 0 && ( diff --git a/apps/website/src/app/[lang]/layout.tsx b/apps/website/src/app/[lang]/layout.tsx index e8029b2..5b81fd3 100644 --- a/apps/website/src/app/[lang]/layout.tsx +++ b/apps/website/src/app/[lang]/layout.tsx @@ -181,6 +181,7 @@ export default async function RootLayout({ exploreMenu={footerMenus.exploreMenu} supportMenu={footerMenus.supportMenu} settings={settings} + lang={lang} />
diff --git a/apps/website/src/app/[lang]/order/[id]/page.tsx b/apps/website/src/app/[lang]/order/[id]/page.tsx index 1db110a..92f0fcf 100644 --- a/apps/website/src/app/[lang]/order/[id]/page.tsx +++ b/apps/website/src/app/[lang]/order/[id]/page.tsx @@ -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 = { - PENDING_PAYMENT: 'รอชำระเงิน', - PAID: 'ชำระเงินแล้ว', - PROCESSING: 'กำลังจัดเตรียมสินค้า', - SHIPPED: 'จัดส่งแล้ว', - DELIVERED: 'จัดส่งสำเร็จ', - CANCELLED: 'ยกเลิกแล้ว', - } - - return labels[status] ?? status.replaceAll('_', ' ') -} - export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise { 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 )}
-

ไม่สามารถโหลดคำสั่งซื้อนี้ได้

+

{t.order.navigation.loadFailed}

{orderResult.error}

- กลับไปค้นหาคำสั่งซื้อ + + {t.order.navigation.backToTrack} +
@@ -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 (
@@ -100,38 +98,38 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
-

Order Status

+

{t.order.header.orderStatus}

{orderLabel(order.id)}

-

คำสั่งซื้อของคุณถูกบันทึกแล้ว สามารถตรวจสอบสถานะล่าสุดได้จากหน้านี้

+

{t.order.header.orderReceived}

-

Created

+

{t.order.cards.created}

- {new Date(order.createdAt).toLocaleDateString('th-TH')} + {formattedDate}

-

Status

+

{t.order.cards.status}

- {statusLabel(order.status)} + {statusLabel}
-

Tracking No.

-

{order.trackingNo || 'อยู่ระหว่างจัดเตรียมพัสดุ'}

+

{t.order.cards.trackingNo}

+

{order.trackingNo || t.order.cards.pendingTracking}

- Order Items + {t.order.items.title}

{order.items.map((item) => ( @@ -158,41 +156,43 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
- Subtotal + {t.order.items.subtotal} {formatPrice(subtotal)}
- Shipping Fee + {t.order.items.shippingFee} {formatPrice(order.shippingFee)}
{(order.discountAmount ?? 0) > 0 && (
- Discount{order.couponCode ? ` (${order.couponCode})` : ''} + {t.order.items.discount}{order.couponCode ? ` (${order.couponCode})` : ''} -{formatPrice(order.discountAmount)}
)}
- Total + {t.order.items.total} {formatPrice(order.totalAmount)}
- {order.status === 'PENDING_PAYMENT' && ( -
-

คำสั่งซื้อนี้ยังรอการชำระเงิน

-

- หน้านี้รองรับการตรวจสอบสถานะคำสั่งซื้อแล้ว ส่วนการชำระเงินและอัปโหลดสลิปบน website จะย้ายมาในรอบถัดไป เพื่อไม่ทำ workaround ที่เสี่ยงกับ payment flow -

-
- )} + {/* Interactive Payment & Slip Upload Section */} +
- ค้นหาออเดอร์อื่น + {t.order.navigation.trackAnother} - กลับไปช้อปต่อ + {t.order.navigation.continueShopping}
diff --git a/apps/website/src/components/CheckoutClient.tsx b/apps/website/src/components/CheckoutClient.tsx index 0acacd5..d32d043 100644 --- a/apps/website/src/components/CheckoutClient.tsx +++ b/apps/website/src/components/CheckoutClient.tsx @@ -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(path: string, init?: RequestInit): Promise { @@ -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) => { + 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('/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) => { @@ -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 (
-

กำลังโหลดตะกร้าสินค้า...

+

{t('checkout.loadingCart')}

) } @@ -399,10 +437,10 @@ export default function CheckoutClient() { return (
-

ตะกร้าสินค้าว่าง

-

เลือกสินค้าที่คุณชอบก่อน แล้วกลับมายืนยันคำสั่งซื้อที่หน้านี้ได้เลย

+

{t('checkout.emptyCartTitle')}

+

{t('checkout.emptyCartDesc')}

- กลับไปเลือกสินค้า + {t('checkout.continueShopping')}
) @@ -418,43 +456,43 @@ export default function CheckoutClient() {
-

ข้อมูลผู้รับสินค้า

-

ข้อมูลนี้จะถูกส่งไปสร้างคำสั่งซื้อตาม contract ของ backend

+

{t('checkout.recipientInfo.title')}

+

{t('checkout.recipientInfo.subtitle')}

@@ -464,8 +502,8 @@ export default function CheckoutClient() {
-

ที่อยู่จัดส่ง

-

สมาชิกสามารถเลือกที่อยู่ที่บันทึกไว้ หรือกรอกที่อยู่ใหม่ได้

+

{t('checkout.shippingAddress.title')}

+

{t('checkout.shippingAddress.subtitle')}

@@ -492,12 +530,12 @@ export default function CheckoutClient() { {address.recipientName} {address.isDefault && ( - default + {t('checkout.shippingAddress.defaultBadge')} )} {address.phone} - {formatAddress(address)} + {formatAddress(address, isThai)} ))} @@ -513,7 +551,7 @@ export default function CheckoutClient() { onChange={() => selectAddress('new')} className="accent-brand-primary" /> - กรอกที่อยู่ใหม่ + {t('checkout.shippingAddress.addNewAddress')}
)} @@ -521,17 +559,17 @@ export default function CheckoutClient() { {(savedAddresses.length === 0 || selectedAddressId === 'new') && (
@@ -585,7 +623,7 @@ export default function CheckoutClient() { > {submitting && } - สร้างคำสั่งซื้อ + {t('checkout.summary.placeOrder')} {formatPrice(total)} @@ -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" > - สรุปรายการ - {items.length} รายการในตะกร้า + {t('checkout.summary.title')} + {t('checkout.summary.itemsCount', { count: items.length })} {formatPrice(total)} @@ -620,7 +658,7 @@ export default function CheckoutClient() {

{item.productName}

-

{item.variantName || 'Standard'} x {item.quantity}

+

{item.variantName || t('checkout.summary.standardVariant')} x {item.quantity}

{formatPrice(item.price * item.quantity)}

@@ -630,12 +668,12 @@ export default function CheckoutClient() {
{!appliedCoupon ? (
- +
setCouponCode(event.target.value.toUpperCase())} - placeholder="COUPON" + placeholder={t('checkout.summary.couponPlaceholder')} className="ui-form-shell min-w-0 flex-1 uppercase tracking-widest" />
{couponError &&

{couponError}

} @@ -656,7 +694,7 @@ export default function CheckoutClient() { {appliedCoupon}
)} @@ -665,8 +703,8 @@ export default function CheckoutClient() { {customerPoints > 0 && (
- คะแนนสะสม - {customerPoints} points + {t('checkout.summary.pointsLabel')} + {t('checkout.summary.pointsBalance', { count: customerPoints })}
{usePoints && (
- Subtotal + {t('checkout.summary.subtotal')} {formatPrice(subtotal)}
{promoLevel1Disc > 0 && ( @@ -713,18 +751,18 @@ export default function CheckoutClient() { )} {discountAmount > 0 && (
- Coupon {appliedCoupon ? `(${appliedCoupon})` : ''} + {t('checkout.summary.coupon')} {appliedCoupon ? `(${appliedCoupon})` : ''} -{formatPrice(discountAmount)}
)} {pointsDiscount > 0 && (
- Points + {t('checkout.summary.points')} -{formatPrice(pointsDiscount)}
)}
- Shipping + {t('checkout.summary.shipping')} {loadingSettings ? ( ) : ( @@ -733,12 +771,12 @@ export default function CheckoutClient() {
{isFreeShipping && (
- Free shipping + {t('checkout.summary.freeShipping')} -{formatPrice(shippingFee)}
)}
- Total + {t('checkout.summary.total')} {formatPrice(total)}
@@ -747,7 +785,7 @@ export default function CheckoutClient() {

- รอบนี้จะสร้างคำสั่งซื้อสถานะรอชำระเงิน แล้วพาไปหน้า Order Detail ส่วน payment/slip upload ยังเป็นงานถัดไปตามแผน + {t('checkout.summary.notice')}

@@ -760,7 +798,7 @@ export default function CheckoutClient() { > {submitting && } - สร้างคำสั่งซื้อ + {t('checkout.summary.placeOrder')} {formatPrice(total)} diff --git a/apps/website/src/components/Footer.tsx b/apps/website/src/components/Footer.tsx index 6fdd393..e1d4dd5 100644 --- a/apps/website/src/components/Footer.tsx +++ b/apps/website/src/components/Footer.tsx @@ -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) diff --git a/apps/website/src/components/OrderPaymentClient.tsx b/apps/website/src/components/OrderPaymentClient.tsx new file mode 100644 index 0000000..c61a271 --- /dev/null +++ b/apps/website/src/components/OrderPaymentClient.tsx @@ -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) => { + return i18nT(key, { lng: lang, ...options }) + }, + [i18nT, lang], + ) + + const [order, setOrder] = useState(initialOrder) + const [paymentMethod, setPaymentMethod] = useState<'bank' | 'promptpay'>('bank') + const [copied, setCopied] = useState(false) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [qrCodeUrl, setQrCodeUrl] = useState(null) + const [generatingQr, setGeneratingQr] = useState(false) + const [qrError, setQrError] = useState(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) => { + 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 ( +
+
+ +
+

{t('order.payment.paymentComplete')}

+

Status: {t('order.status.PAID')}

+
+ ) + } + + if (order.status !== 'PENDING_PAYMENT') { + return null + } + + return ( +
+ {/* Method Selector Tabs */} +
+ + + +
+ + {/* Bank Transfer Panel */} + {paymentMethod === 'bank' && ( +
+
+

+ {t('order.payment.required')} +

+
+ {formatPrice(order.totalAmount)} +
+ + {bankSettings ? ( +
+ {bankSettings.bankName && ( +
+ + {t('order.payment.bank')} + + + {bankSettings.bankName} + +
+ )} + + {bankSettings.bankAccountNumber && ( +
+
+ + {t('order.payment.accountNumber')} + + + {bankSettings.bankAccountNumber} + +
+ + +
+ )} + + {bankSettings.bankAccountName && ( +
+ + {t('order.payment.accountName')} + + + {bankSettings.bankAccountName} + +
+ )} +
+ ) : null} + +

+ {t('order.payment.transferInstruction')} +

+
+ + {/* Slip Upload Area */} +
+

+ {t('order.payment.slipVerification')} +

+ + {uploadError && ( +
+ + {uploadError} +
+ )} + + {order.paymentSlipUrl ? ( +
+
+ +
+
+

+ {t('order.payment.slipUploaded')} +

+

+ {t('order.payment.slipUploadedDesc')} +

+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Payment Slip Proof +
+ +
+ ) : ( + + )} +
+
+ )} + + {/* PromptPay QR Panel */} + {paymentMethod === 'promptpay' && ( +
+

+ {t('order.payment.promptPayTitle')} +

+
+ {formatPrice(order.totalAmount)} +
+ + {qrError && ( +
+ + {qrError} +
+ )} + + {!qrCodeUrl ? ( +
+ +
+ ) : ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + PromptPay QR Code +
+

+ {t('order.payment.scanPromptPay')} +

+
+ + Checking payment status... +
+
+ )} +
+ )} +
+ ) +} diff --git a/apps/website/src/components/ProductCard.tsx b/apps/website/src/components/ProductCard.tsx index 841e384..a8a6b2f 100644 --- a/apps/website/src/components/ProductCard.tsx +++ b/apps/website/src/components/ProductCard.tsx @@ -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 ( +
+
+
+ +
+ {categoryName && ( + + {categoryName} + + )} + + {productName} + +
+ +
+
+
+
+

+ {productName} +

+ {categoryName && ( +

+ {categoryName} +

+ )} +
+ + {formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)} + +
+ + {colorSwatches.length > 0 && ( +
+ {colorSwatches.slice(0, 8).map(({ colorCode, variant, label }) => ( + + ))} +
+ )} +
+ +
+ + {inStock ? '✓ พร้อมส่ง (In Stock)' : '⏳ สั่งผลิต (Pre-Order)'} + + + + +
+
+
+ ) + } + return (
@@ -282,13 +359,16 @@ export default function ProductCard({ product, cardStyle = 'standard', badgeText )) : null}
+ {showPrice && (
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
+ )} - {product.variants && product.variants.length > 1 ? ( + {showAddToCart && ( + product.variants && product.variants.length > 1 ? ( - )} + ) + )}
diff --git a/apps/website/src/components/category/CategoryCatalogContainer.tsx b/apps/website/src/components/category/CategoryCatalogContainer.tsx index 6c93505..3181aef 100644 --- a/apps/website/src/components/category/CategoryCatalogContainer.tsx +++ b/apps/website/src/components/category/CategoryCatalogContainer.tsx @@ -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 ( -
-
- {/* Left Column: Filter Sidebar (Desktop) */} -
- setFilters(initialFilters)} - config={filterConfig} - priceBounds={priceBounds} - stockCounts={stockCounts} - dynamicFacets={dynamicFacets} - lang={lang} - /> -
+ 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 */} -
- setIsMobileFilterOpen(true)} - activeFilterCount={activeFilterCount} - /> + const [currentPage, setCurrentPage] = useState(1) - {/* Active Filter Pills Bar */} - {activeFilterCount > 0 && ( -
- Active Filters: - {filters.stockStatus.map((s) => ( - { + 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 && ( + setIsMobileFilterOpen(true)} + activeFilterCount={activeFilterCount} + /> + )} + + {/* Active Filter Pills Bar */} + {shouldShowFilters && activeFilterCount > 0 && ( +
+ Active Filters: + {filters.stockStatus.map((s) => ( + + {s === 'in_stock' ? 'In Stock' : 'Pre-Order'} + + + ))} + + {Object.entries(filters.attributes).map(([code, vals]) => + vals.map((v) => ( + + {code}: + {v} + - - ))} - - {Object.entries(filters.attributes).map(([code, vals]) => - vals.map((v) => ( - - {code}: - {v} - - - )) - )} - - -
+ + +
+ )) )} - {/* Products Grid */} - {filteredProducts.length === 0 ? ( -
- -
-

No products found matching active filters

-

- Try adjusting your price range or clearing selected attribute filters. -

-
- -
- ) : ( -
- {filteredProducts.map((product) => ( - - ))} -
- )} +
-
+ )} + + {/* Products Grid */} + {filteredProducts.length === 0 ? ( +
+ +
+

No products found matching active filters

+

+ Try adjusting your price range or clearing selected attribute filters. +

+
+ +
+ ) : ( + <> +
+ {paginatedProducts.map((product) => ( + + ))} +
+ + {itemsPerPage && totalPages > 1 && ( +
+ + {Array.from({ length: totalPages }).map((_, i) => ( + + ))} + +
+ )} + + )} + + ) + + return ( +
+ {!shouldShowFilters ? ( + /* ── Full Width Catalog Grid (No Filters) ── */ +
+ {renderProductList()} +
+ ) : isTopBarFilter ? ( + /* ── Top Bar Filter Layout (Full 100% Width) ── */ +
+
+ setFilters(initialFilters)} + config={filterConfig} + priceBounds={priceBounds} + stockCounts={stockCounts} + dynamicFacets={dynamicFacets} + lang={lang} + /> +
+
+ {renderProductList()} +
+
+ ) : ( + /* ── Standard Sidebar Filter Layout ── */ +
+ {/* Left Column: Filter Sidebar (Desktop) */} +
+ setFilters(initialFilters)} + config={filterConfig} + priceBounds={priceBounds} + stockCounts={stockCounts} + dynamicFacets={dynamicFacets} + lang={lang} + /> +
+ + {/* Right Column: Catalog Grid & Toolbar */} +
+ {renderProductList()} +
+
+ )} {/* Mobile Drawer Filter Modal */} - {isMobileFilterOpen && ( + {shouldShowFilters && isMobileFilterOpen && (
-
+
{/* Left Column: Content */}
diff --git a/apps/website/src/components/category/CategoryFilterHorizontalBar.tsx b/apps/website/src/components/category/CategoryFilterHorizontalBar.tsx new file mode 100644 index 0000000..d191656 --- /dev/null +++ b/apps/website/src/components/category/CategoryFilterHorizontalBar.tsx @@ -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 + 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(null) + const containerRef = useRef(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 ( +
+
+ {/* Left: Filters Label + Facet Buttons */} +
+
+ + Filters +
+ + {/* 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 ( +
+ + + {/* Dropdown Menu */} + {isOpen && ( +
+ {facet.options.length === 0 ? ( +

No options available

+ ) : ( + facet.options.map((opt) => { + const isChecked = selectedVals.includes(opt.value) + return ( + + ) + }) + )} +
+ )} +
+ ) + })} + + {/* Price Range Dropdown */} + {showPrice && ( +
+ + + {activeDropdown === 'price' && ( +
+
+ Max Price + + ฿{filters.priceRange[1].toLocaleString()} + +
+ + 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" + /> +
+ ฿{priceBounds[0].toLocaleString()} + ฿{priceBounds[1].toLocaleString()} +
+
+ )} +
+ )} + + {/* Stock Availability Pills */} + {showStock && ( +
+ + +
+ )} +
+ + {/* Right: Reset All */} + +
+
+ ) +} diff --git a/apps/website/src/lib/cms.ts b/apps/website/src/lib/cms.ts index 5d46b3f..a8ac668 100644 --- a/apps/website/src/lib/cms.ts +++ b/apps/website/src/lib/cms.ts @@ -38,8 +38,9 @@ export type ApiResult = export async function fetchJson(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise { 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(path: string, init?: RequestInit & { next?: { export async function fetchJsonResult(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise> { 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) { diff --git a/apps/website/src/lib/translations.ts b/apps/website/src/lib/translations.ts new file mode 100644 index 0000000..0709180 --- /dev/null +++ b/apps/website/src/lib/translations.ts @@ -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 = { + 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 +} diff --git a/apps/website/src/locales/en/translation.json b/apps/website/src/locales/en/translation.json index d6485b4..6aa3521 100644 --- a/apps/website/src/locales/en/translation.json +++ b/apps/website/src/locales/en/translation.json @@ -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" + } } } diff --git a/apps/website/src/locales/it/translation.json b/apps/website/src/locales/it/translation.json index ad91aa2..8a92570 100644 --- a/apps/website/src/locales/it/translation.json +++ b/apps/website/src/locales/it/translation.json @@ -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" + } } } diff --git a/apps/website/src/locales/ja/translation.json b/apps/website/src/locales/ja/translation.json index a1e7d89..3929243 100644 --- a/apps/website/src/locales/ja/translation.json +++ b/apps/website/src/locales/ja/translation.json @@ -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": "注文検索に戻る" + } } } diff --git a/apps/website/src/locales/ko/translation.json b/apps/website/src/locales/ko/translation.json index 32f4117..c00f35d 100644 --- a/apps/website/src/locales/ko/translation.json +++ b/apps/website/src/locales/ko/translation.json @@ -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": "주문 조회로 돌아가기" + } } } diff --git a/apps/website/src/locales/lo/translation.json b/apps/website/src/locales/lo/translation.json index 00a2f24..9edc3ec 100644 --- a/apps/website/src/locales/lo/translation.json +++ b/apps/website/src/locales/lo/translation.json @@ -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": "ກັບໄປຄົ້ນຫາຄຳສັ່ງຊື້" + } } } diff --git a/apps/website/src/locales/th/translation.json b/apps/website/src/locales/th/translation.json index 9e0de87..d6151cf 100644 --- a/apps/website/src/locales/th/translation.json +++ b/apps/website/src/locales/th/translation.json @@ -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": "กลับไปค้นหาคำสั่งซื้อ" + } } } diff --git a/apps/website/src/locales/vi/translation.json b/apps/website/src/locales/vi/translation.json index 976c438..c890cdc 100644 --- a/apps/website/src/locales/vi/translation.json +++ b/apps/website/src/locales/vi/translation.json @@ -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" + } } } diff --git a/apps/website/src/locales/zh/translation.json b/apps/website/src/locales/zh/translation.json index 4e63297..6176ef9 100644 --- a/apps/website/src/locales/zh/translation.json +++ b/apps/website/src/locales/zh/translation.json @@ -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": "返回订单查询" + } } } diff --git a/packages/types/index.ts b/packages/types/index.ts index a270abd..f00fb33 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -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 { diff --git a/scratch/hooks/beauty-post-receive b/scratch/hooks/beauty-post-receive deleted file mode 100644 index e3dbea4..0000000 --- a/scratch/hooks/beauty-post-receive +++ /dev/null @@ -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)" diff --git a/scratch/hooks/fashion-post-receive b/scratch/hooks/fashion-post-receive deleted file mode 100644 index a83eeba..0000000 --- a/scratch/hooks/fashion-post-receive +++ /dev/null @@ -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)" diff --git a/scratch/hooks/moto-post-receive b/scratch/hooks/moto-post-receive deleted file mode 100644 index 6a0671b..0000000 --- a/scratch/hooks/moto-post-receive +++ /dev/null @@ -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)" diff --git a/scratch/hooks/wellness-post-receive b/scratch/hooks/wellness-post-receive deleted file mode 100644 index 22ed85c..0000000 --- a/scratch/hooks/wellness-post-receive +++ /dev/null @@ -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)"