diff --git a/apps/admin/src/components/ProductAttributeBulkJsonModal.tsx b/apps/admin/src/components/ProductAttributeBulkJsonModal.tsx new file mode 100644 index 0000000..11173ee --- /dev/null +++ b/apps/admin/src/components/ProductAttributeBulkJsonModal.tsx @@ -0,0 +1,785 @@ +import React, { useState, useMemo, useRef } from 'react'; +import { + X, + Copy, + Check, + Download, + Upload, + FileJson, + AlertCircle, + CheckCircle2, + FileText, + ArrowRight, + Bot, + Loader2, + Sliders, + Filter +} from 'lucide-react'; +import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'; + +interface ProductAttributeBulkJsonModalProps { + isOpen: boolean; + onClose: () => void; + definitions: AttributeDefinition[]; + categories: Category[]; + onSuccess: () => void; +} + +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' }, +]; + +const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const; + +export default function ProductAttributeBulkJsonModal({ + isOpen, + onClose, + definitions, + categories, + onSuccess, +}: ProductAttributeBulkJsonModalProps) { + const [activeTab, setActiveTab] = useState<'import' | 'export' | 'ai'>('import'); + const [copiedJson, setCopiedJson] = useState(false); + const [copiedPrompt, setCopiedPrompt] = useState(false); + const [importJsonText, setImportJsonText] = useState(''); + const [parseError, setParseError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [resultMessage, setResultMessage] = useState(null); + const fileInputRef = useRef(null); + + const categoryNameMap = useMemo(() => { + const map = new Map(); + categories.forEach((cat) => { + const name = typeof cat.name === 'string' + ? cat.name + : ((cat.name as any)?.th || (cat.name as any)?.en || `Category #${cat.id}`); + map.set(cat.id, name); + }); + return map; + }, [categories]); + + // 1. Export Data Generation + const exportData = useMemo(() => { + return definitions.map((def) => { + const transMap: Record = {}; + const defLabelObj = typeof def.label === 'object' && def.label !== null ? (def.label as any) : null; + const defDescObj = typeof def.description === 'object' && def.description !== null ? (def.description as any) : null; + const baseLabel = typeof def.label === 'string' ? def.label : (defLabelObj?.th || def.code); + + for (const loc of SUPPORTED_LOCALES) { + const labelVal = defLabelObj?.[loc.code] || (Array.isArray(def.translations) ? def.translations.find((t: any) => t.langCode === loc.code)?.label : ''); + const descVal = defDescObj?.[loc.code] || (Array.isArray(def.translations) ? def.translations.find((t: any) => t.langCode === loc.code)?.description : ''); + + if (labelVal || descVal) { + transMap[loc.code] = { + label: labelVal || baseLabel, + description: descVal || undefined, + }; + } + } + + if (!transMap['th'] && baseLabel) { + transMap['th'] = { label: baseLabel }; + } + + return { + id: def.id, + code: def.code, + type: def.type, + unit: def.unit || null, + isRequired: Boolean(def.isRequired), + isFilterable: Boolean(def.isFilterable), + sortOrder: def.sortOrder ?? 0, + categoryId: def.categoryId ?? null, + label: baseLabel, + description: typeof def.description === 'string' ? def.description : (defDescObj?.th || null), + options: def.options || undefined, + translations: transMap, + }; + }); + }, [definitions]); + + const exportJsonString = useMemo(() => { + return JSON.stringify(exportData, null, 2); + }, [exportData]); + + // Sample Data for Load Sample Button + const sampleImportData = useMemo(() => [ + { + code: 'motorcycle_model', + type: 'SELECT', + isFilterable: true, + isRequired: false, + sortOrder: 1, + categoryId: null, + label: 'รุ่นรถมอเตอร์ไซค์', + description: 'ระบุรุ่นรถที่สามารถติดตั้งได้', + options: [ + { th: 'CBR 650R', en: 'CBR 650R' }, + { th: 'MT-09', en: 'MT-09' }, + { th: 'Ninja ZX-6R', en: 'Ninja ZX-6R' }, + { th: 'XMAX 300', en: 'XMAX 300' }, + { th: 'Forza 350', en: 'Forza 350' }, + { th: 'S1000RR', en: 'S1000RR' } + ], + translations: { + th: { label: 'รุ่นรถมอเตอร์ไซค์', description: 'ระบุรุ่นรถที่สามารถติดตั้งได้' }, + en: { label: 'Motorcycle Model', description: 'Compatible motorcycle models' }, + ja: { label: '適合車種', description: '対応するバイクモデル' }, + zh: { label: '适用车型', description: '适配摩托车型号' } + } + }, + { + code: 'suspension_series', + type: 'SELECT', + isFilterable: true, + isRequired: false, + sortOrder: 2, + categoryId: null, + label: 'ซีรีส์โช้คอัพ', + description: 'ประเภทและรุ่นซีรีส์ของโช้คอัพ', + options: [ + { th: 'G-Series', en: 'G-Series' }, + { th: 'Top Line', en: 'Top Line' }, + { th: 'Racing Edition', en: 'Racing Edition' }, + { th: 'Ecoline', en: 'Ecoline' } + ], + translations: { + th: { label: 'ซีรีส์โช้คอัพ', description: 'ประเภทและรุ่นซีรีส์ของโช้คอัพ' }, + en: { label: 'Suspension Series', description: 'Shock absorber product series' } + } + }, + { + code: 'material', + type: 'TEXT', + isFilterable: true, + isRequired: false, + sortOrder: 3, + categoryId: null, + label: 'วัสดุหลัก', + description: 'วัสดุของชิ้นงานหรือส่วนประกอบ', + translations: { + th: { label: 'วัสดุหลัก', description: 'วัสดุของชิ้นงานหรือส่วนประกอบ' }, + en: { label: 'Material', description: 'Primary component material' }, + ja: { label: '素材', description: '主要な構成素材' }, + zh: { label: '材质', description: '主要构成材质' } + } + }, + { + code: 'piston_diameter', + type: 'NUMBER', + unit: 'mm', + isFilterable: false, + isRequired: false, + sortOrder: 4, + categoryId: null, + label: 'ขนาดลูกสูบ (Piston)', + description: 'เส้นผ่านศูนย์กลางลูกสูบภายในโช้คอัพ', + translations: { + th: { label: 'ขนาดลูกสูบ (Piston)', description: 'เส้นผ่านศูนย์กลางลูกสูบภายในโช้คอัพ' }, + en: { label: 'Piston Diameter', description: 'Internal piston diameter in millimeters' } + } + }, + { + code: 'is_adjustable', + type: 'BOOLEAN', + isFilterable: true, + isRequired: false, + sortOrder: 5, + categoryId: null, + label: 'ปรับตั้งค่าได้ (Adjustable)', + description: 'สามารถปรับ Compression / Rebound / Preload ได้', + translations: { + th: { label: 'ปรับตั้งค่าได้ (Adjustable)', description: 'สามารถปรับ Compression / Rebound / Preload ได้' }, + en: { label: 'Adjustable', description: 'Supports Compression / Rebound / Preload tuning' } + } + } + ], []); + + // AI Prompt Generator + const aiPrompt = useMemo(() => { + return `You are a Senior E-commerce Data Architect. Generate a JSON Array of Product Attribute Definitions for our store catalog. + +### Technical JSON Schema Requirements: +Each item in the array MUST follow this structure: +- \`code\`: (string, lowercase snake_case, unique e.g. "motorcycle_model", "material", "burn_time", "spring_rate") +- \`type\`: ("SELECT" | "TEXT" | "NUMBER" | "BOOLEAN" | "JSON") +- \`unit\`: (string or null, e.g. "mm", "kg", "hrs", "cc") +- \`isRequired\`: (boolean, true if compulsory for all products) +- \`isFilterable\`: (boolean, true if this attribute should be a facet filter on category/catalog pages) +- \`sortOrder\`: (number, display ordering priority) +- \`categoryId\`: (number or null, null = global attribute for all categories) +- \`label\`: (string or object with language keys) +- \`description\`: (string or object with language keys) +- \`options\`: (required when type === "SELECT", array of objects [{ "th": "...", "en": "..." }] or strings) +- \`translations\`: (object with keys for languages "th", "en", "ja", "zh", "vi", "ko", "lo", "it", containing { "label": "...", "description": "..." }) + +### Sample Output Format: +\`\`\`json +${JSON.stringify(sampleImportData, null, 2)} +\`\`\` + +Please output ONLY the valid JSON Array without surrounding markdown or explanations.`; + }, [sampleImportData]); + + // 2. Parsed Import Items + const parsedImportData = useMemo(() => { + if (!importJsonText.trim()) return null; + + try { + const raw = JSON.parse(importJsonText); + const list = Array.isArray(raw) + ? raw + : (Array.isArray(raw.definitions) ? raw.definitions : (Array.isArray(raw.attributes) ? raw.attributes : null)); + + if (!list || !Array.isArray(list)) { + setParseError('JSON must be an Array of attribute definition objects e.g. [ { "code": "...", "type": "SELECT", "label": "..." } ]'); + return null; + } + + const normalized = list.map((item: any) => { + const code = String(item.code || item.name || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + const rawType = String(item.type || 'TEXT').toUpperCase(); + const type = ATTRIBUTE_TYPES.includes(rawType as any) ? rawType : 'TEXT'; + + const trans: Record = {}; + if (item.translations && typeof item.translations === 'object') { + for (const [k, v] of Object.entries(item.translations)) { + if (typeof v === 'string') { + trans[k.toLowerCase()] = { label: v }; + } else if (v && typeof v === 'object' && typeof (v as any).label === 'string') { + trans[k.toLowerCase()] = { + label: (v as any).label, + description: (v as any).description ? String((v as any).description) : undefined, + }; + } + } + } + + const baseLabel = typeof item.label === 'string' + ? item.label + : (item.label?.th || item.label?.en || trans['th']?.label || trans['en']?.label || code); + + const baseDesc = typeof item.description === 'string' + ? item.description + : (item.description?.th || item.description?.en || trans['th']?.description || null); + + if (!trans['th'] && baseLabel) { + trans['th'] = { label: baseLabel, description: baseDesc || undefined }; + } + + return { + id: typeof item.id === 'number' ? item.id : undefined, + code, + type, + unit: item.unit ? String(item.unit) : null, + isRequired: Boolean(item.isRequired), + isFilterable: item.isFilterable !== undefined ? Boolean(item.isFilterable) : true, + sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : (parseInt(item.sortOrder) || 0), + categoryId: item.categoryId ? Number(item.categoryId) : null, + label: baseLabel, + description: baseDesc, + options: Array.isArray(item.options) ? item.options : undefined, + translations: trans, + }; + }); + + const invalidCode = normalized.find((item) => !item.code); + if (invalidCode) { + setParseError('Each attribute definition must have a non-empty "code" field (e.g. "material", "burn_time").'); + return null; + } + + if (normalized.length === 0) { + setParseError('No attribute definition items found in the array.'); + return null; + } + + setParseError(null); + return normalized; + } catch (err: any) { + setParseError(`Invalid JSON Syntax: ${err.message}`); + return null; + } + }, [importJsonText]); + + if (!isOpen) return null; + + const handleCopyJson = async () => { + try { + await navigator.clipboard.writeText(exportJsonString); + setCopiedJson(true); + setTimeout(() => setCopiedJson(false), 2000); + } catch {} + }; + + const handleCopyPrompt = async () => { + try { + await navigator.clipboard.writeText(aiPrompt); + setCopiedPrompt(true); + setTimeout(() => setCopiedPrompt(false), 2000); + } catch {} + }; + + 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_attributes_export_${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result as string; + if (content) { + setImportJsonText(content); + } + }; + reader.readAsText(file); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleLoadSample = () => { + setImportJsonText(JSON.stringify(sampleImportData, null, 2)); + }; + + const handleLoadCurrent = () => { + setImportJsonText(exportJsonString); + }; + + const handleImportSubmit = async () => { + if (!parsedImportData || parsedImportData.length === 0) return; + setSubmitting(true); + setResultMessage(null); + + try { + const res = await fetch(`${API_BASE}/product-attributes/definitions/bulk-import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(parsedImportData), + }); + + const data = await res.json(); + + if (res.ok && data.success) { + setResultMessage(`นำเข้าสำเร็จเรียบร้อย! (สร้างใหม่ ${data.createdCount || 0} รายการ, อัปเดต ${data.updatedCount || 0} รายการ)`); + onSuccess(); + setTimeout(() => { + onClose(); + }, 1500); + } else { + setParseError(data.message || 'Failed to bulk import attribute definitions'); + } + } catch (err: any) { + console.error('Import attribute definitions error:', err); + setParseError(err.message || 'Network error occurred'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + {/* Modal Header */} +
+
+
+ +
+
+

+ Product Attributes JSON Manager + + Bulk JSON Format + +

+

+ Import, Export, and Paste JSON to create or update attributes with multi-language specs and options. +

+
+
+ + +
+ + {/* Tab Navigation */} +
+ + + + + +
+ + {/* Tab Content Body */} +
+ + {/* IMPORT TAB */} + {activeTab === 'import' && ( +
+ + {/* Action Toolbar */} +
+
+ + + + + + {definitions.length > 0 && ( + + )} +
+ +
+ รองรับ: + + SELECT, TEXT, NUMBER, BOOLEAN + +
+
+ + {/* JSON Editor Textarea */} +
+