diff --git a/apps/admin/src/components/products/ProductTranslationModal.tsx b/apps/admin/src/components/products/ProductTranslationModal.tsx
new file mode 100644
index 0000000..2b2ca98
--- /dev/null
+++ b/apps/admin/src/components/products/ProductTranslationModal.tsx
@@ -0,0 +1,694 @@
+import React, { useState, useMemo } from 'react';
+import {
+ X,
+ Copy,
+ Check,
+ Download,
+ Upload,
+ FileJson,
+ AlertCircle,
+ CheckCircle2,
+ Sparkles,
+ Bot,
+ ArrowRight,
+ Package,
+} from 'lucide-react';
+
+export const SUPPORTED_LOCALES = [
+ { code: 'th', label: 'TH', full: 'ภาษาไทย' },
+ { code: 'en', label: 'EN', full: 'English' },
+ { code: 'ja', label: 'JA', full: '日本語' },
+ { code: 'zh', label: 'ZH', full: '中文' },
+ { code: 'vi', label: 'VI', full: 'Tiếng Việt' },
+ { code: 'ko', label: 'KO', full: '한국어' },
+ { code: 'lo', label: 'LO', full: 'ພາສາລາວ' },
+ { code: 'it', label: 'IT', full: 'Italiano' },
+] as const;
+
+export interface ProductTranslationData {
+ name: Record;
+ description: Record;
+ details: Record;
+ options?: Array<{
+ name: Record;
+ values: Array & { imageUrl?: string }>;
+ }>;
+ attributes?: Array<{
+ definitionId: number;
+ name: string;
+ translations: Record;
+ }>;
+}
+
+export interface ProductTranslationApplyPayload {
+ name: Record;
+ description: Record;
+ details: Record;
+ options?: Array<{
+ name: Record;
+ values: Array & { imageUrl?: string }>;
+ }>;
+ attributes?: Array<{
+ definitionId: number;
+ translations: Record;
+ }>;
+}
+
+interface ProductTranslationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ productTitle?: string;
+ productId?: string | number;
+ data: ProductTranslationData;
+ initialTab?: 'export' | 'import';
+ onApplyTranslations: (updated: ProductTranslationApplyPayload) => void;
+}
+
+function getLocalesObject(val: any): Record {
+ const result: Record = {};
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ if (typeof val === 'string') {
+ result[code] = code === 'th' ? val : '';
+ } else if (typeof val === 'object' && val !== null) {
+ result[code] = val[code] || '';
+ } else {
+ result[code] = '';
+ }
+ });
+ return result;
+}
+
+function mergeLocalesObject(existing: any, incoming: any): Record {
+ const base: Record =
+ typeof existing === 'object' && existing !== null
+ ? { ...existing }
+ : typeof existing === 'string'
+ ? { th: existing }
+ : {};
+
+ if (typeof incoming === 'object' && incoming !== null) {
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ if (typeof incoming[code] === 'string' && incoming[code].trim()) {
+ base[code] = incoming[code].trim();
+ }
+ });
+ } else if (typeof incoming === 'string' && incoming.trim()) {
+ base.th = incoming.trim();
+ }
+ return base;
+}
+
+function extractFieldTranslations(obj: any, fieldKey: string): Record {
+ const res: Record = {};
+ if (!obj || typeof obj !== 'object') return res;
+
+ if (typeof obj[fieldKey] === 'object' && obj[fieldKey] !== null) {
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ res[code] = obj[fieldKey][code] || '';
+ });
+ return res;
+ }
+
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ if (obj[code] && typeof obj[code] === 'object' && obj[code][fieldKey]) {
+ res[code] = obj[code][fieldKey] || '';
+ }
+ });
+
+ if (typeof obj[fieldKey] === 'string') {
+ res.th = obj[fieldKey];
+ }
+ return res;
+}
+
+export default function ProductTranslationModal({
+ isOpen,
+ onClose,
+ productTitle,
+ productId,
+ data,
+ initialTab = 'export',
+ onApplyTranslations,
+}: ProductTranslationModalProps) {
+ 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);
+ }
+ }, [isOpen, initialTab]);
+
+ // Structured export object
+ const exportData = useMemo(() => {
+ const payload: Record = {
+ name: getLocalesObject(data.name),
+ description: getLocalesObject(data.description),
+ details: getLocalesObject(data.details),
+ };
+
+ if (Array.isArray(data.options) && data.options.length > 0) {
+ payload.options = data.options.map((opt, idx) => ({
+ option: idx + 1,
+ name: getLocalesObject(opt.name),
+ values: opt.values.map((v, vIdx) => ({
+ valueIndex: vIdx + 1,
+ text: getLocalesObject(v),
+ })),
+ }));
+ }
+
+ if (Array.isArray(data.attributes) && data.attributes.length > 0) {
+ payload.attributes = data.attributes.map((attr) => ({
+ definitionId: attr.definitionId,
+ name: attr.name,
+ translations: getLocalesObject(attr.translations),
+ }));
+ }
+
+ return payload;
+ }, [data]);
+
+ const exportJsonString = useMemo(() => {
+ return JSON.stringify(exportData, null, 2);
+ }, [exportData]);
+
+ const aiPromptString = useMemo(() => {
+ return `คุณคือผู้เชี่ยวชาญด้านการแปลภาษาและเขียนคำบรรยายสินค้า (Copywriting & Localization) สำหรับแบรนด์เครื่องหอมและลักชัวรีไลฟ์สไตล์ (HomAroma)
+กรุณาแปลข้อความของสินค้า "${productTitle || 'Product'}" ตามโครงสร้าง JSON ด้านล่างนี้ให้ครบทุกภาษาที่กำหนด ได้แก่:
+- th: ภาษาไทย
+- en: English
+- ja: ภาษาญี่ปุ่น (日本語)
+- zh: ภาษาจีนตัวย่อ (中文)
+- vi: ภาษาเวียดนาม (Tiếng Việt)
+- ko: ภาษาเกาหลี (한국어)
+- lo: ภาษาลาว (ພາສາລາວ)
+- it: ภาษาอิตาลี (Italiano)
+
+ข้อกำหนดสำคัญ:
+1. รักษาโครงสร้าง JSON และคีย์หลักทั้งหมด (name, description, details, options, attributes)
+2. "name": ชื่อสินค้า แปลให้สละสลวย หรูหรา ไพเราะ
+3. "description": คำบรรยายสรุปสั้น ๆ ของสินค้า ถ่ายทอดอารมณ์และเสน่ห์ของกลิ่นหอม
+4. "details": รายละเอียดสินค้าแบบ Markdown (คงรูปแบบหัวข้อ ข้อย่อย และ Bullet points ไว้)
+5. สไตล์การแปล: หรูหรา สง่างาม ผ่อนคลาย อบอุ่น (Quiet Luxury & Sensory Fragrance Tone)
+6. ตอบกลับมาเฉพาะโค้ด JSON ที่ถูกต้องเท่านั้น (Valid JSON only) ห้ามใส่คำอธิบายเพิ่มเติม
+
+\`\`\`json
+${exportJsonString}
+\`\`\``;
+ }, [productTitle, 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 = `product-${productId || '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 (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ return { error: 'รูปแบบ JSON ต้องเป็น Object' };
+ }
+
+ const name = extractFieldTranslations(parsed, 'name');
+ const description = extractFieldTranslations(parsed, 'description');
+ const details = extractFieldTranslations(parsed, 'details');
+
+ // Parse options if present
+ let optionsResult: any = undefined;
+ if (Array.isArray(parsed.options)) {
+ optionsResult = parsed.options.map((opt: any) => ({
+ name: extractFieldTranslations(opt, 'name'),
+ values: Array.isArray(opt.values)
+ ? opt.values.map((v: any) => extractFieldTranslations(v, 'text'))
+ : [],
+ }));
+ }
+
+ // Parse attributes if present
+ let attributesResult: any = undefined;
+ if (Array.isArray(parsed.attributes)) {
+ attributesResult = parsed.attributes.map((attr: any) => ({
+ definitionId: attr.definitionId,
+ name: attr.name,
+ translations: extractFieldTranslations(attr, 'translations'),
+ }));
+ }
+
+ // Language counts
+ const languageCounts: Record = {};
+ SUPPORTED_LOCALES.forEach(({ code }) => {
+ let count = 0;
+ if (name[code]?.trim()) count++;
+ if (description[code]?.trim()) count++;
+ if (details[code]?.trim()) count++;
+ languageCounts[code] = count;
+ });
+
+ return {
+ name,
+ description,
+ details,
+ options: optionsResult,
+ attributes: attributesResult,
+ languageCounts,
+ };
+ } catch (err: any) {
+ return { error: err.message || 'JSON ไม่ถูกต้อง' };
+ }
+ }, [importText]);
+
+ const handleApplyImport = () => {
+ if (!parsedImport || parsedImport.error) {
+ setParseError('กรุณาวาง JSON ที่ถูกต้องก่อนกดอัปเดต');
+ return;
+ }
+
+ const updatedName = mergeLocalesObject(data.name, parsedImport.name);
+ const updatedDescription = mergeLocalesObject(data.description, parsedImport.description);
+ const updatedDetails = mergeLocalesObject(data.details, parsedImport.details);
+
+ let updatedOptions = data.options;
+ if (parsedImport.options && Array.isArray(data.options)) {
+ updatedOptions = data.options.map((opt, oIdx) => {
+ const transOpt = parsedImport.options[oIdx];
+ if (!transOpt) return opt;
+ const newName = mergeLocalesObject(opt.name, transOpt.name);
+ const newValues = opt.values.map((v, vIdx) => {
+ const transVal = transOpt.values?.[vIdx];
+ const merged = mergeLocalesObject(v, transVal);
+ return {
+ ...merged,
+ ...(v.imageUrl ? { imageUrl: v.imageUrl } : {}),
+ };
+ });
+ return { name: newName, values: newValues };
+ });
+ }
+
+ let updatedAttributes = data.attributes?.map((attr) => ({
+ definitionId: attr.definitionId,
+ translations: attr.translations,
+ }));
+ if (parsedImport.attributes && Array.isArray(data.attributes)) {
+ updatedAttributes = data.attributes.map((attr) => {
+ const transAttr = parsedImport.attributes.find((a: any) => a.definitionId === attr.definitionId);
+ if (!transAttr) return { definitionId: attr.definitionId, translations: attr.translations };
+ return {
+ definitionId: attr.definitionId,
+ translations: mergeLocalesObject(attr.translations, transAttr.translations),
+ };
+ });
+ }
+
+ onApplyTranslations({
+ name: updatedName,
+ description: updatedDescription,
+ details: updatedDetails,
+ options: updatedOptions,
+ attributes: updatedAttributes,
+ });
+
+ setSuccessMsg('อัปเดตข้อมูลสินค้าทุกภาษาลงในฟอร์มสำเร็จแล้ว!');
+ setTimeout(() => {
+ onClose();
+ }, 1200);
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ จัดการข้อมูลสินค้าหลายภาษา (Product Multi-Language JSON)
+
+ {productId && (
+
+ ID: {productId}
+
+ )}
+
+
+ ส่งออกชื่อ, คำบรรยาย, รายละเอียด (Markdown), ตัวเลือกสินค้า ให้ LLM แปล 8 ภาษา แล้ววาง JSON เพื่ออัปเดต
+
+
+
+
+
+
+ {/* Navigation Tabs */}
+
+
+
+
+
+
+ {/* Modal Body */}
+
+ {successMsg && (
+
+
+ {successMsg}
+
+ )}
+
+ {activeTab === 'export' ? (
+
+ {/* Instructions Banner */}
+
+
+
+
+ ขั้นตอนการแปลข้อมูลสินค้าด้วย AI:
+
+
+ 1. กดปุ่ม "คัดลอกพร้อมท์ AI" → 2. นำไปวางใน ChatGPT / Claude / Gemini → 3. สลับไปแท็บ "วางผลแปล & อัปเดต" แล้วนำผลลัพธ์มาวาง
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Supported Languages Chips */}
+
+
+ ภาษาที่รองรับในระบบ (8 ภาษา):
+
+ {productTitle || 'Product Catalog Item'}
+
+
+
+ {SUPPORTED_LOCALES.map(({ code, label, full }) => (
+
+ {label}
+ ({full})
+
+ ))}
+
+
+
+ {/* JSON Code Viewer */}
+
+
+
+
+ (UTF-8 Encoded)
+
+
+
+
+ {exportJsonString}
+
+
+
+
+ ) : (
+
+ {/* Import Instructions */}
+
+
+
+ ระบบรองรับทั้งโค้ด JSON ธรรมดา และบล็อกโค้ดมาร์กดาวน์ (```json ... ```) พร้อมจับคู่ภาษาอัตโนมัติ
+
+
+
+ {/* Paste Textarea */}
+
+
+
+ {/* Validation Status & Error Alert */}
+ {parseError && (
+
+ )}
+
+ {parsedImport && parsedImport.error && (
+
+
+
รูปแบบ JSON ไม่ถูกต้อง: {parsedImport.error}
+
+ )}
+
+ {/* Live Preview & Stats */}
+ {parsedImport && !parsedImport.error && (
+
+
+
+
+ สรุปข้อมูลสินค้าที่จะอัปเดตลงฟอร์ม:
+
+
+ ตรวจพบชื่อ, คำบรรยาย, รายละเอียด
+
+
+
+ {/* Languages summary chips */}
+
+ {SUPPORTED_LOCALES.map(({ code, label }) => {
+ const count = parsedImport.languageCounts?.[code] || 0;
+ return (
+
0
+ ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
+ : 'bg-white/[0.02] border-white/5 text-slate-500'
+ }`}
+ >
+ {label}
+
+ {count > 0 ? `${count}/3 ฟิลด์หลัก` : 'ไม่มี'}
+
+
+ );
+ })}
+
+
+ {/* Live Preview Cards */}
+
+
+
+ {/* Name Preview */}
+
+
+ Product Name (ชื่อสินค้า)
+
+
+ {parsedImport.name && SUPPORTED_LOCALES.filter((l) => parsedImport.name?.[l.code]).map((l) => (
+
+
+ [{l.code}]
+
+ {parsedImport.name?.[l.code]}
+
+ ))}
+
+
+
+ {/* Description Preview */}
+
+
+ Short Description (คำบรรยายย่อ)
+
+
+ {parsedImport.description && SUPPORTED_LOCALES.filter((l) => parsedImport.description?.[l.code]).map((l) => (
+
+
+ [{l.code}]
+
+ {parsedImport.description?.[l.code]}
+
+ ))}
+
+
+
+
+ )}
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+ {activeTab === 'export' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/pages/ProductCmsPage.tsx b/apps/admin/src/pages/ProductCmsPage.tsx
index 0f06cb4..51b6778 100644
--- a/apps/admin/src/pages/ProductCmsPage.tsx
+++ b/apps/admin/src/pages/ProductCmsPage.tsx
@@ -6,6 +6,7 @@ import {
ArrowLeft,
ArrowUp,
CheckCircle2,
+ Download,
Edit3,
Eye,
EyeOff,
@@ -25,6 +26,7 @@ import {
Sparkles,
Star,
Trash2,
+ Upload,
UploadCloud,
Image as ImageIcon,
} from 'lucide-react'
@@ -36,6 +38,7 @@ import ProductHeroCarouselBlockForm from '../components/products/ProductHeroCaro
import ProductDividerBlockForm from '../components/products/ProductDividerBlockForm'
import { validateProductHeroCarouselPayload } from '../components/products/productHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
+import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
interface Brand {
id: number
@@ -246,6 +249,8 @@ export default function ProductCmsPage() {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const [isProductPickerOpen, setIsProductPickerOpen] = useState(false)
+ const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
+ const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const selectedSection = selectedSectionId === 'new'
? null
@@ -757,12 +762,42 @@ export default function ProductCmsPage() {
{SECTION_HELP[sectionType] || 'Configure the section payload for this product detail page.'}
- {selectedSection?.isLocked && (
-
-
- Locked
-
- )}
+
+ {isTranslatableBlock(sectionType) && (
+ <>
+
+
+ >
+ )}
+ {selectedSection?.isLocked && (
+
+
+ Locked
+
+ )}
+
@@ -1369,6 +1404,21 @@ export default function ProductCmsPage() {
onClose={() => setIsProductPickerOpen(false)}
/>
)}
+
+ 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/ProductEditPage.tsx b/apps/admin/src/pages/ProductEditPage.tsx
index b9532cd..72b5fa7 100644
--- a/apps/admin/src/pages/ProductEditPage.tsx
+++ b/apps/admin/src/pages/ProductEditPage.tsx
@@ -19,12 +19,15 @@ import {
ArrowDown,
Monitor,
Smartphone,
- RotateCcw
+ RotateCcw,
+ Download,
+ Upload
} from 'lucide-react'
import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
import MDEditor from '@uiw/react-md-editor'
import MediaLibraryModal from '../components/MediaLibraryModal'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
+import ProductTranslationModal from '../components/products/ProductTranslationModal'
type EditableAttribute = {
id?: number
@@ -117,8 +120,10 @@ export default function ProductEditPage() {
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
- // Form State - 4 Core Languages
+ // Form State - Languages & Translations
const [activeLang, setActiveLang] = useState('th')
+ const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
+ const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
const [categoryId, setCategoryId] = useState(0)
const [name, setName] = useState>({})
@@ -488,9 +493,33 @@ export default function ProductEditPage() {
+
+
@@ -1332,6 +1361,41 @@ export default function ProductEditPage() {
}}
/>
+
setIsTranslationModalOpen(false)}
+ productTitle={name[activeLang] || name.th || name.en || product?.brand?.name}
+ productId={id}
+ initialTab={translationModalTab}
+ data={{
+ name,
+ description,
+ details,
+ options,
+ attributes: attributes.map((attr) => ({
+ definitionId: attr.attributeDefinitionId,
+ name: typeof attr.attributeDefinition?.label === 'string'
+ ? attr.attributeDefinition.label
+ : (attr.attributeDefinition?.code || 'Attribute'),
+ translations: attr.translations || {},
+ })),
+ }}
+ onApplyTranslations={(updated) => {
+ if (updated.name) setName(updated.name)
+ if (updated.description) setDescription(updated.description)
+ if (updated.details) setDetails(updated.details)
+ if (updated.options) setOptions(updated.options)
+ if (updated.attributes) {
+ setAttributes((prev) =>
+ prev.map((attr) => {
+ const matched = updated.attributes?.find((a) => a.definitionId === attr.attributeDefinitionId)
+ return matched ? { ...attr, translations: matched.translations } : attr
+ })
+ )
+ }
+ }}
+ />
+
)
}