This commit is contained in:
Your Name committed 2026-10-03 09:35:02 +07:00
1 parent 27cc56abaf
commit 0c3faaba29
11 files changed
+2223 -419

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,29 +501,44 @@ 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:
<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-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm: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' },
{
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-xl border-2 transition-all cursor-pointer flex flex-col justify-between ${
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'
? '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'
}`}
>
@@ -511,6 +558,115 @@ export default function CategoryHybridCmsEditor({
})}
</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>
)
+10 -3
View File
@@ -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 */}
+21 -1
View File
@@ -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,6 +211,15 @@ export default function ProductAttributesPage() {
</div>
<p className="text-slate-500 text-sm">Define multilingual specs, units, and select options for products.</p>
</div>
<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())
@@ -220,6 +231,15 @@ export default function ProductAttributesPage() {
{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">
+46 -1
View File
@@ -264,7 +264,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const [category, total] = await Promise.all([
const [category, total, availableFilters] = await Promise.all([
prisma.category.findUnique({
where: { id, deletedAt: null },
include: {
@@ -278,6 +278,14 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
translations: true,
variants: { orderBy: { price: "asc" as const } },
images: { orderBy: { order: "asc" as const } },
attributes: {
include: {
attributeDefinition: {
include: { translations: true },
},
translations: true,
},
},
},
orderBy: { id: "asc" },
skip,
@@ -289,10 +297,45 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
},
}),
prisma.product.count({ where: { categoryId: id, isDeleted: false } }),
prisma.attributeDefinition.findMany({
where: {
isFilterable: true,
OR: [{ categoryId: id }, { categoryId: null }],
},
include: {
translations: true,
},
orderBy: [
{ sortOrder: 'asc' },
{ code: 'asc' },
],
}),
]);
if (!category) return status(404, { message: "Category not found" });
const formattedAvailableFilters = availableFilters.map((def: any) => {
const transMap: Record<string, string> = {};
def.translations?.forEach((t: any) => {
transMap[t.langCode] = t.label;
});
const label = lang === 'all'
? (Object.keys(transMap).length > 0 ? transMap : { th: def.label, en: def.label })
: (transMap[lang] || def.label);
return {
id: def.id,
code: def.code,
label,
type: def.type,
unit: def.unit,
options: def.options,
isFilterable: def.isFilterable,
sortOrder: def.sortOrder,
categoryId: def.categoryId,
};
});
return {
category: {
id: category.id,
@@ -303,8 +346,10 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
featuredSeries: category.featuredSeries,
uspBenefits: category.uspBenefits,
filterConfig: category.filterConfig,
availableFilters: formattedAvailableFilters,
_count: category._count,
},
availableFilters: formattedAvailableFilters,
data: category.products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
+183 -24
View File
@@ -18,20 +18,94 @@ function parseId(value: string, label: string) {
}
function buildDefinitionPayload(payload: any) {
const baseLabel = typeof payload.label === 'string'
? payload.label
: (payload.label?.th || payload.label?.en || Object.values(payload.label || {})[0] || payload.translations?.th?.label || payload.translations?.en?.label || (typeof payload.translations?.th === 'string' ? payload.translations.th : '') || (typeof payload.translations?.en === 'string' ? payload.translations.en : '') || payload.code);
const baseDesc = typeof payload.description === 'string'
? payload.description
: (payload.description?.th || payload.description?.en || Object.values(payload.description || {})[0] || payload.translations?.th?.description || payload.translations?.en?.description || null);
return {
code: payload.code,
label: payload.label,
description: payload.description ?? null,
type: normalizeAttributeType(payload.type),
unit: payload.unit ?? null,
code: String(payload.code || '').trim(),
label: String(baseLabel || payload.code || '').trim(),
description: baseDesc ? String(baseDesc).trim() : null,
type: normalizeAttributeType(payload.type || 'TEXT'),
unit: payload.unit ? String(payload.unit).trim() : null,
options: payload.options ?? null,
isRequired: Boolean(payload.isRequired),
isFilterable: Boolean(payload.isFilterable),
sortOrder: typeof payload.sortOrder === 'number' ? payload.sortOrder : 0,
categoryId: payload.categoryId ?? null,
sortOrder: typeof payload.sortOrder === 'number' ? payload.sortOrder : (parseInt(payload.sortOrder) || 0),
categoryId: payload.categoryId ? parseInt(payload.categoryId) : null,
};
}
function extractTranslationsMap(payload: any): Record<string, { label: string; description?: string | null }> {
const result: Record<string, { label: string; description?: string | null }> = {};
for (const lang of SUPPORTED_LANGS) {
let label = '';
let description: string | null = null;
if (payload.translations?.[lang]) {
if (typeof payload.translations[lang] === 'string') {
label = payload.translations[lang].trim();
} else if (typeof payload.translations[lang] === 'object') {
label = (payload.translations[lang].label || '').trim();
if (payload.translations[lang].description) {
description = String(payload.translations[lang].description).trim();
}
}
}
if (!label && payload.label && typeof payload.label === 'object' && payload.label[lang]) {
label = String(payload.label[lang]).trim();
}
if (!description && payload.description && typeof payload.description === 'object' && payload.description[lang]) {
description = String(payload.description[lang]).trim();
}
if (label) {
result[lang] = { label, description };
}
}
// Fallback Thai label
if (!result['th'] && typeof payload.label === 'string' && payload.label.trim()) {
result['th'] = {
label: payload.label.trim(),
description: typeof payload.description === 'string' ? payload.description.trim() : null,
};
}
return result;
}
async function syncAttributeTranslations(definitionId: number, translationsMap: Record<string, { label: string; description?: string | null }>) {
for (const [langCode, val] of Object.entries(translationsMap)) {
if (!val.label) continue;
await prisma.attributeDefinitionTranslation.upsert({
where: {
attributeDefinitionId_langCode: {
attributeDefinitionId: definitionId,
langCode,
},
},
update: {
label: val.label,
description: val.description ?? null,
},
create: {
attributeDefinitionId: definitionId,
langCode,
label: val.label,
description: val.description ?? null,
},
});
}
}
async function assertProductAndDefinitionMatch(productId: number, attributeDefinitionId: number) {
const [product, definition] = await Promise.all([
prisma.product.findUnique({ where: { id: productId } }),
@@ -84,31 +158,106 @@ export const productAttributesRoute = new Elysia()
return status(400, { message: err.message });
}
})
.post("/product-attributes/definitions/bulk-import", async ({ body, status }) => {
try {
const payload = body as any;
const list = Array.isArray(payload)
? payload
: (Array.isArray(payload?.definitions) ? payload.definitions : (Array.isArray(payload?.attributes) ? payload.attributes : null));
if (!list || !Array.isArray(list) || list.length === 0) {
return status(400, { message: "Invalid payload: Expected a JSON array of attribute definition objects" });
}
let createdCount = 0;
let updatedCount = 0;
const results: any[] = [];
for (const item of list) {
if (!item || (!item.code && !item.name && !item.id)) continue;
const code = String(item.code || item.name || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
if (!code && !item.id) continue;
const defPayload = buildDefinitionPayload({
...item,
code: code || item.code,
});
const translationsMap = extractTranslationsMap(item);
let definition = null;
// Check if exists by ID or by Code
if (item.id && typeof item.id === 'number') {
definition = await prisma.attributeDefinition.findUnique({ where: { id: item.id } });
}
if (!definition && code) {
definition = await prisma.attributeDefinition.findUnique({ where: { code } });
}
if (definition) {
definition = await prisma.attributeDefinition.update({
where: { id: definition.id },
data: defPayload,
include: { category: true, translations: true },
});
updatedCount++;
} else {
definition = await prisma.attributeDefinition.create({
data: defPayload,
include: { category: true, translations: true },
});
createdCount++;
}
// Sync all translations
await syncAttributeTranslations(definition.id, translationsMap);
const updated = await prisma.attributeDefinition.findUnique({
where: { id: definition.id },
include: { category: true, translations: true },
});
results.push(localizeAttributeDefinition(updated, 'all'));
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
definitions: results,
};
} catch (err: any) {
console.error("Bulk import attribute definitions error:", err);
return status(500, { message: err?.message || "Failed to bulk import attribute definitions" });
}
}, {
body: t.Any(),
})
.post("/product-attributes/definitions", async ({ body, status }) => {
try {
const payload = body as any;
const defPayload = buildDefinitionPayload(payload);
const translationsMap = extractTranslationsMap(payload);
const definition = await prisma.attributeDefinition.create({
data: {
...buildDefinitionPayload(payload),
translations: payload.translations && typeof payload.translations === 'object'
? {
create: Object.entries(payload.translations)
.filter(([lang, value]) => SUPPORTED_LANGS.includes(lang as any) && value && typeof value === 'object' && (value as any).label)
.map(([lang, value]) => ({
langCode: lang,
label: (value as any).label,
description: (value as any).description ?? null,
})),
}
: undefined,
},
data: defPayload,
include: {
category: true,
translations: true,
},
});
return { success: true, definition };
await syncAttributeTranslations(definition.id, translationsMap);
const refreshed = await prisma.attributeDefinition.findUnique({
where: { id: definition.id },
include: { category: true, translations: true },
});
return { success: true, definition: refreshed };
} catch (err: any) {
if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
return status(500, { message: err?.message || "Failed to create attribute definition" });
@@ -120,16 +269,26 @@ export const productAttributesRoute = new Elysia()
try {
const id = parseId(params.id, "attribute definition ID");
const payload = body as any;
const defPayload = buildDefinitionPayload(payload);
const translationsMap = extractTranslationsMap(payload);
const definition = await prisma.attributeDefinition.update({
where: { id },
data: buildDefinitionPayload(payload),
data: defPayload,
include: {
category: true,
translations: true,
},
});
return { success: true, definition };
await syncAttributeTranslations(definition.id, translationsMap);
const refreshed = await prisma.attributeDefinition.findUnique({
where: { id: definition.id },
include: { category: true, translations: true },
});
return { success: true, definition: refreshed };
} catch (err: any) {
if (err.code === 'P2025') return status(404, { message: "Attribute definition not found" });
if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
@@ -3,7 +3,7 @@ import Image from 'next/image'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import type { Category, Product } from '@homaroma/types'
import type { Category, Product, AttributeDefinition } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
@@ -17,6 +17,7 @@ import { resolveLocalized } from '@/lib/localization'
// lang is extracted from params
type CategoryProductsResponse = {
category?: Category
availableFilters?: AttributeDefinition[]
data?: Product[]
}
@@ -299,6 +300,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
products={products}
categoryName={categoryName}
filterConfig={category.filterConfig}
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
layoutGridClass={getProductGridClass()}
/>
)}
@@ -1,16 +1,17 @@
"use client"
import React, { useState, useMemo } from 'react'
import type { Product, CategoryFilterConfig } from '@homaroma/types'
import type { Product, CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
import ProductCard from '@/components/ProductCard'
import CategoryFilterSidebar, { type FilterState } from './CategoryFilterSidebar'
import CategoryFilterSidebar, { type FilterState, type DynamicFacet, type DynamicFacetOption } from './CategoryFilterSidebar'
import CategoryToolbar from './CategoryToolbar'
import { FilterX } from 'lucide-react'
import { FilterX, X } from 'lucide-react'
interface CategoryCatalogContainerProps {
products: Product[]
categoryName: string
filterConfig?: CategoryFilterConfig | null
availableFilters?: AttributeDefinition[]
layoutGridClass?: string
}
@@ -29,6 +30,7 @@ export default function CategoryCatalogContainer({
products,
categoryName,
filterConfig,
availableFilters = [],
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
}: CategoryCatalogContainerProps) {
// Determine price bounds from products
@@ -48,104 +50,98 @@ export default function CategoryCatalogContainer({
return [Math.floor(min), Math.ceil(max)]
}, [products])
// Extract available series and models from product titles/details
const { availableSeries, availableModels, typeCounts, fitmentCounts, stockCounts } = useMemo(() => {
const seriesSet = new Set<string>()
const modelsSet = new Set<string>()
const types: Record<string, number> = {}
const fitments: Record<string, number> = {
'Street Use': 0,
'Racing Use': 0,
'Off-road Use': 0,
}
let inStock = 0
let preOrder = 0
// Compute Dynamic Facets for this Category from availableFilters
const dynamicFacets = useMemo<DynamicFacet[]>(() => {
if (!availableFilters || availableFilters.length === 0) return []
return availableFilters.map((attr) => {
// 1. Collect declared options from AttributeDefinition.options
const declaredOptions: Array<{ value: string; label: string }> = []
if (Array.isArray(attr.options)) {
attr.options.forEach((opt: any) => {
if (typeof opt === 'string') {
declaredOptions.push({ value: opt, label: opt })
} else if (opt && typeof opt === 'object') {
const val = opt.value || opt.label?.th || opt.label?.en || JSON.stringify(opt)
const label = opt.label?.th || opt.label?.en || opt.value || val
declaredOptions.push({ value: val, label })
}
})
}
// 2. Also inspect products in this category to see what values exist in product.attributes
const productFoundValues = new Set<string>()
products.forEach((p) => {
const name = extractText(p.name).toUpperCase()
const desc = extractText(p.description).toUpperCase()
const details = extractText(p.details).toUpperCase()
const combined = `${name} ${desc} ${details}`
// Series detection
const seriesMatches = name.match(/\b([A-Z]{2,4})\s+SERIES\b/)
if (seriesMatches && seriesMatches[1]) {
seriesSet.add(`${seriesMatches[1]} Series`)
} else if (name.includes('PD SERIES') || name.includes('PD-SERIES')) {
seriesSet.add('PD Series')
} else if (name.includes('AB SERIES')) {
seriesSet.add('AB Series')
} else if (name.includes('FX SERIES')) {
seriesSet.add('FX Series')
} else if (name.includes('GP SERIES')) {
seriesSet.add('GP Series')
p.attributes?.forEach((pa) => {
if (pa.attributeDefinitionId === attr.id || pa.attributeDefinition?.code === attr.code) {
if (pa.valueText) productFoundValues.add(pa.valueText)
}
// Models detection
const commonModels = ['VARIO 160', 'PCX 160', 'CRF 150L', 'KLX 150', 'NMAX 155', 'AEROX', 'BEAT', 'SCOOPY']
commonModels.forEach((m) => {
if (combined.includes(m)) modelsSet.add(m)
})
})
// Type detection
let detectedType = 'Other'
if (combined.includes('REAR SHOCK') || combined.includes('ABSORBER')) {
detectedType = 'Rear Shock Absorber'
} else if (combined.includes('FRONT FORK') || combined.includes('FORK')) {
detectedType = 'Front Fork'
} else if (combined.includes('DAMPER') || combined.includes('STEERING')) {
detectedType = 'Steering Damper'
// Combine options
const mergedOptionsMap = new Map<string, string>()
declaredOptions.forEach((o) => mergedOptionsMap.set(o.value.toLowerCase(), o.label))
productFoundValues.forEach((v) => {
if (!mergedOptionsMap.has(v.toLowerCase())) {
mergedOptionsMap.set(v.toLowerCase(), v)
}
types[detectedType] = (types[detectedType] || 0) + 1
})
// Fitment tags detection
if (combined.includes('STREET') || combined.includes('DAILY')) fitments['Street Use']++
if (combined.includes('RACE') || combined.includes('TRACK') || combined.includes('RACING')) fitments['Racing Use']++
if (combined.includes('OFF-ROAD') || combined.includes('DIRT') || combined.includes('MOTOCROSS') || combined.includes('TRAIL')) fitments['Off-road Use']++
// 3. Count matching products for each option
const optionsWithCounts: DynamicFacetOption[] = []
mergedOptionsMap.forEach((label, valLower) => {
let count = 0
products.forEach((p) => {
// Check product attributes
const hasAttr = p.attributes?.some((pa) => {
const codeMatch = pa.attributeDefinitionId === attr.id || pa.attributeDefinition?.code === attr.code
if (!codeMatch) return false
return pa.valueText?.toLowerCase() === valLower
})
// Stock
if (hasAttr) {
count++
} else {
// Text fallback on name/desc/details
const combined = `${extractText(p.name)} ${extractText(p.description)} ${extractText(p.details)}`.toLowerCase()
if (combined.includes(valLower)) {
count++
}
}
})
optionsWithCounts.push({
value: valLower,
label,
count,
})
})
return {
attr,
options: optionsWithCounts,
}
})
}, [availableFilters, products])
// Stock status counts
const stockCounts = useMemo(() => {
let inStock = 0
let preOrder = 0
products.forEach((p) => {
const stock = p.variants?.reduce((acc, v) => acc + (v.stock ?? 0), 0) ?? 0
if (stock > 0 || (p.variants?.length ?? 0) === 0) inStock++
else preOrder++
})
// Fallbacks if no series or models explicitly matched
if (seriesSet.size === 0) {
['PD Series', 'AB Series', 'FX Series', 'GP Series'].forEach((s) => seriesSet.add(s))
}
if (modelsSet.size === 0) {
['Vario 160', 'PCX 160', 'CRF 150L', 'KLX 150', 'NMAX 155', 'Aerox'].forEach((m) => modelsSet.add(m))
}
if (Object.keys(types).length === 0 || (Object.keys(types).length === 1 && types['Other'])) {
types['Rear Shock Absorber'] = Math.ceil(products.length * 0.6)
types['Front Fork'] = Math.floor(products.length * 0.25)
types['Steering Damper'] = Math.max(1, products.length - types['Rear Shock Absorber'] - types['Front Fork'])
}
if (fitments['Street Use'] === 0 && fitments['Racing Use'] === 0 && fitments['Off-road Use'] === 0) {
fitments['Street Use'] = Math.ceil(products.length * 0.5)
fitments['Racing Use'] = Math.ceil(products.length * 0.4)
fitments['Off-road Use'] = Math.ceil(products.length * 0.3)
}
return {
availableSeries: Array.from(seriesSet),
availableModels: Array.from(modelsSet),
typeCounts: types,
fitmentCounts: fitments,
stockCounts: { inStock: inStock || products.length, preOrder: preOrder || 0 },
}
return { inStock: inStock || products.length, preOrder: preOrder || 0 }
}, [products])
// Filters State
const initialFilters: FilterState = {
model: '',
year: '',
series: '',
types: [],
colors: [],
fitments: [],
priceRange: [priceBounds[0], priceBounds[1]],
stockStatus: [],
attributes: {},
}
const [filters, setFilters] = useState<FilterState>(initialFilters)
@@ -156,63 +152,30 @@ export default function CategoryCatalogContainer({
// Calculate active filter count
const activeFilterCount = useMemo(() => {
let count = 0
if (filters.model) count++
if (filters.year) count++
if (filters.series) count++
count += filters.types.length
count += filters.colors.length
count += filters.fitments.length
count += filters.stockStatus.length
if (filters.priceRange[1] < priceBounds[1]) count++
Object.values(filters.attributes).forEach((vals) => {
count += vals.length
})
return count
}, [filters, priceBounds])
// Remove individual active filter tag
const handleRemoveActiveAttribute = (code: string, value: string) => {
const current = filters.attributes[code] || []
setFilters({
...filters,
attributes: {
...filters.attributes,
[code]: current.filter((v) => v !== value),
},
})
}
// Apply filters & sorting
const filteredProducts = useMemo(() => {
return products
.filter((p) => {
const name = extractText(p.name).toUpperCase()
const desc = extractText(p.description).toUpperCase()
const details = extractText(p.details).toUpperCase()
const combined = `${name} ${desc} ${details}`
// Model filter
if (filters.model && !combined.includes(filters.model.toUpperCase())) return false
// Year filter
if (filters.year && !combined.includes(filters.year)) return false
// Series filter
if (filters.series && !combined.includes(filters.series.toUpperCase())) return false
// Types filter
if (filters.types.length > 0) {
const matchType = filters.types.some((t) => {
if (t === 'Rear Shock Absorber') return combined.includes('REAR') || combined.includes('ABSORBER') || combined.includes('SHOCK')
if (t === 'Front Fork') return combined.includes('FORK')
if (t === 'Steering Damper') return combined.includes('DAMPER')
return combined.includes(t.toUpperCase())
})
if (!matchType) return false
}
// Color filter
if (filters.colors.length > 0) {
const matchColor = filters.colors.some((c) => combined.includes(c.toUpperCase()))
if (!matchColor) return false
}
// Fitment filter
if (filters.fitments.length > 0) {
const matchFit = filters.fitments.some((f) => {
if (f === 'Street Use') return combined.includes('STREET') || combined.includes('DAILY')
if (f === 'Racing Use') return combined.includes('RACE') || combined.includes('TRACK') || combined.includes('RACING')
if (f === 'Off-road Use') return combined.includes('OFF-ROAD') || combined.includes('DIRT') || combined.includes('TRAIL')
return true
})
if (!matchFit) return false
}
// Stock status filter
if (filters.stockStatus.length > 0) {
const stock = p.variants?.reduce((acc, v) => acc + (v.stock ?? 0), 0) ?? 0
@@ -225,6 +188,25 @@ export default function CategoryCatalogContainer({
const price = p.variants?.[0]?.price ?? 0
if (price > filters.priceRange[1]) return false
// Dynamic Attributes filter
for (const [code, selectedValues] of Object.entries(filters.attributes)) {
if (!selectedValues || selectedValues.length === 0) continue
const match = p.attributes?.some((pa) => {
const isTargetAttr = pa.attributeDefinition?.code === code
if (!isTargetAttr) return false
const val = pa.valueText?.toLowerCase()
return val && selectedValues.some((sv) => sv.toLowerCase() === val)
})
if (!match) {
// Text fallback if attributes aren't linked yet
const combined = `${extractText(p.name)} ${extractText(p.description)} ${extractText(p.details)}`.toLowerCase()
const textMatch = selectedValues.some((sv) => combined.includes(sv.toLowerCase()))
if (!textMatch) return false
}
}
return true
})
.sort((a, b) => {
@@ -252,15 +234,12 @@ export default function CategoryCatalogContainer({
onReset={() => setFilters(initialFilters)}
config={filterConfig}
priceBounds={priceBounds}
typeCounts={typeCounts}
fitmentCounts={fitmentCounts}
stockCounts={stockCounts}
availableSeries={availableSeries}
availableModels={availableModels}
dynamicFacets={dynamicFacets}
/>
</div>
{/* Right Column: Toolbar + Product Grid */}
{/* Right Column: Catalog Grid & Toolbar */}
<div className="lg:col-span-9 space-y-6">
<CategoryToolbar
categoryName={categoryName}
@@ -274,74 +253,103 @@ export default function CategoryCatalogContainer({
activeFilterCount={activeFilterCount}
/>
{filteredProducts.length === 0 ? (
<div className="py-20 text-center rounded-2xl bg-surface-card border border-line-primary/10 p-8 space-y-4">
<div className="w-12 h-12 rounded-full bg-surface-page flex items-center justify-center mx-auto text-muted-text">
<FilterX className="w-6 h-6" />
{/* Active Filter Pills Bar */}
{activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2">
<span className="text-xs text-muted-text font-medium">Active Filters:</span>
{filters.stockStatus.map((s) => (
<span
key={s}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-primary/10 text-brand-primary border border-brand-primary/20"
>
<span>{s === 'in_stock' ? 'In Stock' : 'Pre-Order'}</span>
<button
type="button"
onClick={() =>
setFilters({
...filters,
stockStatus: filters.stockStatus.filter((item) => item !== s),
})
}
className="hover:text-text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
</span>
))}
{Object.entries(filters.attributes).map(([code, vals]) =>
vals.map((v) => (
<span
key={`${code}-${v}`}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-surface-card border border-line-primary/30 text-text-main"
>
<span className="text-[10px] text-muted-text font-mono uppercase">{code}:</span>
<span>{v}</span>
<button
type="button"
onClick={() => handleRemoveActiveAttribute(code, v)}
className="text-muted-text hover:text-text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
</span>
))
)}
<button
type="button"
onClick={() => setFilters(initialFilters)}
className="text-xs text-muted-text hover:text-brand-primary underline ml-2 cursor-pointer"
>
Clear all
</button>
</div>
)}
{/* Products Grid */}
{filteredProducts.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-line-primary/20 rounded-3xl space-y-3 bg-surface-card/30">
<FilterX className="w-10 h-10 text-muted-text/50 mx-auto" />
<div className="space-y-1">
<h4 className="text-base font-bold text-text-main">No products found</h4>
<p className="text-xs text-muted-text max-w-sm mx-auto">
No products matched your selected filters. Try clearing some filters to see more results.
<p className="text-base font-bold text-text-main">No products found matching active filters</p>
<p className="text-xs text-muted-text">
Try adjusting your price range or clearing selected attribute filters.
</p>
</div>
<button
type="button"
onClick={() => setFilters(initialFilters)}
className="ui-button-primary px-4 py-2 text-xs font-bold inline-flex items-center gap-1.5"
className="mt-3 px-4 py-2 rounded-xl bg-brand-primary text-surface-card font-bold text-xs hover:opacity-90 transition-opacity"
>
Reset Filters
Reset All Filters
</button>
</div>
) : (
<div
className={
viewMode === 'list'
? 'grid grid-cols-1 gap-4'
: 'grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6'
}
>
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
{filteredProducts.map((product) => (
<div key={product.id} className="h-full">
<ProductCard product={product} />
</div>
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</div>
{/* Mobile Filter Drawer Modal */}
{/* Mobile Drawer Filter Modal */}
{isMobileFilterOpen && (
<div className="fixed inset-0 z-50 lg:hidden flex">
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileFilterOpen(false)}
/>
<div className="relative ml-auto w-full max-w-xs h-full bg-surface-card shadow-2xl overflow-y-auto z-10 flex flex-col justify-between">
<div className="fixed inset-0 z-[120] lg:hidden bg-black/70 backdrop-blur-sm flex justify-end">
<div className="w-full max-w-xs h-full bg-surface-card overflow-y-auto shadow-2xl animate-in slide-in-from-right duration-200">
<CategoryFilterSidebar
filters={filters}
onChangeFilters={setFilters}
onReset={() => setFilters(initialFilters)}
config={filterConfig}
priceBounds={priceBounds}
typeCounts={typeCounts}
fitmentCounts={fitmentCounts}
stockCounts={stockCounts}
availableSeries={availableSeries}
availableModels={availableModels}
dynamicFacets={dynamicFacets}
isMobileModal={true}
onCloseMobileModal={() => setIsMobileFilterOpen(false)}
/>
<div className="p-4 border-t border-line-primary/10 bg-surface-page sticky bottom-0">
<button
type="button"
onClick={() => setIsMobileFilterOpen(false)}
className="ui-button-primary w-full py-3 text-xs font-bold"
>
Apply Filters ({filteredProducts.length} Results)
</button>
</div>
</div>
</div>
)}
@@ -1,18 +1,31 @@
"use client"
import React, { useState } from 'react'
import { RotateCcw, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
import type { CategoryFilterConfig } from '@homaroma/types'
import { RotateCcw, ChevronDown, ChevronUp, Check, X, SlidersHorizontal } from 'lucide-react'
import type { CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
export interface DynamicFacetOption {
value: string
label: string
count: number
}
export interface DynamicFacet {
attr: AttributeDefinition
options: DynamicFacetOption[]
}
export interface FilterState {
model: string
year: string
series: string
types: string[]
colors: string[]
fitments: string[]
priceRange: [number, number]
stockStatus: string[] // 'in_stock' | 'pre_order'
attributes: Record<string, string[]> // { [attrCode]: selectedValues[] }
// Legacy compatibility
model?: string
year?: string
series?: string
types?: string[]
colors?: string[]
fitments?: string[]
}
interface CategoryFilterSidebarProps {
@@ -21,64 +34,31 @@ interface CategoryFilterSidebarProps {
onReset: () => void
config?: CategoryFilterConfig | null
priceBounds: [number, number]
typeCounts: Record<string, number>
fitmentCounts: Record<string, number>
stockCounts: { inStock: number; preOrder: number }
availableSeries: string[]
availableModels: string[]
dynamicFacets?: DynamicFacet[]
isMobileModal?: boolean
onCloseMobileModal?: () => void
}
const COLOR_SWATCHES = [
{ name: 'Red', hex: '#EF4444' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Blue', hex: '#3B82F6' },
{ name: 'Yellow', hex: '#EAB308' },
{ name: 'Silver', hex: '#E2E8F0' },
{ name: 'Black', hex: '#171717' },
]
export default function CategoryFilterSidebar({
filters,
onChangeFilters,
onReset,
config,
priceBounds,
typeCounts,
fitmentCounts,
stockCounts,
availableSeries,
availableModels,
dynamicFacets = [],
isMobileModal = false,
onCloseMobileModal,
}: CategoryFilterSidebarProps) {
const showSeries = config?.showSeries ?? true
const showType = config?.showType ?? true
const showColor = config?.showColor ?? true
const showFitment = config?.showFitment ?? true
const showPrice = config?.showPrice ?? true
const showStock = config?.showStock ?? true
const toggleType = (t: string) => {
const next = filters.types.includes(t)
? filters.types.filter((item) => item !== t)
: [...filters.types, t]
onChangeFilters({ ...filters, types: next })
}
// Collapsed sections tracker
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggleColor = (c: string) => {
const next = filters.colors.includes(c)
? filters.colors.filter((item) => item !== c)
: [...filters.colors, c]
onChangeFilters({ ...filters, colors: next })
}
const toggleFitment = (f: string) => {
const next = filters.fitments.includes(f)
? filters.fitments.filter((item) => item !== f)
: [...filters.fitments, f]
onChangeFilters({ ...filters, fitments: next })
const toggleCollapse = (key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}
const toggleStock = (s: string) => {
@@ -88,11 +68,36 @@ export default function CategoryFilterSidebar({
onChangeFilters({ ...filters, stockStatus: next })
}
const toggleAttribute = (code: string, val: string) => {
const current = filters.attributes[code] || []
const next = current.includes(val)
? current.filter((v) => v !== val)
: [...current, val]
onChangeFilters({
...filters,
attributes: {
...filters.attributes,
[code]: next,
},
})
}
const getFacetTitle = (attr: AttributeDefinition): string => {
if (typeof attr.label === 'string') return attr.label
if (attr.label && typeof attr.label === 'object') {
const rec = attr.label as Record<string, string>
return rec.th || rec.en || attr.code
}
return attr.code
}
return (
<aside className={`w-full space-y-6 ${isMobileModal ? 'p-6' : 'p-5 rounded-2xl bg-surface-card border border-line-primary/10 shadow-sm'}`}>
{/* Top Header */}
<div className="flex items-center justify-between pb-4 border-b border-line-primary/10">
<h3 className="text-sm font-black uppercase tracking-wider text-text-main flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-brand-primary" />
<span>FILTERS</span>
</h3>
<div className="flex items-center gap-3">
@@ -116,173 +121,86 @@ export default function CategoryFilterSidebar({
</div>
</div>
{/* Model & Year Dropdowns */}
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text">
Motorcycle Model
</label>
<select
value={filters.model}
onChange={(e) => onChangeFilters({ ...filters, model: e.target.value })}
className="w-full bg-surface-page border border-line-primary/20 rounded-xl px-3 py-2 text-xs font-semibold text-text-main outline-none focus:border-brand-primary transition-colors cursor-pointer"
>
<option value="">All Models</option>
{availableModels.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
{/* ── Dynamic Category Attribute Facets ─────────────────────────── */}
{dynamicFacets.map((facet) => {
const title = getFacetTitle(facet.attr)
const isCollapsed = collapsed[facet.attr.code]
const selected = filters.attributes[facet.attr.code] || []
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text">
Year
</label>
<select
value={filters.year}
onChange={(e) => onChangeFilters({ ...filters, year: e.target.value })}
className="w-full bg-surface-page border border-line-primary/20 rounded-xl px-3 py-2 text-xs font-semibold text-text-main outline-none focus:border-brand-primary transition-colors cursor-pointer"
>
<option value="">All Years</option>
<option value="2026">2026</option>
<option value="2025">2025</option>
<option value="2024">2024</option>
<option value="2023">2023</option>
</select>
</div>
</div>
{/* Series Filter */}
{showSeries && availableSeries.length > 0 && (
<div className="space-y-2 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Suspension Series
</label>
<select
value={filters.series}
onChange={(e) => onChangeFilters({ ...filters, series: e.target.value })}
className="w-full bg-surface-page border border-line-primary/20 rounded-xl px-3 py-2 text-xs font-semibold text-text-main outline-none focus:border-brand-primary transition-colors cursor-pointer"
>
<option value="">All Series</option>
{availableSeries.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Product Type Filter */}
{showType && Object.keys(typeCounts).length > 0 && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Type
</label>
<div className="space-y-2">
{Object.entries(typeCounts).map(([type, count]) => {
const checked = filters.types.includes(type)
return (
<label
key={type}
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors"
<div key={facet.attr.id || facet.attr.code} className="space-y-2.5 pt-2 border-t border-line-primary/10 first:border-t-0 first:pt-0">
<div
onClick={() => toggleCollapse(facet.attr.code)}
className="flex items-center justify-between cursor-pointer group"
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={() => toggleType(type)}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
/>
<span>{type}</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({count})</span>
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text group-hover:text-text-main transition-colors cursor-pointer">
{title}
</label>
)
})}
</div>
</div>
)}
{/* Color Swatches */}
{showColor && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Color
</label>
<div className="flex flex-wrap gap-2.5 items-center">
{COLOR_SWATCHES.map((swatch) => {
const active = filters.colors.includes(swatch.name)
return (
<button
key={swatch.name}
type="button"
onClick={() => toggleColor(swatch.name)}
title={swatch.name}
className={`w-6 h-6 rounded-full transition-all relative flex items-center justify-center cursor-pointer shadow-sm ${
active ? 'ring-2 ring-offset-2 ring-brand-primary scale-110' : 'hover:scale-105'
}`}
style={{ backgroundColor: swatch.hex }}
>
{active && (
<Check className={`w-3.5 h-3.5 ${swatch.name === 'Silver' || swatch.name === 'Yellow' ? 'text-black' : 'text-white'} stroke-[3]`} />
{selected.length > 0 && (
<span className="w-4 h-4 rounded-full bg-brand-primary text-[10px] text-surface-card font-bold flex items-center justify-center">
{selected.length}
</span>
)}
</div>
<button type="button" className="text-muted-text group-hover:text-text-main">
{isCollapsed ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronUp className="w-3.5 h-3.5" />}
</button>
)
})}
</div>
</div>
)}
{/* Fitment Usage */}
{showFitment && Object.keys(fitmentCounts).length > 0 && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Fitment
</label>
<div className="space-y-2">
{Object.entries(fitmentCounts).map(([fitment, count]) => {
const checked = filters.fitments.includes(fitment)
{!isCollapsed && (
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
{facet.options.length === 0 ? (
<p className="text-xs text-muted-text/60 italic py-1">ไม่มีตัวเลือกสำหรับสินค้านี้</p>
) : (
facet.options.map((opt) => {
const isChecked = selected.includes(opt.value)
return (
<label
key={fitment}
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors"
key={opt.value}
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors py-1 px-1.5 rounded-lg hover:bg-surface-page/50"
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={checked}
onChange={() => toggleFitment(fitment)}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
checked={isChecked}
onChange={() => toggleAttribute(facet.attr.code, opt.value)}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5 accent-brand-primary"
/>
<span>{fitment}</span>
<span className={isChecked ? 'font-bold text-brand-primary' : ''}>
{opt.label}
</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({count})</span>
<span className="text-[10px] text-muted-text font-mono">
({opt.count})
</span>
</label>
)
})}
</div>
})
)}
</div>
)}
</div>
)
})}
{/* Price Range Slider */}
{/* ── Universal Facet: Price Range Slider ───────────────────────── */}
{showPrice && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<div className="space-y-3 pt-2 border-t border-line-primary/10">
<div className="flex items-center justify-between">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text">
Price Range
</label>
<span className="text-[10px] font-mono text-brand-primary font-bold">
Up to {filters.priceRange[1].toLocaleString()}
<span className="text-xs font-mono font-bold text-text-main">
฿{filters.priceRange[0].toLocaleString()} - ฿{filters.priceRange[1].toLocaleString()}
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds[0]}
max={priceBounds[1]}
step={Math.max(10, Math.floor((priceBounds[1] - priceBounds[0]) / 50))}
value={filters.priceRange[1]}
onChange={(e) =>
onChangeFilters({
@@ -290,20 +208,21 @@ export default function CategoryFilterSidebar({
priceRange: [filters.priceRange[0], Number(e.target.value)],
})
}
className="w-full accent-brand-primary cursor-pointer"
className="w-full h-1.5 bg-surface-page rounded-lg appearance-none cursor-pointer accent-brand-primary"
/>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-text">
<span>{priceBounds[0].toLocaleString()}</span>
<span>{priceBounds[1].toLocaleString()}</span>
<div className="flex justify-between text-[10px] text-muted-text font-mono">
<span>฿{priceBounds[0].toLocaleString()}</span>
<span>฿{priceBounds[1].toLocaleString()}</span>
</div>
</div>
</div>
)}
{/* Stock Status */}
{/* ── Universal Facet: Stock Availability Status ────────────────── */}
{showStock && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Stock Status
Stock Availability
</label>
<div className="space-y-2">
<label className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors">
@@ -312,12 +231,9 @@ export default function CategoryFilterSidebar({
type="checkbox"
checked={filters.stockStatus.includes('in_stock')}
onChange={() => toggleStock('in_stock')}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5 accent-brand-primary"
/>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" />
<span>In Stock</span>
</span>
<span>In Stock (พร้อมส่ง)</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({stockCounts.inStock})</span>
</label>
@@ -328,12 +244,9 @@ export default function CategoryFilterSidebar({
type="checkbox"
checked={filters.stockStatus.includes('pre_order')}
onChange={() => toggleStock('pre_order')}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5 accent-brand-primary"
/>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-500 inline-block" />
<span>Pre Order</span>
</span>
<span>Pre-Order (สั่งผลิต)</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({stockCounts.preOrder})</span>
</label>
+1
View File
@@ -66,6 +66,7 @@ export interface Category {
featuredSeries?: CategoryFeaturedSeries | null
uspBenefits?: CategoryUspBenefit[] | null
filterConfig?: CategoryFilterConfig | null
availableFilters?: AttributeDefinition[]
_count?: { products: number }
translations?: { langCode: string; name: string }[]
createdAt?: string