From b8a255821b10d76a5428f5fa964e88e73da879d0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 3 Oct 2026 12:56:11 +0700 Subject: [PATCH] x --- apps/admin/src/App.tsx | 4 + .../src/pages/ProductAttributeEditPage.tsx | 1447 +++++++++++++++++ .../admin/src/pages/ProductAttributesPage.tsx | 620 +++---- .../src/app/[lang]/category/[id]/page.tsx | 1 + .../category/CategoryCatalogContainer.tsx | 144 +- .../category/CategoryFilterSidebar.tsx | 36 +- 6 files changed, 1820 insertions(+), 432 deletions(-) create mode 100644 apps/admin/src/pages/ProductAttributeEditPage.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index bc7b593..c0fc6d4 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -52,6 +52,7 @@ import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage' import MediaLibraryPage from './pages/MediaLibraryPage' import ContactMessagesPage from './pages/ContactMessagesPage' import ProductAttributesPage from './pages/ProductAttributesPage' +import ProductAttributeEditPage from './pages/ProductAttributeEditPage' import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage' import TiktokShopCasePage from './pages/TiktokShopCasePage' import ShopeeShopCasePage from './pages/ShopeeShopCasePage' @@ -95,6 +96,9 @@ function App() { } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/pages/ProductAttributeEditPage.tsx b/apps/admin/src/pages/ProductAttributeEditPage.tsx new file mode 100644 index 0000000..54f12dc --- /dev/null +++ b/apps/admin/src/pages/ProductAttributeEditPage.tsx @@ -0,0 +1,1447 @@ +import { useState, useEffect, useMemo } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types' +import { + ArrowLeft, + Save, + Trash2, + Settings2, + Plus, + Globe, + CheckCircle2, + AlertCircle, + Layers, + Tag, + ListFilter, + FileJson, + ClipboardPaste, + Sparkles, + Copy, + Code2, + Check, + Send, + X, + Bot, +} from 'lucide-react' +import ContentLanguageTabs from '../components/ContentLanguageTabs' + +const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const +const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const + +type EditableDefinition = { + id?: number + code: string + type: typeof ATTRIBUTE_TYPES[number] + unit: string + isRequired: boolean + isFilterable: boolean + sortOrder: number + categoryId: number | null + translations: Record + options: Record[] +} + +const SAMPLE_JSON_TEMPLATE = JSON.stringify( + { + th: 'ประเภทโช้คอัพ', + en: 'Shock Absorber Type', + ja: 'ショックアブソーバータイプ', + zh: '减震器类型', + vi: 'Loại giảm xóc', + ko: '쇼크 업소버 유형', + lo: 'ປະເພດໂຊ້ກອັບ', + it: 'Tipo di ammortizzatore', + }, + null, + 2 +) + +const SAMPLE_NESTED_JSON_TEMPLATE = JSON.stringify( + { + th: { + label: 'ประเภทโช้คอัพ', + description: 'ชนิดของโช้คอัพ เช่น โช้คเดี่ยว หรือ โช้คคู่', + }, + en: { + label: 'Shock Absorber Type', + description: 'Shock absorber configuration (Mono or Twin Shock)', + }, + ja: { + label: 'ショックアブソーバータイプ', + description: 'ショックアブソーバーの構成(モノまたはツイン)', + }, + zh: { + label: '减震器类型', + description: '减震器配置(单筒或双筒)', + }, + vi: { + label: 'Loại giảm xóc', + description: 'Cấu hình giảm xóc (Đơn hoặc Đôi)', + }, + ko: { + label: '쇼크 업소버 유형', + description: '쇼크 업소버 구성 (모โน หรือ 트윈)', + }, + lo: { + label: 'ປະເພດໂຊ້ກອັບ', + description: 'ໂຄງສ້າງໂຊ້ກອັບ (ດ່ຽວ ຫຼື ຄູ່)', + }, + it: { + label: 'Tipo di ammortizzatore', + description: 'Configurazione ammortizzatore (Mono o Doppio)', + }, + }, + null, + 2 +) + +const SAMPLE_OPTIONS_TEMPLATE = JSON.stringify( + [ + { + th: 'โช้คเดี่ยว (Mono Shock)', + en: 'Mono Shock', + ja: 'モノショック', + zh: '单筒减震器', + vi: 'Giảm xóc đơn', + ko: '모노 쇼크', + lo: 'ໂຊ້ກດ່ຽວ', + it: 'Mono ammortizzatore', + }, + { + th: 'โช้คคู่ (Twin Shock)', + en: 'Twin Shock', + ja: 'ツインショック', + zh: '双筒减震器', + vi: 'Giảm xóc đôi', + ko: '트윈 쇼크', + lo: 'ໂຊ້ກຄູ່', + it: 'Doppio ammortizzatore', + }, + ], + null, + 2 +) + +function emptyDefinition(): EditableDefinition { + return { + code: '', + type: 'TEXT', + unit: '', + isRequired: false, + isFilterable: true, + sortOrder: 0, + categoryId: null, + translations: Object.fromEntries(LANGUAGES.map((lang) => [lang, { label: '', description: '' }])), + options: [], + } +} + +function parseDefinition(definition: AttributeDefinition): EditableDefinition { + const translations = Object.fromEntries( + LANGUAGES.map((lang) => [ + lang, + { + label: + typeof definition.label === 'string' + ? lang === 'th' + ? definition.label + : '' + : definition.label?.[lang] || '', + description: + typeof definition.description === 'string' + ? lang === 'th' + ? definition.description + : '' + : definition.description?.[lang] || '', + }, + ]) + ) + + return { + id: definition.id, + code: definition.code, + type: definition.type, + unit: definition.unit || '', + isRequired: definition.isRequired, + isFilterable: definition.isFilterable, + sortOrder: definition.sortOrder, + categoryId: definition.categoryId ?? null, + translations, + options: Array.isArray(definition.options) + ? definition.options.map((option) => (typeof option === 'string' ? { th: option } : option)) + : [], + } +} + +function parseTranslationsJson(raw: string): { + translations: Record + options?: Record[] +} { + const parsed = JSON.parse(raw) + const result: Record = Object.fromEntries( + LANGUAGES.map((l) => [l, { label: '', description: '' }]) + ) + let extractedOptions: Record[] | undefined = undefined + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Invalid JSON: Must be a JSON object.') + } + + // Check if options array is present + if (Array.isArray(parsed.options)) { + extractedOptions = parsed.options.map((opt: any) => + typeof opt === 'string' ? { th: opt, en: opt } : typeof opt === 'object' && opt !== null ? opt : {} + ) + } + + // Case A: { label: { th: "...", en: "..." }, description: { th: "...", en: "..." } } + if (parsed.label || parsed.description) { + if (typeof parsed.label === 'object' && parsed.label !== null) { + Object.entries(parsed.label).forEach(([lang, val]) => { + if (typeof val === 'string' && (LANGUAGES as readonly string[]).includes(lang)) { + result[lang].label = val + } + }) + } else if (typeof parsed.label === 'string') { + result.th.label = parsed.label + result.en.label = parsed.label + } + + if (typeof parsed.description === 'object' && parsed.description !== null) { + Object.entries(parsed.description).forEach(([lang, val]) => { + if (typeof val === 'string' && (LANGUAGES as readonly string[]).includes(lang)) { + result[lang].description = val + } + }) + } else if (typeof parsed.description === 'string') { + result.th.description = parsed.description + } + return { translations: result, options: extractedOptions } + } + + // Case B: Direct language keys: { th: "...", en: "...", ja: "..." } or { th: { label: "...", description: "..." } } + let hasLangKeys = false + Object.entries(parsed).forEach(([key, val]) => { + const lang = key.toLowerCase() + if ((LANGUAGES as readonly string[]).includes(lang)) { + hasLangKeys = true + if (typeof val === 'string') { + result[lang].label = val + } else if (typeof val === 'object' && val !== null) { + const v = val as any + if (v.label) result[lang].label = String(v.label) + if (v.description) result[lang].description = String(v.description) + if (v.name && !result[lang].label) result[lang].label = String(v.name) + } + } + }) + + // Case C: Flat keys like { label_th: "...", label_en: "...", description_th: "..." } + if (!hasLangKeys) { + Object.entries(parsed).forEach(([key, val]) => { + if (typeof val === 'string') { + const parts = key.toLowerCase().split('_') + const lastPart = parts[parts.length - 1] + if ((LANGUAGES as readonly string[]).includes(lastPart)) { + if (key.includes('desc')) { + result[lastPart].description = val + hasLangKeys = true + } else { + result[lastPart].label = val + hasLangKeys = true + } + } + } + }) + } + + if (!hasLangKeys) { + throw new Error('No recognized language keys (th, en, ja, zh, vi, ko, lo, it) found in JSON.') + } + + return { translations: result, options: extractedOptions } +} + +function parseOptionsJson(raw: string): Record[] { + const parsed = JSON.parse(raw) + let rawList: any[] = [] + + if (Array.isArray(parsed)) { + rawList = parsed + } else if (typeof parsed === 'object' && parsed !== null) { + if (Array.isArray(parsed.options)) { + rawList = parsed.options + } else { + rawList = Object.values(parsed) + } + } else { + throw new Error('Invalid JSON: Options must be an array of objects or strings.') + } + + const result: Record[] = [] + rawList.forEach((item) => { + if (typeof item === 'string') { + const trimmed = item.trim() + if (trimmed) { + result.push({ th: trimmed, en: trimmed }) + } + } else if (typeof item === 'object' && item !== null) { + const optRecord: Record = {} + if (item.label && typeof item.label === 'object') { + Object.entries(item.label).forEach(([k, v]) => { + if (typeof v === 'string' && v.trim()) optRecord[k] = v.trim() + }) + } else if (item.label && typeof item.label === 'string') { + optRecord.th = item.label.trim() + optRecord.en = item.label.trim() + } else { + Object.entries(item).forEach(([k, v]) => { + if (typeof v === 'string' && v.trim()) { + optRecord[k] = v.trim() + } + }) + } + + if (Object.keys(optRecord).length > 0) { + result.push(optRecord) + } + } + }) + + if (result.length === 0) { + throw new Error('No valid options found in JSON.') + } + + return result +} + +export default function ProductAttributeEditPage() { + const { t } = useTranslation() + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const isNew = !id || id === 'new' + + const [categories, setCategories] = useState([]) + const [form, setForm] = useState(emptyDefinition()) + const [activeLang, setActiveLang] = useState('th') + const [loading, setLoading] = useState(!isNew) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [successToast, setSuccessToast] = useState(null) + + // JSON Quick Paste State (Labels & Descriptions) + const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false) + const [jsonInput, setJsonInput] = useState('') + const [jsonError, setJsonError] = useState(null) + const [jsonSuccess, setJsonSuccess] = useState(null) + + // JSON Quick Paste State (Select Options) + const [isOptionsJsonBoxOpen, setIsOptionsJsonBoxOpen] = useState(false) + const [optionsJsonInput, setOptionsJsonInput] = useState('') + const [optionsJsonError, setOptionsJsonError] = useState(null) + const [optionsJsonSuccess, setOptionsJsonSuccess] = useState(null) + + // Fetch Category list and existing Attribute Definition + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true) + const [catRes, defRes] = await Promise.all([ + fetch(`${API_BASE}/categories?lang=all`), + !isNew ? fetch(`${API_BASE}/product-attributes/definitions?lang=all`) : Promise.resolve(null), + ]) + + if (catRes.ok) { + const catData = await catRes.json() + setCategories(catData) + } + + if (defRes && defRes.ok) { + const allDefs: AttributeDefinition[] = await defRes.json() + const target = allDefs.find((d) => d.id === Number(id)) + if (target) { + setForm(parseDefinition(target)) + } else { + setError('Attribute definition not found') + } + } + } catch (err: any) { + setError(err.message || 'Failed to load attribute data') + } finally { + setLoading(false) + } + } + + fetchData() + }, [id, isNew]) + + const handleCopyFromLang = (fromLang: string) => { + const src = form.translations[fromLang] + if (!src) return + setForm({ + ...form, + translations: { + ...form.translations, + [activeLang]: { + label: src.label || '', + description: src.description || '', + }, + }, + }) + } + + const labelTranslationsMap = useMemo(() => { + return Object.fromEntries(Object.entries(form.translations).map(([lang, val]) => [lang, val.label])) + }, [form.translations]) + + const handleSave = async ( + e?: React.FormEvent, + overrideTranslations?: Record, + overrideOptions?: Record[] + ) => { + if (e) e.preventDefault() + setSaving(true) + setError(null) + setSuccessToast(null) + + const activeTranslations = overrideTranslations || form.translations + const activeOptions = overrideOptions || form.options + + try { + const baseLabel = activeTranslations.th?.label || activeTranslations.en?.label + if (!baseLabel) { + throw new Error('Thai or English Label is required') + } + if (!form.code.trim()) { + throw new Error('Attribute Code is required (e.g. model, scent, series)') + } + + const payload = { + code: form.code.trim(), + type: form.type, + unit: form.unit.trim(), + isRequired: form.isRequired, + isFilterable: form.isFilterable, + sortOrder: Number(form.sortOrder) || 0, + categoryId: form.categoryId ? Number(form.categoryId) : null, + label: Object.fromEntries( + Object.entries(activeTranslations) + .filter(([, val]) => val.label.trim()) + .map(([lang, val]) => [lang, val.label.trim()]) + ), + description: Object.fromEntries( + Object.entries(activeTranslations) + .filter(([, val]) => val.description.trim()) + .map(([lang, val]) => [lang, val.description.trim()]) + ), + options: + form.type === 'SELECT' + ? activeOptions + .map((opt) => + Object.fromEntries( + Object.entries(opt).filter(([, val]) => typeof val === 'string' && val.trim()) + ) + ) + .filter((opt) => Object.keys(opt).length > 0) + : undefined, + } + + const url = !isNew + ? `${API_BASE}/product-attributes/definitions/${id}` + : `${API_BASE}/product-attributes/definitions` + const method = !isNew ? 'PUT' : 'POST' + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!res.ok) { + const errData = await res.json().catch(() => null) + throw new Error(errData?.message || 'Failed to save attribute definition') + } + + const savedData = await res.json() + setSuccessToast('Attribute definition saved successfully!') + + if (isNew && savedData?.id) { + navigate(`/product-attributes/${savedData.id}`, { replace: true }) + } + } catch (err: any) { + setError(err.message || 'An error occurred while saving') + throw err + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!id || isNew) return + if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this attribute definition?'))) return + + try { + setSaving(true) + const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, { method: 'DELETE' }) + if (!res.ok) { + const errData = await res.json().catch(() => null) + throw new Error(errData?.message || 'Failed to delete attribute definition') + } + navigate('/product-attributes') + } catch (err: any) { + setError(err.message) + setSaving(false) + } + } + + // ── JSON Quick Paste Actions (Labels & Description) ─────────────────────── + const handlePasteJsonFromClipboard = async () => { + try { + const text = await navigator.clipboard.readText() + if (!text || !text.trim()) { + setJsonError('Clipboard is empty.') + return + } + setJsonInput(text) + setJsonError(null) + setJsonSuccess('Pasted text from clipboard!') + setTimeout(() => setJsonSuccess(null), 1500) + } catch { + setJsonError('Unable to read clipboard. Please paste manually into the text box.') + } + } + + const handleFillSampleJson = () => { + setJsonInput(SAMPLE_JSON_TEMPLATE) + setJsonError(null) + setJsonSuccess('Inserted Simple JSON (Type 1)!') + setTimeout(() => setJsonSuccess(null), 1500) + } + + const handleFillNestedSampleJson = () => { + const hasExisting = Boolean(form.translations.th?.label || form.translations.en?.label) + if (!hasExisting) { + setJsonInput(SAMPLE_NESTED_JSON_TEMPLATE) + navigator.clipboard.writeText(SAMPLE_NESTED_JSON_TEMPLATE).catch(() => {}) + setJsonError(null) + setJsonSuccess('Inserted & copied Nested JSON (Type 3) to clipboard!') + setTimeout(() => setJsonSuccess(null), 2500) + return + } + + const cleanNested: Record = {} + LANGUAGES.forEach((lang) => { + cleanNested[lang] = { + label: + form.translations[lang]?.label || + (lang === 'th' ? form.translations.en?.label || 'ประเภทโช้คอัพ' : ''), + description: form.translations[lang]?.description || '', + } + }) + const output = JSON.stringify(cleanNested, null, 2) + setJsonInput(output) + navigator.clipboard.writeText(output).catch(() => {}) + setJsonError(null) + setJsonSuccess('Copied Nested JSON (Type 3) to clipboard!') + setTimeout(() => setJsonSuccess(null), 2500) + } + + const handleCopyLlmPrompt = () => { + const cleanNested: Record = {} + LANGUAGES.forEach((lang) => { + cleanNested[lang] = { + label: + form.translations[lang]?.label || + (lang === 'th' ? form.translations.en?.label || 'ประเภทโช้คอัพ' : ''), + description: + form.translations[lang]?.description || + (lang === 'th' ? form.translations.en?.description || 'ชนิดของโช้คอัพ เช่น โช้คเดี่ยว หรือ โช้คคู่' : ''), + } + }) + + const promptText = `Please translate this product attribute specification into all 8 languages (th, en, ja, zh, vi, ko, lo, it) for an e-commerce platform. +Attribute Code: "${form.code || 'attribute_code'}" + +Return ONLY valid JSON in this exact Nested Languages format (no markdown fences, just pure JSON): +${JSON.stringify(cleanNested, null, 2)}` + + setJsonInput(promptText) + navigator.clipboard.writeText(promptText).catch(() => {}) + setJsonError(null) + setJsonSuccess('Copied complete LLM Prompt to clipboard!') + setTimeout(() => setJsonSuccess(null), 3000) + } + + const handleExportCurrentJson = () => { + const cleanObject: Record = {} + for (const [k, v] of Object.entries(form.translations)) { + if (v?.label && v.label.trim()) cleanObject[k] = v.label.trim() + } + const output = JSON.stringify(cleanObject, null, 2) + setJsonInput(output) + navigator.clipboard.writeText(output).catch(() => {}) + setJsonError(null) + setJsonSuccess('Exported & copied current translations to clipboard!') + setTimeout(() => setJsonSuccess(null), 2000) + } + + const handleApplyJson = async (autoSaveToBackend: boolean = false) => { + if (!jsonInput.trim()) { + setJsonError('Please enter or paste JSON first.') + return + } + + try { + setJsonError(null) + const parsed = parseTranslationsJson(jsonInput) + + const nextTranslations = { ...form.translations } + Object.entries(parsed.translations).forEach(([lang, val]) => { + if (val.label.trim()) nextTranslations[lang] = { ...nextTranslations[lang], label: val.label.trim() } + if (val.description.trim()) nextTranslations[lang] = { ...nextTranslations[lang], description: val.description.trim() } + }) + + const nextOptions = parsed.options && parsed.options.length > 0 ? parsed.options : form.options + + setForm((prev) => ({ + ...prev, + translations: nextTranslations, + options: nextOptions, + })) + + if (autoSaveToBackend) { + setJsonSuccess('Applying and saving to backend...') + await handleSave(undefined, nextTranslations, nextOptions) + setJsonSuccess('Applied and successfully saved to backend!') + setTimeout(() => { + setIsJsonBoxOpen(false) + setJsonSuccess(null) + }, 1200) + } else { + setJsonSuccess('Applied translations to all language tabs!') + setTimeout(() => setJsonSuccess(null), 2000) + } + } catch (err: any) { + setJsonError(err.message || 'Failed to parse JSON') + } + } + + // ── JSON Quick Paste Actions (Select Options) ─────────────────────────── + const handlePasteOptionsJsonFromClipboard = async () => { + try { + const text = await navigator.clipboard.readText() + if (!text || !text.trim()) { + setOptionsJsonError('Clipboard is empty.') + return + } + setOptionsJsonInput(text) + setOptionsJsonError(null) + setOptionsJsonSuccess('Pasted text from clipboard!') + setTimeout(() => setOptionsJsonSuccess(null), 1500) + } catch { + setOptionsJsonError('Unable to read clipboard. Please paste manually into the text box.') + } + } + + const handleFillSampleOptionsJson = () => { + setOptionsJsonInput(SAMPLE_OPTIONS_TEMPLATE) + setOptionsJsonError(null) + setOptionsJsonSuccess('Inserted sample options JSON!') + setTimeout(() => setOptionsJsonSuccess(null), 1500) + } + + const handleExportCurrentOptionsJson = () => { + const output = JSON.stringify(form.options, null, 2) + setOptionsJsonInput(output) + navigator.clipboard.writeText(output).catch(() => {}) + setOptionsJsonError(null) + setOptionsJsonSuccess('Exported & copied options JSON to clipboard!') + setTimeout(() => setOptionsJsonSuccess(null), 2000) + } + + const handleCopyOptionsLlmPrompt = () => { + const attributeName = form.translations.th?.label || form.translations.en?.label || form.code || 'Specification' + const draftOptions = + form.options.length > 0 + ? form.options + : [ + { + th: 'ตัวเลือก 1', + en: 'Option 1', + ja: 'オプション1', + zh: '选项1', + vi: 'Lựa chọn 1', + ko: '옵션 1', + lo: 'ຕົວເລືອກ 1', + it: 'Opzione 1', + }, + { + th: 'ตัวเลือก 2', + en: 'Option 2', + ja: 'オプション2', + zh: '选项2', + vi: 'Lựa chọn 2', + ko: '옵션 2', + lo: 'ຕົວເລືອກ 2', + it: 'Opzione 2', + }, + ] + + const promptText = `Please generate and translate realistic, complete selectable options for this product attribute across all 8 languages (th, en, ja, zh, vi, ko, lo, it) for an e-commerce catalog. + +Attribute Code: "${form.code || 'attribute_code'}" +Attribute Name: "${attributeName}" + +Reference / Draft Options: +${JSON.stringify(draftOptions, null, 2)} + +Return ONLY a valid JSON Array of objects in this exact structure (no markdown fences, just pure JSON array): +[ + { + "th": "...", + "en": "...", + "ja": "...", + "zh": "...", + "vi": "...", + "ko": "...", + "lo": "...", + "it": "..." + } +]` + + setOptionsJsonInput(promptText) + navigator.clipboard.writeText(promptText).catch(() => {}) + setOptionsJsonError(null) + setOptionsJsonSuccess('Copied Options LLM Prompt to clipboard!') + setTimeout(() => setOptionsJsonSuccess(null), 3000) + } + + const handleApplyOptionsJson = async (autoSaveToBackend: boolean = false) => { + if (!optionsJsonInput.trim()) { + setOptionsJsonError('Please enter or paste JSON first.') + return + } + + try { + setOptionsJsonError(null) + const parsedOptions = parseOptionsJson(optionsJsonInput) + + setForm((prev) => ({ + ...prev, + options: parsedOptions, + })) + + if (autoSaveToBackend) { + setOptionsJsonSuccess('Applying and saving options to backend...') + await handleSave(undefined, undefined, parsedOptions) + setOptionsJsonSuccess('Applied and successfully saved options to backend!') + setTimeout(() => { + setIsOptionsJsonBoxOpen(false) + setOptionsJsonSuccess(null) + }, 1200) + } else { + setOptionsJsonSuccess(`Applied ${parsedOptions.length} options to manager!`) + setTimeout(() => setOptionsJsonSuccess(null), 2000) + } + } catch (err: any) { + setOptionsJsonError(err.message || 'Failed to parse Options JSON') + } + } + + if (loading) { + return ( +
+
+
+

Loading attribute definition...

+
+
+ ) + } + + return ( +
+ {/* Toast Notification */} + {successToast && ( +
+ + {successToast} +
+ )} + + {/* Header Bar */} +
+
+ + + +
+
+

+ {isNew + ? 'Create Attribute Definition' + : form.translations[activeLang]?.label || + form.translations.th?.label || + form.translations.en?.label || + form.code || + 'Edit Attribute'} +

+ + {form.type} + + {!isNew && #{id}} +
+

+ Manage attribute code, multilingual labels, filters, and select options. +

+
+
+ +
+ {!isNew && ( + + )} + +
+
+ + {error && ( +
+ + {error} +
+ )} + +
+ {/* Section 1: Core Specs */} +
+
+
+ + Core Configuration +
+ System identifier and data type +
+ +
+
+ + + setForm({ ...form, code: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_') }) + } + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none font-mono" + placeholder="e.g. burn_time, weight, shock_type" + /> +

+ Unique key for queries and filters (lowercase, a-z, 0-9, _) +

+
+ +
+ + +

Choose SELECT for predefined options or filters

+
+ +
+ + setForm({ ...form, unit: e.target.value })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none" + placeholder="e.g. g, ml, hrs, mm, kg" + /> +

Measurement suffix shown on product spec

+
+
+ +
+
+ + +

+ Restricting to a Category will make it appear only when editing products in that category +

+
+ +
+ + setForm({ ...form, sortOrder: Number(e.target.value) })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none font-mono" + placeholder="0" + /> +

+ Lower numbers appear first in filter sidebars and spec tables +

+
+
+ + {/* Flags / Switches */} +
+ + + +
+
+ + {/* Section 2: Multilingual Labels & Description + JSON Quick Paste */} +
+
+
+
+ + Multilingual Labels & Descriptions +
+

Localized display names and descriptions

+
+ + {/* Quick JSON & LLM Action Buttons */} +
+ + + +
+
+ + {/* Expandable JSON Quick Import Box */} + {isJsonBoxOpen && ( +
+
+
+ + Paste / Export Multilingual Translations JSON +
+ +
+ + + + + + + + + + + +
+
+ + {jsonError && ( +
+ + {jsonError} +
+ )} + + {jsonSuccess && ( +
+ + {jsonSuccess} +
+ )} + +