This commit is contained in:
Your Name committed 2026-10-05 06:09:46 +07:00
1 parent af6047fb4d
commit d9901fd9a6
3 files changed
+811 -3

No files matched your search

@@ -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<string, string>;
description: Record<string, string>;
details: Record<string, string>;
options?: Array<{
name: Record<string, string>;
values: Array<Record<string, string> & { imageUrl?: string }>;
}>;
attributes?: Array<{
definitionId: number;
name: string;
translations: Record<string, string>;
}>;
}
export interface ProductTranslationApplyPayload {
name: Record<string, string>;
description: Record<string, string>;
details: Record<string, string>;
options?: Array<{
name: Record<string, string>;
values: Array<Record<string, string> & { imageUrl?: string }>;
}>;
attributes?: Array<{
definitionId: number;
translations: Record<string, string>;
}>;
}
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<string, string> {
const result: Record<string, string> = {};
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<string, string> {
const base: Record<string, string> =
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<string, string> {
const res: Record<string, string> = {};
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<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
React.useEffect(() => {
if (isOpen) {
setActiveTab(initialTab);
setParseError(null);
setSuccessMsg(null);
}
}, [isOpen, initialTab]);
// Structured export object
const exportData = useMemo(() => {
const payload: Record<string, any> = {
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<string, number> = {};
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden text-slate-200 animate-pop-in">
{/* Header */}
<div className="px-6 py-5 border-b border-white/5 flex items-center justify-between bg-gradient-to-r from-white/[0.04] to-transparent shrink-0">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-2xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow">
<Package className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-white">
จัดการข้อมูลสินค้าหลายภาษา (Product Multi-Language JSON)
</h3>
{productId && (
<span className="px-2 py-0.5 text-[10px] font-mono font-bold bg-white/5 text-slate-400 rounded border border-white/10">
ID: {productId}
</span>
)}
</div>
<p className="text-xs text-slate-400 mt-0.5">
ส่งออกชื่อ, คำบรรยาย, รายละเอียด (Markdown), ตัวเลือกสินค้า ให้ LLM แปล 8 ภาษา แล้ววาง JSON เพื่ออัปเดต
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl hover:bg-white/5 text-slate-400 hover:text-white transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation Tabs */}
<div className="px-6 pt-3 border-b border-white/5 bg-night-950/40 flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('export')}
className={`flex items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider border-b-2 transition-all cursor-pointer ${
activeTab === 'export'
? 'border-cyan-glow text-cyan-glow bg-cyan-glow/5 rounded-t-xl'
: 'border-transparent text-slate-400 hover:text-white hover:bg-white/5 rounded-t-xl'
}`}
>
<Download className="w-3.5 h-3.5" />
<span>1. ส่งออก & พร้อมท์ AI (Export)</span>
</button>
<button
type="button"
onClick={() => setActiveTab('import')}
className={`flex items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider border-b-2 transition-all cursor-pointer ${
activeTab === 'import'
? 'border-pop-pink text-pop-pink bg-pop-pink/5 rounded-t-xl'
: 'border-transparent text-slate-400 hover:text-white hover:bg-white/5 rounded-t-xl'
}`}
>
<Upload className="w-3.5 h-3.5" />
<span>2. วางผลแปล & อัปเดต (Import)</span>
{parsedImport && !parsedImport.error && (
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
)}
</button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{successMsg && (
<div className="p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 flex items-center gap-3 animate-fade-in">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<span className="text-sm font-semibold">{successMsg}</span>
</div>
)}
{activeTab === 'export' ? (
<div className="space-y-4">
{/* Instructions Banner */}
<div className="bg-night-950/60 p-4 rounded-2xl border border-white/5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-bold text-slate-200">
<Sparkles className="w-4 h-4 text-cyan-glow" />
<span>ขั้นตอนการแปลข้อมูลสินค้าด้วย AI:</span>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
1. กดปุ่ม <b className="text-cyan-glow">"คัดลอกพร้อมท์ AI"</b> &rarr; 2. นำไปวางใน ChatGPT / Claude / Gemini &rarr; 3. สลับไปแท็บ <b className="text-pop-pink">"วางผลแปล & อัปเดต"</b> แล้วนำผลลัพธ์มาวาง
</p>
</div>
<div className="flex items-center gap-2 shrink-0 w-full sm:w-auto">
<button
type="button"
onClick={handleCopyPrompt}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-bold transition-all shadow-md cursor-pointer ${
copiedPrompt
? 'bg-emerald-500 text-white shadow-emerald-500/20'
: 'bg-cyan-glow hover:bg-cyan-glow/90 text-night-950 shadow-cyan-glow/20'
}`}
>
{copiedPrompt ? <Check className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
<span>{copiedPrompt ? 'คัดลอกพร้อมท์แล้ว!' : 'คัดลอกพร้อมท์ AI'}</span>
</button>
<button
type="button"
onClick={handleCopyJson}
className="flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-white text-xs font-medium border border-white/10 transition-all cursor-pointer"
>
{copiedJson ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copiedJson ? 'คัดลอก JSON แล้ว' : 'คัดลอก JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadJson}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-all cursor-pointer"
title="ดาวน์โหลดไฟล์ .json"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
{/* Supported Languages Chips */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>ภาษาที่รองรับในระบบ (8 ภาษา):</span>
<span className="font-mono text-[10px] text-cyan-glow">
{productTitle || 'Product Catalog Item'}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{SUPPORTED_LOCALES.map(({ code, label, full }) => (
<span
key={code}
className="px-2.5 py-1 rounded-lg bg-white/5 border border-white/10 text-[11px] font-medium text-slate-300 flex items-center gap-1.5"
>
<span className="font-mono font-bold text-cyan-glow">{label}</span>
<span className="text-slate-500">({full})</span>
</span>
))}
</div>
</div>
{/* JSON Code Viewer */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<FileJson className="w-3.5 h-3.5 text-cyan-glow" />
<span>โครงสร้าง JSON ปัจจุบันของสินค้านี้:</span>
</label>
<span className="text-[10px] text-slate-500 font-mono">
(UTF-8 Encoded)
</span>
</div>
<div className="relative rounded-2xl bg-black/60 border border-white/10 overflow-hidden">
<pre className="p-4 text-xs font-mono text-cyan-200/90 overflow-x-auto max-h-72 leading-relaxed selection:bg-cyan-500/30">
{exportJsonString}
</pre>
</div>
</div>
</div>
) : (
<div className="space-y-4">
{/* Import Instructions */}
<div className="space-y-1">
<label className="text-xs font-bold text-slate-200 flex items-center gap-1.5">
<Upload className="w-4 h-4 text-pop-pink" />
<span>วางโค้ด JSON ที่ได้จาก AI (ChatGPT / Claude / Gemini):</span>
</label>
<p className="text-[11px] text-slate-400">
ระบบรองรับทั้งโค้ด JSON ธรรมดา และบล็อกโค้ดมาร์กดาวน์ (```json ... ```) พร้อมจับคู่ภาษาอัตโนมัติ
</p>
</div>
{/* Paste Textarea */}
<div className="relative">
<textarea
value={importText}
onChange={(e) => {
setImportText(e.target.value);
setParseError(null);
setSuccessMsg(null);
}}
placeholder={`วางโค้ด JSON ข้อมูลสินค้าที่แปลแล้วที่นี่...\n\nตัวอย่าง:\n${exportJsonString.slice(0, 160)}...`}
rows={9}
className="w-full rounded-2xl bg-black/60 border border-white/10 focus:border-pop-pink/50 focus:ring-1 focus:ring-pop-pink/30 p-4 text-xs font-mono text-slate-200 placeholder:text-slate-600 transition-all outline-none resize-none leading-relaxed"
/>
{importText && (
<button
type="button"
onClick={() => {
setImportText('');
setParseError(null);
}}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-slate-400 hover:text-white text-xs transition-all cursor-pointer"
>
ล้างข้อความ
</button>
)}
</div>
{/* Validation Status & Error Alert */}
{parseError && (
<div className="p-3.5 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-300 flex items-center gap-2.5 text-xs animate-shake">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0" />
<span>{parseError}</span>
</div>
)}
{parsedImport && parsedImport.error && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-300 flex items-center gap-2.5 text-xs animate-fade-in">
<AlertCircle className="w-4 h-4 text-amber-400 shrink-0" />
<span>รูปแบบ JSON ไม่ถูกต้อง: {parsedImport.error}</span>
</div>
)}
{/* Live Preview & Stats */}
{parsedImport && !parsedImport.error && (
<div className="space-y-3 p-4 rounded-2xl bg-night-950/60 border border-emerald-500/20 animate-fade-in">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-slate-200">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>สรุปข้อมูลสินค้าที่จะอัปเดตลงฟอร์ม:</span>
</div>
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
ตรวจพบชื่อ, คำบรรยาย, รายละเอียด
</span>
</div>
{/* Languages summary chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{SUPPORTED_LOCALES.map(({ code, label }) => {
const count = parsedImport.languageCounts?.[code] || 0;
return (
<div
key={code}
className={`p-2 rounded-xl border flex items-center justify-between text-xs ${
count > 0
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
: 'bg-white/[0.02] border-white/5 text-slate-500'
}`}
>
<span className="font-mono font-bold">{label}</span>
<span className="text-[10px]">
{count > 0 ? `${count}/3 ฟิลด์หลัก` : 'ไม่มี'}
</span>
</div>
);
})}
</div>
{/* Live Preview Cards */}
<div className="mt-3 border-t border-white/5 pt-3 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400 block mb-1">
ตัวอย่างข้อความที่ตรวจพบ (Preview):
</label>
{/* Name Preview */}
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 text-xs space-y-1">
<span className="text-[10px] uppercase font-bold text-cyan-glow font-mono">
Product Name (ชื่อสินค้า)
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 text-[11px] text-slate-300">
{parsedImport.name && SUPPORTED_LOCALES.filter((l) => parsedImport.name?.[l.code]).map((l) => (
<div key={l.code} className="truncate">
<span className="font-mono font-bold text-slate-500 mr-1.5 uppercase">
[{l.code}]
</span>
<span>{parsedImport.name?.[l.code]}</span>
</div>
))}
</div>
</div>
{/* Description Preview */}
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 text-xs space-y-1">
<span className="text-[10px] uppercase font-bold text-pop-pink font-mono">
Short Description (คำบรรยายย่อ)
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 text-[11px] text-slate-300">
{parsedImport.description && SUPPORTED_LOCALES.filter((l) => parsedImport.description?.[l.code]).map((l) => (
<div key={l.code} className="truncate">
<span className="font-mono font-bold text-slate-500 mr-1.5 uppercase">
[{l.code}]
</span>
<span>{parsedImport.description?.[l.code]}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/60 flex items-center justify-between shrink-0">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl text-xs font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all cursor-pointer"
>
ยกเลิก
</button>
<div className="flex items-center gap-2">
{activeTab === 'export' ? (
<button
type="button"
onClick={() => setActiveTab('import')}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-pop-pink hover:bg-pop-pink/90 text-white font-bold text-xs transition-all shadow-lg shadow-pop-pink/20 cursor-pointer"
>
<span>ไปที่หน้าวางผลแปล (Import)</span>
<ArrowRight className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={handleApplyImport}
disabled={!parsedImport || Boolean(parsedImport.error)}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed text-night-950 font-black text-xs transition-all shadow-lg shadow-emerald-500/20 cursor-pointer"
>
<Check className="w-4 h-4" />
<span>อัปเดตลงฟอร์มทันที (Apply to Form)</span>
</button>
)}
</div>
</div>
</div>
</div>
);
}
+50
View File
@@ -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,6 +762,35 @@ export default function ProductCmsPage() {
{SECTION_HELP[sectionType] || 'Configure the section payload for this product detail page.'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{isTranslatableBlock(sectionType) && (
<>
<button
type="button"
onClick={() => {
setTranslationModalTab('export')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="ส่งออกข้อความทุกภาษาของบล็อกนี้เพื่อนำไปแปลด้วย LLM"
>
<Download className="w-3.5 h-3.5" />
<span>Export ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setTranslationModalTab('import')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="วาง JSON ผลลัพธ์จาก LLM เพื่ออัปเดตทุกภาษาพร้อมกัน"
>
<Upload className="w-3.5 h-3.5" />
<span>Import ภาษา</span>
</button>
</>
)}
{selectedSection?.isLocked && (
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-night-950 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-400">
<Lock className="h-3.5 w-3.5" />
@@ -764,6 +798,7 @@ export default function ProductCmsPage() {
</div>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
@@ -1369,6 +1404,21 @@ export default function ProductCmsPage() {
onClose={() => setIsProductPickerOpen(false)}
/>
)}
<BlockTranslationModal
isOpen={isTranslationModalOpen}
onClose={() => 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))
}}
/>
</div>
)
}
+67 -3
View File
@@ -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<string | null>(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<number>(0)
const [name, setName] = useState<Record<string, string>>({})
@@ -488,9 +493,33 @@ export default function ProductEditPage() {
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setTranslationModalTab('export')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="ส่งออกข้อมูลสินค้าทุกภาษาเพื่อนำไปแปลด้วย AI (Export & Prompt)"
>
<Download className="w-4 h-4" />
<span>Export ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setTranslationModalTab('import')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="วาง JSON ผลลัพธ์จาก AI เพื่ออัปเดตข้อมูลสินค้าทุกภาษาพร้อมกัน"
>
<Upload className="w-4 h-4" />
<span>Import ภาษา</span>
</button>
<button
onClick={() => navigate('/products')}
className="px-6 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all"
className="px-6 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all cursor-pointer"
>
Cancel
</button>
@@ -1332,6 +1361,41 @@ export default function ProductEditPage() {
}}
/>
<ProductTranslationModal
isOpen={isTranslationModalOpen}
onClose={() => 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
})
)
}
}}
/>
</div>
)
}