From d9901fd9a6994464ae3e5125fead19f9a0fa46c9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 5 Oct 2026 06:09:46 +0700 Subject: [PATCH] x --- .../products/ProductTranslationModal.tsx | 694 ++++++++++++++++++ apps/admin/src/pages/ProductCmsPage.tsx | 62 +- apps/admin/src/pages/ProductEditPage.tsx | 70 +- 3 files changed, 817 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/components/products/ProductTranslationModal.tsx 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 */} +
+