diff --git a/apps/admin/src/components/category/CategoryTranslationModal.tsx b/apps/admin/src/components/category/CategoryTranslationModal.tsx
new file mode 100644
index 0000000..1070a8a
--- /dev/null
+++ b/apps/admin/src/components/category/CategoryTranslationModal.tsx
@@ -0,0 +1,534 @@
+import React, { useState, useMemo } from 'react';
+import { createPortal } from 'react-dom';
+import {
+ X,
+ Copy,
+ Check,
+ Download,
+ Upload,
+ FileJson,
+ AlertCircle,
+ CheckCircle2,
+ Sparkles,
+ Bot,
+ ArrowRight,
+ FolderTree,
+} from 'lucide-react';
+
+export const SUPPORTED_LOCALES = [
+ { code: 'th', label: 'TH', full: 'ภาษาไทย', flag: '🇹🇭' },
+ { code: 'en', label: 'EN', full: 'English', flag: '🇬🇧' },
+ { code: 'ja', label: 'JA', full: '日本語', flag: '🇯🇵' },
+ { code: 'zh', label: 'ZH', full: '中文', flag: '🇨🇳' },
+ { code: 'vi', label: 'VI', full: 'Tiếng Việt', flag: '🇻🇳' },
+ { code: 'ko', label: 'KO', full: '한국어', flag: '🇰🇷' },
+ { code: 'lo', label: 'LO', full: 'ພາສາລາວ', flag: '🇱🇦' },
+ { code: 'it', label: 'IT', full: 'Italiano', flag: '🇮🇹' },
+] as const;
+
+export interface CategoryTranslationData {
+ name: Record;
+}
+
+interface CategoryTranslationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ categoryName?: string;
+ categoryId?: string | number;
+ data: CategoryTranslationData;
+ initialTab?: 'export' | 'import';
+ onApplyTranslations: (updated: { name: Record }) => void;
+}
+
+function getLocalesObject(val: unknown): Record {
+ const result: Record = {};
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ if (typeof val === 'string') {
+ result[code] = code === 'th' ? val : '';
+ } else if (typeof val === 'object' && val !== null) {
+ const record = val as Record;
+ result[code] = typeof record[code] === 'string' ? record[code] : '';
+ } else {
+ result[code] = '';
+ }
+ });
+ return result;
+}
+
+function extractFieldTranslations(obj: unknown, fieldKey: string): Record {
+ const res: Record = {};
+ if (!obj || typeof obj !== 'object') return res;
+
+ const record = obj as Record;
+
+ // Format A: { [fieldKey]: { th: "...", en: "..." } }
+ if (typeof record[fieldKey] === 'object' && record[fieldKey] !== null) {
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ res[code] = typeof record[fieldKey][code] === 'string' ? record[fieldKey][code].trim() : '';
+ });
+ return res;
+ }
+
+ // Format B: { th: { [fieldKey]: "..." }, en: { [fieldKey]: "..." } }
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ if (record[code] && typeof record[code] === 'object' && record[code][fieldKey]) {
+ res[code] = typeof record[code][fieldKey] === 'string' ? record[code][fieldKey].trim() : '';
+ }
+ });
+
+ // Flat Format: { th: "...", en: "..." }
+ if (Object.keys(res).length === 0 || !Object.values(res).some(Boolean)) {
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ if (typeof record[code] === 'string') {
+ res[code] = record[code].trim();
+ }
+ });
+ }
+
+ return res;
+}
+
+export default function CategoryTranslationModal({
+ isOpen,
+ onClose,
+ categoryName,
+ categoryId,
+ data,
+ initialTab = 'export',
+ onApplyTranslations,
+}: CategoryTranslationModalProps) {
+ const [activeTab, setActiveTab] = useState<'export' | 'import'>(initialTab);
+ const [copiedJson, setCopiedJson] = useState(false);
+ const [copiedPrompt, setCopiedPrompt] = useState(false);
+ const [importText, setImportText] = useState('');
+ const [parseError, setParseError] = useState(null);
+ const [successMsg, setSuccessMsg] = useState(null);
+
+ React.useEffect(() => {
+ if (isOpen) {
+ setActiveTab(initialTab);
+ setParseError(null);
+ setSuccessMsg(null);
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.body.style.overflow = prevOverflow;
+ };
+ }
+ }, [isOpen, initialTab]);
+
+ // Structured export object
+ const exportData = useMemo(() => {
+ return {
+ name: getLocalesObject(data.name),
+ };
+ }, [data]);
+
+ const exportJsonString = useMemo(() => {
+ return JSON.stringify(exportData, null, 2);
+ }, [exportData]);
+
+ // AI Prompt tailored for category naming & sensory fragrance branding
+ const aiPromptString = useMemo(() => {
+ const title = categoryName || data.name?.th || data.name?.en || 'หมวดหมู่สินค้า';
+ return `คุณคือ AI ผู้เชี่ยวชาญด้าน E-Commerce Localization, Luxury Fragrance & Home Aroma Brand Strategist (HomAroma Platform)
+
+ภารกิจ: แปลชื่อหมวดหมู่สินค้า (Category / Collection Name): "${title}" เป็น 8 ภาษาให้ครบถ้วน สละสลวย ไพเราะ และกระตุ้นความรู้สึกสัมผัสกลิ่นหอมและบรรยากาศ (Sensory & Ambience Appeal):
+- th: ภาษาไทย
+- en: ภาษาอังกฤษ (English)
+- ja: ภาษาญี่ปุ่น (日本語)
+- zh: ภาษาจีน (中文)
+- vi: ภาษาเวียดนาม (Tiếng Việt)
+- ko: ภาษาเกาหลี (한국어)
+- lo: ภาษาลาว (ພາສາລາວ)
+- it: ภาษาอิตาลี (Italiano)
+
+ข้อกำหนดสำคัญ:
+1. รักษาโครงสร้าง JSON และคีย์ "name" ให้ถูกต้องสมบูรณ์
+2. "name": ชื่อหมวดหมู่สินค้า แปลให้สละสลวย เป็นทางการ และกระตุ้นความรู้สึกผ่อนคลาย สง่างาม น่าค้นหา
+3. สไตล์การแปล: หรูหรา สง่างาม อบอุ่น (Quiet Luxury, High-End Spa & Artisanal Perfumery)
+4. ตอบกลับมาเฉพาะโค้ด JSON ที่ถูกต้องเท่านั้น (Valid JSON only) ห้ามใส่คำอธิบายเพิ่มเติม
+
+\`\`\`json
+${exportJsonString}
+\`\`\``;
+ }, [categoryName, data.name, exportJsonString]);
+
+ const handleCopyJson = async () => {
+ try {
+ await navigator.clipboard.writeText(exportJsonString);
+ setCopiedJson(true);
+ setTimeout(() => setCopiedJson(false), 2000);
+ } catch {
+ // fallback
+ }
+ };
+
+ const handleCopyPrompt = async () => {
+ try {
+ await navigator.clipboard.writeText(aiPromptString);
+ setCopiedPrompt(true);
+ setTimeout(() => setCopiedPrompt(false), 2000);
+ } catch {
+ // fallback
+ }
+ };
+
+ const handleDownloadJson = () => {
+ const blob = new Blob([exportJsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `category-${categoryId || 'item'}-translations.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ // Parser & validator for pasted import JSON
+ const parsedImport = useMemo(() => {
+ if (!importText.trim()) return null;
+
+ let clean = importText.trim();
+ if (clean.startsWith('```json')) clean = clean.slice(7);
+ else if (clean.startsWith('```')) clean = clean.slice(3);
+ if (clean.endsWith('```')) clean = clean.slice(0, -3);
+ clean = clean.trim();
+
+ try {
+ const parsed = JSON.parse(clean);
+ if (!parsed || typeof parsed !== 'object') {
+ return { error: 'รูปแบบ JSON ไม่ถูกต้อง (ต้องเป็น JSON Object)' };
+ }
+
+ const nameMap = extractFieldTranslations(parsed, 'name');
+
+ const languageCounts: Record = {};
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ languageCounts[code] = Boolean(nameMap[code]?.trim());
+ });
+
+ const filledCount = Object.values(languageCounts).filter(Boolean).length;
+
+ return {
+ name: nameMap,
+ languageCounts,
+ filledCount,
+ statsText: `พบชื่อหมวดหมู่ ${filledCount} / 8 ภาษา`,
+ };
+ } catch (err: any) {
+ return { error: `JSON Parse Error: ${err.message}` };
+ }
+ }, [importText]);
+
+ const handleApplyImport = () => {
+ if (!parsedImport || parsedImport.error || !parsedImport.name) return;
+
+ onApplyTranslations({
+ name: parsedImport.name,
+ });
+
+ setSuccessMsg('อัปเดตชื่อหมวดหมู่ทุกภาษาลงในฟอร์มสำเร็จแล้ว!');
+ setTimeout(() => {
+ onClose();
+ }, 1200);
+ };
+
+ if (!isOpen) return null;
+
+ return createPortal(
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ จัดการชื่อหมวดหมู่หลายภาษา (Category Multi-Language JSON)
+
+ {categoryId && (
+
+ ID: {categoryId}
+
+ )}
+
+
+ ส่งออกชื่อหมวดหมู่สินค้าให้ LLM แปล 8 ภาษา แล้วนำ JSON มาวางเพื่ออัปเดตทุกภาษาพร้อมกัน
+
+
+
+
+
+
+ {/* Navigation Tabs */}
+
+
+
+
+
+
+ {/* Modal Body */}
+
+ {/* Error Message */}
+ {parseError && (
+
+ )}
+
+ {/* Success Notification */}
+ {successMsg && (
+
+
+ {successMsg}
+
+ )}
+
+ {activeTab === 'export' ? (
+
+ {/* Instructions Banner */}
+
+
+
+
+ ขั้นตอนการแปลชื่อหมวดหมู่ด้วย AI:
+
+
+ 1. กดปุ่ม "คัดลอกพร้อมท์ AI" → 2. นำไปวางใน ChatGPT / Claude / Gemini → 3. สลับไปแท็บ "วางผลแปล & อัปเดต" แล้วนำผลลัพธ์มาวาง
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Supported Languages Status Badges */}
+
+
+ สถานะภาษาในหมวดหมู่นี้ (8 ภาษา):
+
+
+ {SUPPORTED_LOCALES.map(({ code, label, full, flag }) => {
+ const hasVal = Boolean(data.name?.[code]?.trim());
+ return (
+
+ {flag}
+ {label}
+ ({code})
+ {hasVal ? : -}
+
+ );
+ })}
+
+
+
+ {/* JSON Display Area */}
+
+
+
+
+ โครงสร้าง JSON ข้อมูลชื่อหมวดหมู่ปัจจุบัน:
+
+ UTF-8 Encoded
+
+
+ {exportJsonString}
+
+
+
+ ) : (
+
+ {/* Import Textarea */}
+
+
+
+
+ {/* Parsing Feedback & Validation */}
+ {parsedImport?.error ? (
+
+
+
{parsedImport.error}
+
+ ) : parsedImport ? (
+
+
+
+
+ {parsedImport.statsText}
+
+
พร้อมอัปเดตลงฟอร์ม
+
+
+ {/* 8-Language Live Preview Grid */}
+
+
+ ตัวอย่างชื่อหมวดหมู่ที่จะนำเข้า:
+
+
+ {SUPPORTED_LOCALES.map(({ code, label, full, flag }) => {
+ const val = parsedImport.name?.[code] || '';
+ return (
+
+
+
+ {flag}
+ {label}
+
+
{full}
+
+
+ {val || ไม่มีข้อมูล}
+
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+ {activeTab === 'export' ? (
+
+ ) : (
+
+ )}
+
+
+
+
,
+ document.body
+ );
+}
diff --git a/apps/admin/src/components/page-builder/BlockTranslationModal.tsx b/apps/admin/src/components/page-builder/BlockTranslationModal.tsx
index bf1a676..9f8c3c3 100644
--- a/apps/admin/src/components/page-builder/BlockTranslationModal.tsx
+++ b/apps/admin/src/components/page-builder/BlockTranslationModal.tsx
@@ -157,6 +157,10 @@ export const BLOCK_TRANSLATABLE_FIELDS: Record =
{ key: 'title', label: 'Section Title' },
{ key: 'subtitle', label: 'Section Subtitle' },
],
+ CATEGORY_HEADER: [
+ { key: 'title', label: 'Category Title / Heading' },
+ { key: 'subtitle', label: 'Category Subtitle / Description', multiline: true },
+ ],
PRODUCT_CAROUSEL: [
{ key: 'title', label: 'Carousel Title' },
{ key: 'description', label: 'Carousel Subtitle / Description', multiline: true },
diff --git a/apps/admin/src/pages/CategoryCmsPage.tsx b/apps/admin/src/pages/CategoryCmsPage.tsx
index f9db15e..da1a233 100644
--- a/apps/admin/src/pages/CategoryCmsPage.tsx
+++ b/apps/admin/src/pages/CategoryCmsPage.tsx
@@ -6,6 +6,7 @@ import {
ArrowLeft,
ArrowUp,
CheckCircle2,
+ Download,
Edit3,
Eye,
EyeOff,
@@ -23,6 +24,7 @@ import {
Shield,
Sparkles,
Trash2,
+ Upload,
} from 'lucide-react'
import { API_BASE, type Category, type CategorySection } from '@homaroma/types'
import MediaLibraryModal from '../components/MediaLibraryModal'
@@ -30,6 +32,7 @@ import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCa
import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm'
import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
+import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
interface ApiValidationIssue {
path?: string
@@ -167,6 +170,8 @@ export default function CategoryCmsPage() {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false)
+ const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
+ const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
@@ -671,12 +676,42 @@ export default function CategoryCmsPage() {
{SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'}
- {selectedSection?.isLocked && (
-
-
- Locked
-
- )}
+
+ {isTranslatableBlock(sectionType) && (
+ <>
+
+
+ >
+ )}
+ {selectedSection?.isLocked && (
+
+
+ Locked
+
+ )}
+
@@ -990,6 +1025,22 @@ export default function CategoryCmsPage() {
)}
+
+ {/* AI Multi-Language Translation Modal */}
+ setIsTranslationModalOpen(false)}
+ section={{
+ id: typeof selectedSectionId === 'number' ? selectedSectionId : undefined,
+ type: sectionType,
+ payload: sectionPayload,
+ }}
+ initialTab={translationModalTab}
+ onApplyTranslations={(updatedPayload) => {
+ setSectionPayload(updatedPayload)
+ setSectionJson(JSON.stringify(updatedPayload, null, 2))
+ }}
+ />
)
}
diff --git a/apps/admin/src/pages/CategoryEditPage.tsx b/apps/admin/src/pages/CategoryEditPage.tsx
index 2628ced..2a111b7 100644
--- a/apps/admin/src/pages/CategoryEditPage.tsx
+++ b/apps/admin/src/pages/CategoryEditPage.tsx
@@ -18,17 +18,19 @@ import {
LayoutDashboard,
CheckCircle2,
AlertCircle,
- FileJson,
Check,
Code2,
+ Download,
Sparkles,
LayoutTemplate,
Columns,
Grid3X3,
- EyeOff
+ EyeOff,
+ Upload,
} from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryMediaEditor from '../components/CategoryMediaEditor'
+import CategoryTranslationModal from '../components/category/CategoryTranslationModal'
const ADDITIONAL_LANGS = [
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
@@ -62,6 +64,9 @@ export default function CategoryEditPage() {
const [showMoreLangs, setShowMoreLangs] = useState(false)
const [successToast, setSuccessToast] = useState(null)
+ const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
+ const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
+
// JSON Quick Import State
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
const [jsonInput, setJsonInput] = useState('')
@@ -473,6 +478,32 @@ export default function CategoryEditPage() {
)}
+ {/* AI Multi-Language Translation Buttons */}
+
+
+
- {/* JSON Paste Action Button */}
-
+ {/* Multilingual AI Translation Buttons */}
+
+
+
+
+
{/* Expandable JSON Input Box */}
@@ -1038,6 +1095,22 @@ export default function CategoryEditPage() {
setIsMediaModalOpen(false)
}}
/>
+
+ {/* AI Multi-Language Translation Modal */}
+ setIsTranslationModalOpen(false)}
+ categoryName={name.th || name.en}
+ categoryId={id}
+ data={{ name }}
+ initialTab={translationModalTab}
+ onApplyTranslations={({ name: updatedName }) => {
+ setName((prev) => ({ ...prev, ...updatedName }))
+ setShowMoreLangs(true)
+ setSuccessToast('อัปเดตชื่อหมวดหมู่ทุกภาษาเรียบร้อยแล้ว')
+ setTimeout(() => setSuccessToast(null), 3000)
+ }}
+ />
)
}
diff --git a/apps/website/src/app/globals.css b/apps/website/src/app/globals.css
index f6018a0..91ee3bf 100644
--- a/apps/website/src/app/globals.css
+++ b/apps/website/src/app/globals.css
@@ -61,6 +61,8 @@
@layer base {
:root,
html {
+ --navbar-height: 72px;
+ --navbar-bottom: 72px;
--font-body-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-serif-locale: ui-serif;
diff --git a/apps/website/src/components/Navbar.tsx b/apps/website/src/components/Navbar.tsx
index 6644c2d..acf8712 100644
--- a/apps/website/src/components/Navbar.tsx
+++ b/apps/website/src/components/Navbar.tsx
@@ -101,16 +101,28 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
}, [])
useEffect(() => {
+ let accumulatedDelta = 0
+
const onScroll = () => {
const currentScrollY = window.scrollY
const delta = currentScrollY - previousScrollY.current
if (currentScrollY <= 20) {
setScrollingDown(false)
- } else if (delta > 4) {
- setScrollingDown(true)
- } else if (delta < -4) {
- setScrollingDown(false)
+ accumulatedDelta = 0
+ } else {
+ // Reset accumulator if user reverses scroll direction
+ if ((delta > 0 && accumulatedDelta < 0) || (delta < 0 && accumulatedDelta > 0)) {
+ accumulatedDelta = 0
+ }
+ accumulatedDelta += delta
+
+ // Require 10px of deliberate scroll movement to prevent micro-jitter bounce
+ if (accumulatedDelta > 10) {
+ setScrollingDown(true)
+ } else if (accumulatedDelta < -10) {
+ setScrollingDown(false)
+ }
}
previousScrollY.current = currentScrollY
@@ -127,8 +139,13 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
const updateNavBottom = () => {
if (navElement) {
- const bottom = navElement.getBoundingClientRect().bottom
+ const rect = navElement.getBoundingClientRect()
+ const bottom = Math.max(0, rect.bottom)
+ const height = navElement.offsetHeight || rect.height || 72
document.documentElement.style.setProperty('--navbar-bottom', `${bottom}px`)
+ if (height > 0) {
+ document.documentElement.style.setProperty('--navbar-height', `${height}px`)
+ }
}
}
diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
index 5f5d960..8cc5671 100644
--- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
+++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
@@ -2525,7 +2525,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
if (needsTopSpacing && rendered) {
return (
-