x
This commit is contained in:
1 parent
27cc56abaf
commit
0c3faaba29
11 files changed
+2286
-482
No files matched your search
@@ -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<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const categoryNameMap = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
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<string, { label: string; description?: string }> = {};
|
||||
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<string, { label: string; description?: string }> = {};
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<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="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 sm:p-6 border-b border-white/10 flex items-center justify-between bg-night-950/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow">
|
||||
<FileJson className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>Product Attributes JSON Manager</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
|
||||
Bulk JSON Format
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Import, Export, and Paste JSON to create or update attributes with multi-language specs and options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-white/10 bg-night-950/40 px-6 pt-3 gap-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'import'
|
||||
? 'border-cyan-glow text-cyan-glow'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>📥 Import / วาง JSON เพื่อสร้างใหม่</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('export')}
|
||||
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'export'
|
||||
? 'border-cyan-glow text-cyan-glow'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>📤 Export JSON ({definitions.length} รายการ)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('ai')}
|
||||
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'ai'
|
||||
? 'border-purple-400 text-purple-300'
|
||||
: 'border-transparent text-slate-400 hover:text-purple-300'
|
||||
}`}
|
||||
>
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<span>🤖 AI Prompt Template</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1 space-y-5">
|
||||
|
||||
{/* IMPORT TAB */}
|
||||
{activeTab === 'import' && (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Action Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Upload .json File</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadSample}
|
||||
className="px-3.5 py-1.5 bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-300 border border-cyan-glow/30 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Sliders className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Load Sample JSON (5 Examples)</span>
|
||||
</button>
|
||||
|
||||
{definitions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadCurrent}
|
||||
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Load Current Attributes ({definitions.length})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<span>รองรับ:</span>
|
||||
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">
|
||||
SELECT, TEXT, NUMBER, BOOLEAN
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Editor Textarea */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={importJsonText}
|
||||
onChange={(e) => setImportJsonText(e.target.value)}
|
||||
rows={9}
|
||||
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 focus:border-cyan-glow/50 rounded-xl resize-y text-slate-200 placeholder:text-slate-600 outline-none transition-all shadow-inner"
|
||||
placeholder={`วาง JSON Array ของ Attribute Definitions ที่นี่ เช่น:
|
||||
[
|
||||
{
|
||||
"code": "motorcycle_model",
|
||||
"type": "SELECT",
|
||||
"isFilterable": true,
|
||||
"isRequired": false,
|
||||
"label": "รุ่นรถมอเตอร์ไซค์",
|
||||
"options": [
|
||||
{ "th": "CBR 650R", "en": "CBR 650R" },
|
||||
{ "th": "MT-09", "en": "MT-09" }
|
||||
],
|
||||
"translations": {
|
||||
"th": { "label": "รุ่นรถมอเตอร์ไซค์" },
|
||||
"en": { "label": "Motorcycle Model" }
|
||||
}
|
||||
}
|
||||
]`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Box */}
|
||||
{parseError && (
|
||||
<div className="p-3.5 bg-rose-500/10 border border-rose-500/30 rounded-xl flex items-center gap-3 text-xs text-rose-300 animate-fade-in">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||
<span>{parseError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Box */}
|
||||
{resultMessage && (
|
||||
<div className="p-3.5 bg-emerald-500/10 border border-emerald-500/30 rounded-xl flex items-center gap-3 text-xs text-emerald-300 animate-fade-in">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||
<span>{resultMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Preview List */}
|
||||
{parsedImportData && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-emerald-400">
|
||||
Parsed Successfully: Found {parsedImportData.length} Attribute Definition(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-56 overflow-y-auto pr-1">
|
||||
{parsedImportData.map((item, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3.5 rounded-xl bg-night-950/80 border border-white/5 space-y-2 text-xs flex flex-col justify-between"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-bold text-white truncate text-[13px]">
|
||||
{item.label || item.code}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20 shrink-0">
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
{item.unit && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-white/5 text-amber-400 border border-amber-400/20 shrink-0">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-400 font-mono">
|
||||
<span>code: <strong className="text-slate-200">{item.code}</strong></span>
|
||||
{item.isFilterable && (
|
||||
<span className="flex items-center gap-0.5 text-cyan-glow text-[10px]">
|
||||
<Filter className="w-3 h-3" /> Filterable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="text-[11px] text-slate-400 line-clamp-1">{item.description}</p>
|
||||
)}
|
||||
|
||||
{/* Category Binding */}
|
||||
<p className="text-[10px] text-slate-500">
|
||||
Scope:{' '}
|
||||
<span className="text-slate-300">
|
||||
{item.categoryId ? (categoryNameMap.get(item.categoryId) || `Category #${item.categoryId}`) : 'Global (All Categories)'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Options preview for SELECT type */}
|
||||
{item.type === 'SELECT' && Array.isArray(item.options) && item.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{item.options.slice(0, 4).map((opt: any, oIdx: number) => {
|
||||
const optLabel = typeof opt === 'string' ? opt : (opt.th || opt.en || Object.values(opt)[0] || '');
|
||||
return (
|
||||
<span key={oIdx} className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-slate-300 border border-white/10">
|
||||
{optLabel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{item.options.length > 4 && (
|
||||
<span className="text-[9px] text-slate-500 px-1">+{item.options.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language translation coverage badges */}
|
||||
<div className="flex flex-wrap gap-1 pt-1.5 border-t border-white/5">
|
||||
{SUPPORTED_LOCALES.map((loc) => {
|
||||
const hasTrans = Boolean(item.translations?.[loc.code]?.label);
|
||||
return (
|
||||
<span
|
||||
key={loc.code}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
hasTrans
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-white/5 text-slate-600 border border-white/5'
|
||||
}`}
|
||||
>
|
||||
{loc.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXPORT TAB */}
|
||||
{activeTab === 'export' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-400">
|
||||
Current {definitions.length} Product Attribute Definitions exported with multi-language specs and options:
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJson}
|
||||
className="px-3.5 py-1.5 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 hover:shadow-lg hover:shadow-cyan-glow/20 cursor-pointer"
|
||||
>
|
||||
{copiedJson ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedJson ? 'Copied JSON!' : 'Copy JSON'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadJson}
|
||||
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Download .json</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Display */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
value={exportJsonString}
|
||||
rows={13}
|
||||
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 rounded-xl resize-y text-slate-200 outline-none select-all shadow-inner"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI PROMPT TAB */}
|
||||
{activeTab === 'ai' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-purple-300">
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<span>One-Click AI Attribute Generator Prompt (ChatGPT / Claude / Gemini)</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPrompt}
|
||||
className="px-3.5 py-1.5 bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border border-purple-500/30 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
{copiedPrompt ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedPrompt ? 'Copied Prompt!' : 'Copy Prompt for AI'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/20 text-[11px] text-slate-300 leading-relaxed space-y-2">
|
||||
<p>
|
||||
💡 <strong>วิธีใช้งาน:</strong> กดปุ่ม <em>"Copy Prompt for AI"</em> แล้วนำไปวางใน ChatGPT หรือ Claude พร้อมแนบรายชื่อสเปกสินค้า หรือแคตตาล็อกอะไหล่/สินค้าที่คุณต้องการสร้าง AI จะแปลงเป็น JSON Array ที่มีโครงสร้างถูกต้องและแปลให้ครบ 8 ภาษาอัตโนมัติ จากนั้นนำ JSON กลับมาวางในแท็บ <strong>Import</strong> เพื่อสร้าง Attribute ทั้งหมดได้ในคลิกเดียว
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
value={aiPrompt}
|
||||
rows={11}
|
||||
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 rounded-xl resize-y text-slate-200 outline-none select-all shadow-inner"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-5 sm:p-6 border-t border-white/10 bg-night-950/80 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 rounded-xl text-xs font-semibold transition-all cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
{activeTab === 'import' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!parsedImportData || Boolean(parseError) || submitting}
|
||||
onClick={handleImportSubmit}
|
||||
className="px-5 py-2 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-2 hover:shadow-lg hover:shadow-cyan-glow/20 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Importing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Import {parsedImportData?.length || 0} Attribute Definitions</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
X,
|
||||
Sliders,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit3,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type AttributeDefinition } from '@homaroma/types'
|
||||
|
||||
interface CategoryFilterManagerModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
categoryId?: number
|
||||
categoryName: string
|
||||
}
|
||||
|
||||
type AttributeType = 'SELECT' | 'TEXT' | 'NUMBER' | 'BOOLEAN'
|
||||
|
||||
interface FormState {
|
||||
id?: number
|
||||
code: string
|
||||
labelTh: string
|
||||
labelEn: string
|
||||
type: AttributeType
|
||||
unit: string
|
||||
isFilterable: boolean
|
||||
isCategorySpecific: boolean
|
||||
options: string[]
|
||||
newOptionInput: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: FormState = {
|
||||
code: '',
|
||||
labelTh: '',
|
||||
labelEn: '',
|
||||
type: 'SELECT',
|
||||
unit: '',
|
||||
isFilterable: true,
|
||||
isCategorySpecific: true,
|
||||
options: [],
|
||||
newOptionInput: '',
|
||||
}
|
||||
|
||||
export default function CategoryFilterManagerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categoryId,
|
||||
categoryName,
|
||||
}: CategoryFilterManagerModalProps) {
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterScope, setFilterScope] = useState<'all' | 'category' | 'global'>('all')
|
||||
|
||||
// Form State
|
||||
const [form, setForm] = useState<FormState>(DEFAULT_FORM)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const fetchDefinitions = async () => {
|
||||
if (!categoryId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions?categoryId=${categoryId}&lang=all`)
|
||||
if (!res.ok) throw new Error('Failed to load category attribute definitions')
|
||||
const data = await res.json()
|
||||
setDefinitions(Array.isArray(data) ? data : [])
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading attributes')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && categoryId) {
|
||||
fetchDefinitions()
|
||||
resetForm()
|
||||
}
|
||||
}, [isOpen, categoryId])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({
|
||||
...DEFAULT_FORM,
|
||||
isCategorySpecific: Boolean(categoryId),
|
||||
})
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleEdit = (item: AttributeDefinition) => {
|
||||
const rawOptions = Array.isArray(item.options) ? item.options : []
|
||||
const optionsList: string[] = rawOptions.map((opt: any) => {
|
||||
if (typeof opt === 'string') return opt
|
||||
if (opt && typeof opt === 'object') {
|
||||
return opt.value || opt.label?.th || opt.label?.en || JSON.stringify(opt)
|
||||
}
|
||||
return String(opt)
|
||||
})
|
||||
|
||||
const labelObj = typeof item.label === 'object' && item.label ? (item.label as any) : {}
|
||||
const labelTh = labelObj.th || (typeof item.label === 'string' ? item.label : '')
|
||||
const labelEn = labelObj.en || ''
|
||||
|
||||
setForm({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
labelTh,
|
||||
labelEn,
|
||||
type: (['SELECT', 'TEXT', 'NUMBER', 'BOOLEAN'].includes(item.type) ? item.type : 'SELECT') as AttributeType,
|
||||
unit: item.unit || '',
|
||||
isFilterable: item.isFilterable,
|
||||
isCategorySpecific: item.categoryId != null,
|
||||
options: optionsList,
|
||||
newOptionInput: '',
|
||||
})
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleToggleFilterable = async (item: AttributeDefinition) => {
|
||||
try {
|
||||
const updatedValue = !item.isFilterable
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
type: item.type,
|
||||
unit: item.unit,
|
||||
options: item.options,
|
||||
isRequired: item.isRequired,
|
||||
isFilterable: updatedValue,
|
||||
sortOrder: item.sortOrder,
|
||||
categoryId: item.categoryId,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update filterable state')
|
||||
|
||||
setDefinitions((prev) =>
|
||||
prev.map((d) => (d.id === item.id ? { ...d, isFilterable: updatedValue } : d))
|
||||
)
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to update attribute')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('คุณแน่ใจหรือไม่ว่าต้องการลบ Attribute ตัวกรองนี้?')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete attribute definition')
|
||||
await fetchDefinitions()
|
||||
if (form.id === id) resetForm()
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete attribute')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddOption = () => {
|
||||
const trimmed = form.newOptionInput.trim()
|
||||
if (!trimmed) return
|
||||
if (!form.options.includes(trimmed)) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
options: [...prev.options, trimmed],
|
||||
newOptionInput: '',
|
||||
}))
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, newOptionInput: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
options: prev.options.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.code.trim()) {
|
||||
alert('กรุณาระบุ Attribute Code (เช่น motorcycle_model, material)')
|
||||
return
|
||||
}
|
||||
if (!form.labelTh.trim() && !form.labelEn.trim()) {
|
||||
alert('กรุณาระบุชื่อ Label อย่างน้อย 1 ภาษา (ไทย หรือ อังกฤษ)')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
code: form.code.trim().toLowerCase().replace(/\s+/g, '_'),
|
||||
type: form.type,
|
||||
unit: form.unit.trim() || null,
|
||||
isFilterable: form.isFilterable,
|
||||
categoryId: form.isCategorySpecific && categoryId ? categoryId : null,
|
||||
label: {
|
||||
th: form.labelTh.trim() || form.labelEn.trim(),
|
||||
en: form.labelEn.trim() || form.labelTh.trim(),
|
||||
},
|
||||
options: form.type === 'SELECT'
|
||||
? form.options.map((opt) => ({
|
||||
value: opt.toLowerCase().replace(/\s+/g, '_'),
|
||||
label: { th: opt, en: opt }
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const url = form.id
|
||||
? `${API_BASE}/product-attributes/definitions/${form.id}`
|
||||
: `${API_BASE}/product-attributes/definitions`
|
||||
const method = form.id ? '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')
|
||||
}
|
||||
|
||||
await fetchDefinitions()
|
||||
resetForm()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Save failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter definitions by search and scope
|
||||
const filteredDefinitions = useMemo(() => {
|
||||
return definitions.filter((def) => {
|
||||
// Scope filter
|
||||
if (filterScope === 'category' && def.categoryId !== categoryId) return false
|
||||
if (filterScope === 'global' && def.categoryId != null) return false
|
||||
|
||||
// Search term
|
||||
if (!searchTerm.trim()) return true
|
||||
const query = searchTerm.toLowerCase()
|
||||
const codeMatch = def.code.toLowerCase().includes(query)
|
||||
const labelStr = typeof def.label === 'string'
|
||||
? def.label
|
||||
: Object.values(def.label || {}).join(' ')
|
||||
const labelMatch = labelStr.toLowerCase().includes(query)
|
||||
return codeMatch || labelMatch
|
||||
})
|
||||
}, [definitions, filterScope, searchTerm, categoryId])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-6 bg-night-950/80 backdrop-blur-md animate-fade-in">
|
||||
<div className="relative w-full max-w-6xl max-h-[92vh] flex flex-col rounded-3xl bg-night-900 border border-white/10 shadow-2xl overflow-hidden">
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-2xl bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
<Sliders className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
ตัวจัดการตัวกรอง & คุณลักษณะเฉพาะหมวดหมู่
|
||||
</h2>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono font-bold bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||
{categoryName} (#{categoryId})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
กำหนดค่า Facets, Attribute Definitions และ Options สำหรับการค้นหาและกรองสินค้าในหมวดหมู่นี้
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body: Two Column Layout */}
|
||||
<div className="flex-1 overflow-y-auto grid grid-cols-1 lg:grid-cols-12 divide-y lg:divide-y-0 lg:divide-x divide-white/10">
|
||||
|
||||
{/* Left Column: Attribute List & Facet Toggles (7 cols) */}
|
||||
<div className="lg:col-span-7 p-6 space-y-5">
|
||||
{/* Search & Scope Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="ค้นหา Code หรือชื่อคุณลักษณะ..."
|
||||
className="admin-input w-full text-xs pl-9 pr-3 py-2 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 rounded-xl bg-white/5 border border-white/10 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterScope('all')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
filterScope === 'all'
|
||||
? 'bg-orange-500 text-night-950 font-bold shadow'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ทั้งหมด ({definitions.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterScope('category')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
filterScope === 'category'
|
||||
? 'bg-orange-500 text-night-950 font-bold shadow'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
เฉพาะหมวดนี้
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterScope('global')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
filterScope === 'global'
|
||||
? 'bg-orange-500 text-night-950 font-bold shadow'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ใช้ร่วมกัน (Global)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
{loading ? (
|
||||
<div className="py-16 flex flex-col items-center justify-center gap-2 text-slate-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-orange-400" />
|
||||
<span className="text-xs">กำลังโหลดคุณลักษณะตัวกรอง...</span>
|
||||
</div>
|
||||
) : filteredDefinitions.length === 0 ? (
|
||||
<div className="py-16 px-4 text-center border-2 border-dashed border-white/10 rounded-2xl space-y-2">
|
||||
<Sliders className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-sm font-semibold text-slate-300">ไม่พบคุณลักษณะตัวกรอง</p>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
คุณสามารถสร้าง Attribute ตัวกรองใหม่สำหรับหมวดหมู่นี้ได้ที่แบบฟอร์มด้านขวา
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDefinitions.map((item) => {
|
||||
const isCatSpecific = item.categoryId === categoryId
|
||||
const labelTh = typeof item.label === 'object' ? (item.label as any)?.th : item.label
|
||||
const labelEn = typeof item.label === 'object' ? (item.label as any)?.en : ''
|
||||
const rawOpts = Array.isArray(item.options) ? item.options : []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`p-4 rounded-2xl border transition-all ${
|
||||
form.id === item.id
|
||||
? 'bg-orange-500/10 border-orange-500/50 shadow-md shadow-orange-500/5'
|
||||
: 'bg-white/[0.02] border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold text-white">
|
||||
{labelTh || item.code}
|
||||
</span>
|
||||
{labelEn && (
|
||||
<span className="text-xs text-slate-400">({labelEn})</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-white/5 text-slate-300 border border-white/10">
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-cyan-glow/10 text-cyan-400 border border-cyan-glow/20">
|
||||
{item.type}
|
||||
</span>
|
||||
{isCatSpecific ? (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
เฉพาะหมวดนี้
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options pills if SELECT */}
|
||||
{rawOpts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{rawOpts.slice(0, 8).map((opt: any, idx: number) => {
|
||||
const text = typeof opt === 'string' ? opt : opt.value || opt.label?.th || opt.label?.en || String(idx)
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] bg-white/5 border border-white/10 text-slate-300"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{rawOpts.length > 8 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] text-slate-500">
|
||||
+{rawOpts.length - 8} เพิ่มเติม
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Filterable Switch Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleFilterable(item)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
item.isFilterable
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20'
|
||||
: 'bg-white/5 text-slate-500 border-white/10 hover:text-slate-300'
|
||||
}`}
|
||||
title="สลับการแสดงผลเป็นตัวกรองในหน้าเว็บ"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${item.isFilterable ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'}`} />
|
||||
<span>{item.isFilterable ? 'เป็นตัวกรอง' : 'ไม่กรอง'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(item)}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||
title="แก้ไข Attribute"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isCatSpecific && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/10 text-slate-400 hover:text-rose-400 transition-colors cursor-pointer"
|
||||
title="ลบ Attribute"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Add / Edit Form (5 cols) */}
|
||||
<div className="lg:col-span-5 p-6 space-y-5 bg-white/[0.01]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-orange-400" />
|
||||
<span>{isEditing ? 'แก้ไขคุณลักษณะตัวกรอง' : 'สร้างคุณลักษณะตัวกรองใหม่'}</span>
|
||||
</h3>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="text-xs text-slate-400 hover:text-white underline cursor-pointer"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* Attribute Code */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Attribute Code <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
placeholder="e.g. motorcycle_model, material, damping"
|
||||
disabled={isEditing}
|
||||
className="admin-input w-full text-xs font-mono py-2 disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
รหัสภาษาอังกฤษสำหรับสืบค้นใน Query String (เช่น ?attr_motorcycle_model=...)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Labels (TH & EN) */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">ชื่อภาษาไทย (TH)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.labelTh}
|
||||
onChange={(e) => setForm({ ...form, labelTh: e.target.value })}
|
||||
placeholder="เช่น รุ่นรถมอเตอร์ไซค์"
|
||||
className="admin-input w-full text-xs py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">ชื่อภาษาอังกฤษ (EN)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.labelEn}
|
||||
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
|
||||
placeholder="e.g. Motorcycle Model"
|
||||
className="admin-input w-full text-xs py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type & Unit */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">ประเภทข้อมูล (Type)</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setForm({ ...form, type: e.target.value as AttributeType })}
|
||||
className="admin-input w-full text-xs py-2 cursor-pointer"
|
||||
>
|
||||
<option value="SELECT">SELECT (ตัวเลือก Dropdown/Checkbox)</option>
|
||||
<option value="TEXT">TEXT (ข้อความทั่วไป)</option>
|
||||
<option value="NUMBER">NUMBER (ตัวเลข / ขนาด)</option>
|
||||
<option value="BOOLEAN">BOOLEAN (เปิด/ปิด ใช่/ไม่ใช่)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">หน่วยนับ (Unit)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.unit}
|
||||
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
||||
placeholder="เช่น mm, kg, CC"
|
||||
className="admin-input w-full text-xs py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope & Filterable Switches */}
|
||||
<div className="p-3.5 rounded-2xl bg-white/[0.02] border border-white/10 space-y-3">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-xs font-bold text-white block">เปิดใช้งานเป็นตัวกรอง (isFilterable)</span>
|
||||
<span className="text-[10px] text-slate-400">แสดงผลใน Sidebar ตัวกรองของหน้าสินค้า</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isFilterable}
|
||||
onChange={(e) => setForm({ ...form, isFilterable: e.target.checked })}
|
||||
className="w-4 h-4 accent-orange-500 rounded cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="border-t border-white/5 pt-2">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-xs font-bold text-white block">เฉพาะหมวดหมู่นี้ ({categoryName})</span>
|
||||
<span className="text-[10px] text-slate-400">หากปิด จะสามารถนำไปใช้ได้กับทุกหมวดหมู่ (Global)</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isCategorySpecific}
|
||||
onChange={(e) => setForm({ ...form, isCategorySpecific: e.target.checked })}
|
||||
className="w-4 h-4 accent-orange-500 rounded cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options Management (When type is SELECT) */}
|
||||
{form.type === 'SELECT' && (
|
||||
<div className="space-y-2 p-3.5 rounded-2xl bg-white/[0.02] border border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-white">
|
||||
รายการตัวเลือก (Options) ({form.options.length})
|
||||
</label>
|
||||
<span className="text-[10px] text-slate-500">พิมพ์แล้วกด Enter หรือกดปุ่มเพิ่ม</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.newOptionInput}
|
||||
onChange={(e) => setForm({ ...form, newOptionInput: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddOption()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. CBR 650R, MT-09, ZX-6R, Billet 6061"
|
||||
className="admin-input flex-1 text-xs py-1.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddOption}
|
||||
className="px-3 py-1.5 rounded-xl bg-orange-500/20 hover:bg-orange-500/30 text-orange-400 text-xs font-bold border border-orange-500/30 cursor-pointer"
|
||||
>
|
||||
เพิ่ม
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pills List */}
|
||||
<div className="flex flex-wrap gap-1.5 max-h-36 overflow-y-auto pt-1">
|
||||
{form.options.map((opt, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs bg-white/5 border border-white/10 text-white"
|
||||
>
|
||||
<span>{opt}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveOption(idx)}
|
||||
className="text-slate-400 hover:text-rose-400 cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{form.options.length === 0 && (
|
||||
<p className="text-[11px] text-slate-500 italic py-1">
|
||||
ยังไม่มีตัวเลือก (สามารถเพิ่มตัวเลือกเพื่อให้ลูกค้าคลิกเลือกในตัวกรองได้)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-night-950 font-bold text-xs flex items-center justify-center gap-2 shadow-lg shadow-orange-500/20 cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>{isEditing ? 'บันทึกการแก้ไข' : 'สร้างคุณลักษณะตัวกรอง'}</span>
|
||||
</button>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-xs font-semibold text-slate-300 cursor-pointer"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-white/[0.01] flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>เชื่อมต่อกับระบบฐานข้อมูล Product Attributes แบบ Real-time</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
ปิดหน้าต่าง
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Sparkles,
|
||||
Shield,
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
Trash2,
|
||||
Image as ImageIcon,
|
||||
CheckCircle2,
|
||||
Tag
|
||||
Tag,
|
||||
Sliders,
|
||||
Maximize2
|
||||
} from 'lucide-react'
|
||||
import type { CategoryFeaturedSeries, CategoryUspBenefit, CategoryFilterConfig } from '@homaroma/types'
|
||||
import { API_BASE, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig, type AttributeDefinition } from '@homaroma/types'
|
||||
import CategoryFilterManagerModal from './CategoryFilterManagerModal'
|
||||
|
||||
interface CategoryHybridCmsEditorProps {
|
||||
featuredSeries: CategoryFeaturedSeries
|
||||
@@ -23,6 +26,8 @@ interface CategoryHybridCmsEditorProps {
|
||||
filterConfig: CategoryFilterConfig
|
||||
onChangeFilterConfig: (val: CategoryFilterConfig) => void
|
||||
onOpenMediaModal: (target: 'featured_image' | 'featured_action') => void
|
||||
categoryId?: number
|
||||
categoryName?: string
|
||||
}
|
||||
|
||||
const AVAILABLE_ICONS = [
|
||||
@@ -42,9 +47,36 @@ export default function CategoryHybridCmsEditor({
|
||||
filterConfig,
|
||||
onChangeFilterConfig,
|
||||
onOpenMediaModal,
|
||||
categoryId,
|
||||
categoryName,
|
||||
}: CategoryHybridCmsEditorProps) {
|
||||
const [newFeatureTag, setNewFeatureTag] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'featured' | 'benefits' | 'filters'>('featured')
|
||||
const [isFilterManagerOpen, setIsFilterManagerOpen] = useState(false)
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([])
|
||||
const [loadingAttrs, setLoadingAttrs] = useState(false)
|
||||
|
||||
const fetchAttributes = async () => {
|
||||
if (!categoryId) return
|
||||
setLoadingAttrs(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions?categoryId=${categoryId}&lang=all`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAttributes(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load category attributes', e)
|
||||
} finally {
|
||||
setLoadingAttrs(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryId && activeTab === 'filters') {
|
||||
fetchAttributes()
|
||||
}
|
||||
}, [categoryId, activeTab])
|
||||
|
||||
// Feature Tags handlers
|
||||
const handleAddFeatureTag = () => {
|
||||
@@ -469,49 +501,173 @@ export default function CategoryHybridCmsEditor({
|
||||
|
||||
{/* ── TAB 3: Filter System Configuration ──────────────────────────── */}
|
||||
{activeTab === 'filters' && (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
<p className="text-xs text-slate-400">
|
||||
Select which filter facets should be visible in the Category Filter Sidebar on the website:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ key: 'showCategoryNav', label: 'Category Pills Navigation', desc: 'Display top category switch pills bar above the content' },
|
||||
{ key: 'showSeries', label: 'Suspension Series', desc: 'Allow shoppers to filter by series (PD, AB, GP, FX)' },
|
||||
{ key: 'showType', label: 'Product Type', desc: 'Rear Shock, Front Fork, Steering Damper checkboxes' },
|
||||
{ key: 'showColor', label: 'Color Swatches', desc: 'Visual color dots (Red, Orange, Blue, Yellow, Silver, Black)' },
|
||||
{ key: 'showFitment', label: 'Fitment Usage', desc: 'Street Use, Racing Use, Off-road Use tags' },
|
||||
{ key: 'showPrice', label: 'Price Range Slider', desc: 'Interactive min & max price range slider' },
|
||||
{ key: 'showStock', label: 'Stock Status', desc: 'In Stock & Pre-order checkboxes' },
|
||||
].map(({ key, label, desc }) => {
|
||||
const checked = (filterConfig as any)[key] ?? true
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => onChangeFilterConfig({ ...filterConfig, [key]: !checked })}
|
||||
className={`p-4 rounded-xl border-2 transition-all cursor-pointer flex flex-col justify-between ${
|
||||
checked
|
||||
? 'bg-orange-500/10 border-orange-500/40 text-white'
|
||||
: 'bg-white/[0.01] border-white/10 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-bold block">{label}</span>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">{desc}</p>
|
||||
</div>
|
||||
<div className={`w-4 h-4 rounded mt-0.5 flex items-center justify-center border ${
|
||||
checked ? 'bg-orange-500 border-orange-500 text-night-950 font-bold' : 'border-white/20'
|
||||
}`}>
|
||||
{checked && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Section 1: Standard Global Facet Presets */}
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h5 className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||
Standard Facet Presets (ตัวกรองสากลมาตรฐาน)
|
||||
</h5>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
ตัวกรองพื้นฐานที่มีใช้งานร่วมกันทุกหมวดหมู่สินค้า
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
key: 'showCategoryNav',
|
||||
label: 'Category Navigation Pills',
|
||||
desc: 'แสดงแถบเมนูปุ่มลัดสลับหมวดหมู่สินค้าด้านบนเนื้อหา'
|
||||
},
|
||||
{
|
||||
key: 'showPrice',
|
||||
label: 'Price Range Slider',
|
||||
desc: 'แถบสไลเดอร์กำหนดช่วงราคาสินค้าต่ำสุด-สูงสุด (Min - Max)'
|
||||
},
|
||||
{
|
||||
key: 'showStock',
|
||||
label: 'Stock Availability Status',
|
||||
desc: 'ตัวเลือกกรองสินค้าพร้อมส่ง (In Stock) และ สั่งผลิต (Pre-Order)'
|
||||
},
|
||||
].map(({ key, label, desc }) => {
|
||||
const checked = (filterConfig as any)[key] ?? true
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => onChangeFilterConfig({ ...filterConfig, [key]: !checked })}
|
||||
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between ${
|
||||
checked
|
||||
? 'bg-orange-500/10 border-orange-500/40 text-white shadow-lg shadow-orange-500/5'
|
||||
: 'bg-white/[0.01] border-white/10 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-bold block">{label}</span>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">{desc}</p>
|
||||
</div>
|
||||
<div className={`w-4 h-4 rounded mt-0.5 flex items-center justify-center border ${
|
||||
checked ? 'bg-orange-500 border-orange-500 text-night-950 font-bold' : 'border-white/20'
|
||||
}`}>
|
||||
{checked && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Dynamic Category Attributes & Facets */}
|
||||
<div className="space-y-4 pt-2 border-t border-white/5">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 p-4 rounded-2xl bg-gradient-to-r from-orange-500/10 via-amber-500/5 to-transparent border border-orange-500/30">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-1.5 rounded-lg bg-orange-500/20 text-orange-400">
|
||||
<Sliders className="w-4 h-4" />
|
||||
</span>
|
||||
<h4 className="text-sm font-bold text-white">
|
||||
Category Dynamic Facets (คุณลักษณะตัวกรองเฉพาะของหมวดหมู่นี้)
|
||||
</h4>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-xs text-slate-300">
|
||||
ปรับแต่งคุณลักษณะตัวกรองที่ตรงกับสินค้าในหมวดหมู่นี้ เช่น รุ่นรถ, ซีรีส์, วัสดุ, สเปกทางเทคนิค
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{categoryId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFilterManagerOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-orange-500 hover:bg-orange-600 text-night-950 font-bold text-xs flex items-center gap-2 shadow-lg shadow-orange-500/20 cursor-pointer transition-all shrink-0 hover:scale-105"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
<span>เปิดหน้าต่างปรับแต่งตัวกรองเต็มรูปแบบ (Full UI) ↗</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic bg-white/5 px-3 py-1.5 rounded-lg">
|
||||
บันทึกหมวดหมู่นี้ก่อนเพื่อเปิดใช้งานตัวจัดการ Attributes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attributes Live Preview Cards */}
|
||||
{categoryId && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 px-1">
|
||||
<span>คุณลักษณะที่เปิดเป็นตัวกรองในหมวดนี้ ({attributes.filter(a => a.isFilterable).length})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFilterManagerOpen(true)}
|
||||
className="text-orange-400 hover:underline flex items-center gap-1 cursor-pointer font-medium"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>เพิ่ม/แก้ไข ตัวกรอง</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingAttrs ? (
|
||||
<div className="py-6 text-center text-xs text-slate-500">
|
||||
กำลังโหลดคุณลักษณะตัวกรอง...
|
||||
</div>
|
||||
) : attributes.filter(a => a.isFilterable).length === 0 ? (
|
||||
<div className="p-4 rounded-xl border border-dashed border-white/10 text-center space-y-1 bg-white/[0.01]">
|
||||
<p className="text-xs text-slate-400">ยังไม่ได้เปิดใช้งานตัวกรองคุณลักษณะเฉพาะสำหรับหมวดหมู่นี้</p>
|
||||
<p className="text-[11px] text-slate-600">กดปุ่ม "เปิดหน้าต่างปรับแต่งตัวกรองเต็มรูปแบบ" ด้านบน เพื่อสร้างคุณลักษณะตัวกรองและตัวเลือก</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{attributes.filter(a => a.isFilterable).map((attr) => {
|
||||
const label = typeof attr.label === 'object' ? (attr.label as any)?.th || (attr.label as any)?.en : attr.label
|
||||
const options = Array.isArray(attr.options) ? attr.options : []
|
||||
|
||||
return (
|
||||
<div key={attr.id} className="p-3.5 rounded-xl bg-white/[0.02] border border-white/10 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-white">{label || attr.code}</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
{attr.code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.slice(0, 4).map((opt: any, idx: number) => {
|
||||
const val = typeof opt === 'string' ? opt : opt.value || opt.label?.th || String(idx)
|
||||
return (
|
||||
<span key={idx} className="text-[10px] px-2 py-0.5 rounded-full bg-orange-500/10 text-orange-300 border border-orange-500/20">
|
||||
{val}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{options.length > 4 && (
|
||||
<span className="text-[10px] text-slate-500 self-center">
|
||||
+{options.length - 4} เพิ่มเติม
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Page UI Category Filter & Attribute Manager Modal */}
|
||||
{categoryId && (
|
||||
<CategoryFilterManagerModal
|
||||
isOpen={isFilterManagerOpen}
|
||||
onClose={() => {
|
||||
setIsFilterManagerOpen(false)
|
||||
fetchAttributes()
|
||||
}}
|
||||
categoryId={categoryId}
|
||||
categoryName={categoryName || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -373,10 +373,15 @@ export default function CategoryEditPage() {
|
||||
})
|
||||
}
|
||||
|
||||
setSuccessToast(isNew ? 'Category created successfully!' : 'Category updated successfully!')
|
||||
setSuccessToast(isNew ? 'บันทึกสร้างหมวดหมู่สำเร็จ!' : 'บันทึกการเปลี่ยนแปลงสำเร็จ!')
|
||||
setTimeout(() => {
|
||||
navigate('/categories')
|
||||
}, 800)
|
||||
setSuccessToast(null)
|
||||
}, 3000)
|
||||
|
||||
// If newly created, seamlessly switch URL to the new category ID edit page
|
||||
if (isNew && targetId) {
|
||||
navigate(`/categories/${targetId}`, { replace: true })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Save error:', err)
|
||||
alert(err.message || 'An error occurred while saving.')
|
||||
@@ -934,6 +939,8 @@ export default function CategoryEditPage() {
|
||||
setMediaTarget(target)
|
||||
setIsMediaModalOpen(true)
|
||||
}}
|
||||
categoryId={!isNew && id ? parseInt(id) : undefined}
|
||||
categoryName={name.th || name.en || 'Category'}
|
||||
/>
|
||||
|
||||
{/* Card 3: Associated Products & Quick Link */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'
|
||||
import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||
import { Edit3, FileJson, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
||||
import ProductAttributeBulkJsonModal from '../components/ProductAttributeBulkJsonModal'
|
||||
|
||||
const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
|
||||
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const
|
||||
@@ -67,6 +68,7 @@ export default function ProductAttributesPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isJsonModalOpen, setIsJsonModalOpen] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
@@ -209,18 +211,36 @@ export default function ProductAttributesPage() {
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">Define multilingual specs, units, and select options for products.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(emptyDefinition())
|
||||
setActiveLang('th')
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('common.addNew', 'Add Definition')}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsJsonModalOpen(true)}
|
||||
className="px-4 py-2.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-sm font-semibold transition-all flex items-center gap-2 cursor-pointer shadow-sm hover:border-cyan-glow/30"
|
||||
>
|
||||
<FileJson className="w-4 h-4 text-cyan-glow" />
|
||||
<span>JSON Import / Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(emptyDefinition())
|
||||
setActiveLang('th')
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('common.addNew', 'Add Definition')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductAttributeBulkJsonModal
|
||||
isOpen={isJsonModalOpen}
|
||||
onClose={() => setIsJsonModalOpen(false)}
|
||||
definitions={definitions}
|
||||
categories={categories}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-2xl border border-rose-500/20 bg-rose-500/10 text-rose-400 text-sm">
|
||||
{error}
|
||||
|
||||
Reference in new issue
Block a user