x
This commit is contained in:
1 parent
27cc56abaf
commit
0c3faaba29
11 files changed
+2286
-482
No files matched your search
@@ -0,0 +1,785 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
Download,
|
||||
Upload,
|
||||
FileJson,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Loader2,
|
||||
Sliders,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types';
|
||||
|
||||
interface ProductAttributeBulkJsonModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
definitions: AttributeDefinition[];
|
||||
categories: Category[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const SUPPORTED_LOCALES = [
|
||||
{ code: 'th', label: 'TH', full: 'ภาษาไทย' },
|
||||
{ code: 'en', label: 'EN', full: 'English' },
|
||||
{ code: 'ja', label: 'JA', full: '日本語' },
|
||||
{ code: 'zh', label: 'ZH', full: '中文' },
|
||||
{ code: 'vi', label: 'VI', full: 'Tiếng Việt' },
|
||||
{ code: 'ko', label: 'KO', full: '한국어' },
|
||||
{ code: 'lo', label: 'LO', full: 'ພາສາລາວ' },
|
||||
{ code: 'it', label: 'IT', full: 'Italiano' },
|
||||
];
|
||||
|
||||
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const;
|
||||
|
||||
export default function ProductAttributeBulkJsonModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
definitions,
|
||||
categories,
|
||||
onSuccess,
|
||||
}: ProductAttributeBulkJsonModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'import' | 'export' | 'ai'>('import');
|
||||
const [copiedJson, setCopiedJson] = useState(false);
|
||||
const [copiedPrompt, setCopiedPrompt] = useState(false);
|
||||
const [importJsonText, setImportJsonText] = useState('');
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const categoryNameMap = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
categories.forEach((cat) => {
|
||||
const name = typeof cat.name === 'string'
|
||||
? cat.name
|
||||
: ((cat.name as any)?.th || (cat.name as any)?.en || `Category #${cat.id}`);
|
||||
map.set(cat.id, name);
|
||||
});
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
// 1. Export Data Generation
|
||||
const exportData = useMemo(() => {
|
||||
return definitions.map((def) => {
|
||||
const transMap: Record<string, { label: string; description?: string }> = {};
|
||||
const defLabelObj = typeof def.label === 'object' && def.label !== null ? (def.label as any) : null;
|
||||
const defDescObj = typeof def.description === 'object' && def.description !== null ? (def.description as any) : null;
|
||||
const baseLabel = typeof def.label === 'string' ? def.label : (defLabelObj?.th || def.code);
|
||||
|
||||
for (const loc of SUPPORTED_LOCALES) {
|
||||
const labelVal = defLabelObj?.[loc.code] || (Array.isArray(def.translations) ? def.translations.find((t: any) => t.langCode === loc.code)?.label : '');
|
||||
const descVal = defDescObj?.[loc.code] || (Array.isArray(def.translations) ? def.translations.find((t: any) => t.langCode === loc.code)?.description : '');
|
||||
|
||||
if (labelVal || descVal) {
|
||||
transMap[loc.code] = {
|
||||
label: labelVal || baseLabel,
|
||||
description: descVal || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!transMap['th'] && baseLabel) {
|
||||
transMap['th'] = { label: baseLabel };
|
||||
}
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
code: def.code,
|
||||
type: def.type,
|
||||
unit: def.unit || null,
|
||||
isRequired: Boolean(def.isRequired),
|
||||
isFilterable: Boolean(def.isFilterable),
|
||||
sortOrder: def.sortOrder ?? 0,
|
||||
categoryId: def.categoryId ?? null,
|
||||
label: baseLabel,
|
||||
description: typeof def.description === 'string' ? def.description : (defDescObj?.th || null),
|
||||
options: def.options || undefined,
|
||||
translations: transMap,
|
||||
};
|
||||
});
|
||||
}, [definitions]);
|
||||
|
||||
const exportJsonString = useMemo(() => {
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}, [exportData]);
|
||||
|
||||
// Sample Data for Load Sample Button
|
||||
const sampleImportData = useMemo(() => [
|
||||
{
|
||||
code: 'motorcycle_model',
|
||||
type: 'SELECT',
|
||||
isFilterable: true,
|
||||
isRequired: false,
|
||||
sortOrder: 1,
|
||||
categoryId: null,
|
||||
label: 'รุ่นรถมอเตอร์ไซค์',
|
||||
description: 'ระบุรุ่นรถที่สามารถติดตั้งได้',
|
||||
options: [
|
||||
{ th: 'CBR 650R', en: 'CBR 650R' },
|
||||
{ th: 'MT-09', en: 'MT-09' },
|
||||
{ th: 'Ninja ZX-6R', en: 'Ninja ZX-6R' },
|
||||
{ th: 'XMAX 300', en: 'XMAX 300' },
|
||||
{ th: 'Forza 350', en: 'Forza 350' },
|
||||
{ th: 'S1000RR', en: 'S1000RR' }
|
||||
],
|
||||
translations: {
|
||||
th: { label: 'รุ่นรถมอเตอร์ไซค์', description: 'ระบุรุ่นรถที่สามารถติดตั้งได้' },
|
||||
en: { label: 'Motorcycle Model', description: 'Compatible motorcycle models' },
|
||||
ja: { label: '適合車種', description: '対応するバイクモデル' },
|
||||
zh: { label: '适用车型', description: '适配摩托车型号' }
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'suspension_series',
|
||||
type: 'SELECT',
|
||||
isFilterable: true,
|
||||
isRequired: false,
|
||||
sortOrder: 2,
|
||||
categoryId: null,
|
||||
label: 'ซีรีส์โช้คอัพ',
|
||||
description: 'ประเภทและรุ่นซีรีส์ของโช้คอัพ',
|
||||
options: [
|
||||
{ th: 'G-Series', en: 'G-Series' },
|
||||
{ th: 'Top Line', en: 'Top Line' },
|
||||
{ th: 'Racing Edition', en: 'Racing Edition' },
|
||||
{ th: 'Ecoline', en: 'Ecoline' }
|
||||
],
|
||||
translations: {
|
||||
th: { label: 'ซีรีส์โช้คอัพ', description: 'ประเภทและรุ่นซีรีส์ของโช้คอัพ' },
|
||||
en: { label: 'Suspension Series', description: 'Shock absorber product series' }
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'material',
|
||||
type: 'TEXT',
|
||||
isFilterable: true,
|
||||
isRequired: false,
|
||||
sortOrder: 3,
|
||||
categoryId: null,
|
||||
label: 'วัสดุหลัก',
|
||||
description: 'วัสดุของชิ้นงานหรือส่วนประกอบ',
|
||||
translations: {
|
||||
th: { label: 'วัสดุหลัก', description: 'วัสดุของชิ้นงานหรือส่วนประกอบ' },
|
||||
en: { label: 'Material', description: 'Primary component material' },
|
||||
ja: { label: '素材', description: '主要な構成素材' },
|
||||
zh: { label: '材质', description: '主要构成材质' }
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'piston_diameter',
|
||||
type: 'NUMBER',
|
||||
unit: 'mm',
|
||||
isFilterable: false,
|
||||
isRequired: false,
|
||||
sortOrder: 4,
|
||||
categoryId: null,
|
||||
label: 'ขนาดลูกสูบ (Piston)',
|
||||
description: 'เส้นผ่านศูนย์กลางลูกสูบภายในโช้คอัพ',
|
||||
translations: {
|
||||
th: { label: 'ขนาดลูกสูบ (Piston)', description: 'เส้นผ่านศูนย์กลางลูกสูบภายในโช้คอัพ' },
|
||||
en: { label: 'Piston Diameter', description: 'Internal piston diameter in millimeters' }
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'is_adjustable',
|
||||
type: 'BOOLEAN',
|
||||
isFilterable: true,
|
||||
isRequired: false,
|
||||
sortOrder: 5,
|
||||
categoryId: null,
|
||||
label: 'ปรับตั้งค่าได้ (Adjustable)',
|
||||
description: 'สามารถปรับ Compression / Rebound / Preload ได้',
|
||||
translations: {
|
||||
th: { label: 'ปรับตั้งค่าได้ (Adjustable)', description: 'สามารถปรับ Compression / Rebound / Preload ได้' },
|
||||
en: { label: 'Adjustable', description: 'Supports Compression / Rebound / Preload tuning' }
|
||||
}
|
||||
}
|
||||
], []);
|
||||
|
||||
// AI Prompt Generator
|
||||
const aiPrompt = useMemo(() => {
|
||||
return `You are a Senior E-commerce Data Architect. Generate a JSON Array of Product Attribute Definitions for our store catalog.
|
||||
|
||||
### Technical JSON Schema Requirements:
|
||||
Each item in the array MUST follow this structure:
|
||||
- \`code\`: (string, lowercase snake_case, unique e.g. "motorcycle_model", "material", "burn_time", "spring_rate")
|
||||
- \`type\`: ("SELECT" | "TEXT" | "NUMBER" | "BOOLEAN" | "JSON")
|
||||
- \`unit\`: (string or null, e.g. "mm", "kg", "hrs", "cc")
|
||||
- \`isRequired\`: (boolean, true if compulsory for all products)
|
||||
- \`isFilterable\`: (boolean, true if this attribute should be a facet filter on category/catalog pages)
|
||||
- \`sortOrder\`: (number, display ordering priority)
|
||||
- \`categoryId\`: (number or null, null = global attribute for all categories)
|
||||
- \`label\`: (string or object with language keys)
|
||||
- \`description\`: (string or object with language keys)
|
||||
- \`options\`: (required when type === "SELECT", array of objects [{ "th": "...", "en": "..." }] or strings)
|
||||
- \`translations\`: (object with keys for languages "th", "en", "ja", "zh", "vi", "ko", "lo", "it", containing { "label": "...", "description": "..." })
|
||||
|
||||
### Sample Output Format:
|
||||
\`\`\`json
|
||||
${JSON.stringify(sampleImportData, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
Please output ONLY the valid JSON Array without surrounding markdown or explanations.`;
|
||||
}, [sampleImportData]);
|
||||
|
||||
// 2. Parsed Import Items
|
||||
const parsedImportData = useMemo(() => {
|
||||
if (!importJsonText.trim()) return null;
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(importJsonText);
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: (Array.isArray(raw.definitions) ? raw.definitions : (Array.isArray(raw.attributes) ? raw.attributes : null));
|
||||
|
||||
if (!list || !Array.isArray(list)) {
|
||||
setParseError('JSON must be an Array of attribute definition objects e.g. [ { "code": "...", "type": "SELECT", "label": "..." } ]');
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = list.map((item: any) => {
|
||||
const code = String(item.code || item.name || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
||||
const rawType = String(item.type || 'TEXT').toUpperCase();
|
||||
const type = ATTRIBUTE_TYPES.includes(rawType as any) ? rawType : 'TEXT';
|
||||
|
||||
const trans: Record<string, { label: string; description?: string }> = {};
|
||||
if (item.translations && typeof item.translations === 'object') {
|
||||
for (const [k, v] of Object.entries(item.translations)) {
|
||||
if (typeof v === 'string') {
|
||||
trans[k.toLowerCase()] = { label: v };
|
||||
} else if (v && typeof v === 'object' && typeof (v as any).label === 'string') {
|
||||
trans[k.toLowerCase()] = {
|
||||
label: (v as any).label,
|
||||
description: (v as any).description ? String((v as any).description) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseLabel = typeof item.label === 'string'
|
||||
? item.label
|
||||
: (item.label?.th || item.label?.en || trans['th']?.label || trans['en']?.label || code);
|
||||
|
||||
const baseDesc = typeof item.description === 'string'
|
||||
? item.description
|
||||
: (item.description?.th || item.description?.en || trans['th']?.description || null);
|
||||
|
||||
if (!trans['th'] && baseLabel) {
|
||||
trans['th'] = { label: baseLabel, description: baseDesc || undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'number' ? item.id : undefined,
|
||||
code,
|
||||
type,
|
||||
unit: item.unit ? String(item.unit) : null,
|
||||
isRequired: Boolean(item.isRequired),
|
||||
isFilterable: item.isFilterable !== undefined ? Boolean(item.isFilterable) : true,
|
||||
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : (parseInt(item.sortOrder) || 0),
|
||||
categoryId: item.categoryId ? Number(item.categoryId) : null,
|
||||
label: baseLabel,
|
||||
description: baseDesc,
|
||||
options: Array.isArray(item.options) ? item.options : undefined,
|
||||
translations: trans,
|
||||
};
|
||||
});
|
||||
|
||||
const invalidCode = normalized.find((item) => !item.code);
|
||||
if (invalidCode) {
|
||||
setParseError('Each attribute definition must have a non-empty "code" field (e.g. "material", "burn_time").');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
setParseError('No attribute definition items found in the array.');
|
||||
return null;
|
||||
}
|
||||
|
||||
setParseError(null);
|
||||
return normalized;
|
||||
} catch (err: any) {
|
||||
setParseError(`Invalid JSON Syntax: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}, [importJsonText]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyJson = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportJsonString);
|
||||
setCopiedJson(true);
|
||||
setTimeout(() => setCopiedJson(false), 2000);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleCopyPrompt = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(aiPrompt);
|
||||
setCopiedPrompt(true);
|
||||
setTimeout(() => setCopiedPrompt(false), 2000);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDownloadJson = () => {
|
||||
const blob = new Blob([exportJsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `product_attributes_export_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
if (content) {
|
||||
setImportJsonText(content);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSample = () => {
|
||||
setImportJsonText(JSON.stringify(sampleImportData, null, 2));
|
||||
};
|
||||
|
||||
const handleLoadCurrent = () => {
|
||||
setImportJsonText(exportJsonString);
|
||||
};
|
||||
|
||||
const handleImportSubmit = async () => {
|
||||
if (!parsedImportData || parsedImportData.length === 0) return;
|
||||
setSubmitting(true);
|
||||
setResultMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions/bulk-import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsedImportData),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
setResultMessage(`นำเข้าสำเร็จเรียบร้อย! (สร้างใหม่ ${data.createdCount || 0} รายการ, อัปเดต ${data.updatedCount || 0} รายการ)`);
|
||||
onSuccess();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} else {
|
||||
setParseError(data.message || 'Failed to bulk import attribute definitions');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Import attribute definitions error:', err);
|
||||
setParseError(err.message || 'Network error occurred');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in">
|
||||
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 sm:p-6 border-b border-white/10 flex items-center justify-between bg-night-950/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow">
|
||||
<FileJson className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>Product Attributes JSON Manager</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
|
||||
Bulk JSON Format
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Import, Export, and Paste JSON to create or update attributes with multi-language specs and options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-white/10 bg-night-950/40 px-6 pt-3 gap-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'import'
|
||||
? 'border-cyan-glow text-cyan-glow'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>📥 Import / วาง JSON เพื่อสร้างใหม่</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('export')}
|
||||
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'export'
|
||||
? 'border-cyan-glow text-cyan-glow'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>📤 Export JSON ({definitions.length} รายการ)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('ai')}
|
||||
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'ai'
|
||||
? 'border-purple-400 text-purple-300'
|
||||
: 'border-transparent text-slate-400 hover:text-purple-300'
|
||||
}`}
|
||||
>
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<span>🤖 AI Prompt Template</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1 space-y-5">
|
||||
|
||||
{/* IMPORT TAB */}
|
||||
{activeTab === 'import' && (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Action Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Upload .json File</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadSample}
|
||||
className="px-3.5 py-1.5 bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-300 border border-cyan-glow/30 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Sliders className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Load Sample JSON (5 Examples)</span>
|
||||
</button>
|
||||
|
||||
{definitions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadCurrent}
|
||||
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Load Current Attributes ({definitions.length})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<span>รองรับ:</span>
|
||||
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">
|
||||
SELECT, TEXT, NUMBER, BOOLEAN
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Editor Textarea */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={importJsonText}
|
||||
onChange={(e) => setImportJsonText(e.target.value)}
|
||||
rows={9}
|
||||
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 focus:border-cyan-glow/50 rounded-xl resize-y text-slate-200 placeholder:text-slate-600 outline-none transition-all shadow-inner"
|
||||
placeholder={`วาง JSON Array ของ Attribute Definitions ที่นี่ เช่น:
|
||||
[
|
||||
{
|
||||
"code": "motorcycle_model",
|
||||
"type": "SELECT",
|
||||
"isFilterable": true,
|
||||
"isRequired": false,
|
||||
"label": "รุ่นรถมอเตอร์ไซค์",
|
||||
"options": [
|
||||
{ "th": "CBR 650R", "en": "CBR 650R" },
|
||||
{ "th": "MT-09", "en": "MT-09" }
|
||||
],
|
||||
"translations": {
|
||||
"th": { "label": "รุ่นรถมอเตอร์ไซค์" },
|
||||
"en": { "label": "Motorcycle Model" }
|
||||
}
|
||||
}
|
||||
]`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Box */}
|
||||
{parseError && (
|
||||
<div className="p-3.5 bg-rose-500/10 border border-rose-500/30 rounded-xl flex items-center gap-3 text-xs text-rose-300 animate-fade-in">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||
<span>{parseError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Box */}
|
||||
{resultMessage && (
|
||||
<div className="p-3.5 bg-emerald-500/10 border border-emerald-500/30 rounded-xl flex items-center gap-3 text-xs text-emerald-300 animate-fade-in">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||
<span>{resultMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Preview List */}
|
||||
{parsedImportData && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-emerald-400">
|
||||
Parsed Successfully: Found {parsedImportData.length} Attribute Definition(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-56 overflow-y-auto pr-1">
|
||||
{parsedImportData.map((item, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3.5 rounded-xl bg-night-950/80 border border-white/5 space-y-2 text-xs flex flex-col justify-between"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-bold text-white truncate text-[13px]">
|
||||
{item.label || item.code}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20 shrink-0">
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
{item.unit && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-white/5 text-amber-400 border border-amber-400/20 shrink-0">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-400 font-mono">
|
||||
<span>code: <strong className="text-slate-200">{item.code}</strong></span>
|
||||
{item.isFilterable && (
|
||||
<span className="flex items-center gap-0.5 text-cyan-glow text-[10px]">
|
||||
<Filter className="w-3 h-3" /> Filterable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="text-[11px] text-slate-400 line-clamp-1">{item.description}</p>
|
||||
)}
|
||||
|
||||
{/* Category Binding */}
|
||||
<p className="text-[10px] text-slate-500">
|
||||
Scope:{' '}
|
||||
<span className="text-slate-300">
|
||||
{item.categoryId ? (categoryNameMap.get(item.categoryId) || `Category #${item.categoryId}`) : 'Global (All Categories)'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Options preview for SELECT type */}
|
||||
{item.type === 'SELECT' && Array.isArray(item.options) && item.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{item.options.slice(0, 4).map((opt: any, oIdx: number) => {
|
||||
const optLabel = typeof opt === 'string' ? opt : (opt.th || opt.en || Object.values(opt)[0] || '');
|
||||
return (
|
||||
<span key={oIdx} className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-slate-300 border border-white/10">
|
||||
{optLabel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{item.options.length > 4 && (
|
||||
<span className="text-[9px] text-slate-500 px-1">+{item.options.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language translation coverage badges */}
|
||||
<div className="flex flex-wrap gap-1 pt-1.5 border-t border-white/5">
|
||||
{SUPPORTED_LOCALES.map((loc) => {
|
||||
const hasTrans = Boolean(item.translations?.[loc.code]?.label);
|
||||
return (
|
||||
<span
|
||||
key={loc.code}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
hasTrans
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-white/5 text-slate-600 border border-white/5'
|
||||
}`}
|
||||
>
|
||||
{loc.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXPORT TAB */}
|
||||
{activeTab === 'export' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-400">
|
||||
Current {definitions.length} Product Attribute Definitions exported with multi-language specs and options:
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJson}
|
||||
className="px-3.5 py-1.5 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 hover:shadow-lg hover:shadow-cyan-glow/20 cursor-pointer"
|
||||
>
|
||||
{copiedJson ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedJson ? 'Copied JSON!' : 'Copy JSON'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadJson}
|
||||
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Download .json</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Display */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
value={exportJsonString}
|
||||
rows={13}
|
||||
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 rounded-xl resize-y text-slate-200 outline-none select-all shadow-inner"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI PROMPT TAB */}
|
||||
{activeTab === 'ai' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-purple-300">
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<span>One-Click AI Attribute Generator Prompt (ChatGPT / Claude / Gemini)</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPrompt}
|
||||
className="px-3.5 py-1.5 bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border border-purple-500/30 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
{copiedPrompt ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedPrompt ? 'Copied Prompt!' : 'Copy Prompt for AI'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/20 text-[11px] text-slate-300 leading-relaxed space-y-2">
|
||||
<p>
|
||||
💡 <strong>วิธีใช้งาน:</strong> กดปุ่ม <em>"Copy Prompt for AI"</em> แล้วนำไปวางใน ChatGPT หรือ Claude พร้อมแนบรายชื่อสเปกสินค้า หรือแคตตาล็อกอะไหล่/สินค้าที่คุณต้องการสร้าง AI จะแปลงเป็น JSON Array ที่มีโครงสร้างถูกต้องและแปลให้ครบ 8 ภาษาอัตโนมัติ จากนั้นนำ JSON กลับมาวางในแท็บ <strong>Import</strong> เพื่อสร้าง Attribute ทั้งหมดได้ในคลิกเดียว
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
value={aiPrompt}
|
||||
rows={11}
|
||||
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 rounded-xl resize-y text-slate-200 outline-none select-all shadow-inner"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-5 sm:p-6 border-t border-white/10 bg-night-950/80 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 rounded-xl text-xs font-semibold transition-all cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
{activeTab === 'import' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!parsedImportData || Boolean(parseError) || submitting}
|
||||
onClick={handleImportSubmit}
|
||||
className="px-5 py-2 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-2 hover:shadow-lg hover:shadow-cyan-glow/20 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Importing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Import {parsedImportData?.length || 0} Attribute Definitions</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
X,
|
||||
Sliders,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit3,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type AttributeDefinition } from '@homaroma/types'
|
||||
|
||||
interface CategoryFilterManagerModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
categoryId?: number
|
||||
categoryName: string
|
||||
}
|
||||
|
||||
type AttributeType = 'SELECT' | 'TEXT' | 'NUMBER' | 'BOOLEAN'
|
||||
|
||||
interface FormState {
|
||||
id?: number
|
||||
code: string
|
||||
labelTh: string
|
||||
labelEn: string
|
||||
type: AttributeType
|
||||
unit: string
|
||||
isFilterable: boolean
|
||||
isCategorySpecific: boolean
|
||||
options: string[]
|
||||
newOptionInput: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: FormState = {
|
||||
code: '',
|
||||
labelTh: '',
|
||||
labelEn: '',
|
||||
type: 'SELECT',
|
||||
unit: '',
|
||||
isFilterable: true,
|
||||
isCategorySpecific: true,
|
||||
options: [],
|
||||
newOptionInput: '',
|
||||
}
|
||||
|
||||
export default function CategoryFilterManagerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categoryId,
|
||||
categoryName,
|
||||
}: CategoryFilterManagerModalProps) {
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterScope, setFilterScope] = useState<'all' | 'category' | 'global'>('all')
|
||||
|
||||
// Form State
|
||||
const [form, setForm] = useState<FormState>(DEFAULT_FORM)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const fetchDefinitions = async () => {
|
||||
if (!categoryId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions?categoryId=${categoryId}&lang=all`)
|
||||
if (!res.ok) throw new Error('Failed to load category attribute definitions')
|
||||
const data = await res.json()
|
||||
setDefinitions(Array.isArray(data) ? data : [])
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading attributes')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && categoryId) {
|
||||
fetchDefinitions()
|
||||
resetForm()
|
||||
}
|
||||
}, [isOpen, categoryId])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({
|
||||
...DEFAULT_FORM,
|
||||
isCategorySpecific: Boolean(categoryId),
|
||||
})
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleEdit = (item: AttributeDefinition) => {
|
||||
const rawOptions = Array.isArray(item.options) ? item.options : []
|
||||
const optionsList: string[] = rawOptions.map((opt: any) => {
|
||||
if (typeof opt === 'string') return opt
|
||||
if (opt && typeof opt === 'object') {
|
||||
return opt.value || opt.label?.th || opt.label?.en || JSON.stringify(opt)
|
||||
}
|
||||
return String(opt)
|
||||
})
|
||||
|
||||
const labelObj = typeof item.label === 'object' && item.label ? (item.label as any) : {}
|
||||
const labelTh = labelObj.th || (typeof item.label === 'string' ? item.label : '')
|
||||
const labelEn = labelObj.en || ''
|
||||
|
||||
setForm({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
labelTh,
|
||||
labelEn,
|
||||
type: (['SELECT', 'TEXT', 'NUMBER', 'BOOLEAN'].includes(item.type) ? item.type : 'SELECT') as AttributeType,
|
||||
unit: item.unit || '',
|
||||
isFilterable: item.isFilterable,
|
||||
isCategorySpecific: item.categoryId != null,
|
||||
options: optionsList,
|
||||
newOptionInput: '',
|
||||
})
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleToggleFilterable = async (item: AttributeDefinition) => {
|
||||
try {
|
||||
const updatedValue = !item.isFilterable
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
type: item.type,
|
||||
unit: item.unit,
|
||||
options: item.options,
|
||||
isRequired: item.isRequired,
|
||||
isFilterable: updatedValue,
|
||||
sortOrder: item.sortOrder,
|
||||
categoryId: item.categoryId,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update filterable state')
|
||||
|
||||
setDefinitions((prev) =>
|
||||
prev.map((d) => (d.id === item.id ? { ...d, isFilterable: updatedValue } : d))
|
||||
)
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to update attribute')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('คุณแน่ใจหรือไม่ว่าต้องการลบ Attribute ตัวกรองนี้?')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete attribute definition')
|
||||
await fetchDefinitions()
|
||||
if (form.id === id) resetForm()
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete attribute')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddOption = () => {
|
||||
const trimmed = form.newOptionInput.trim()
|
||||
if (!trimmed) return
|
||||
if (!form.options.includes(trimmed)) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
options: [...prev.options, trimmed],
|
||||
newOptionInput: '',
|
||||
}))
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, newOptionInput: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
options: prev.options.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.code.trim()) {
|
||||
alert('กรุณาระบุ Attribute Code (เช่น motorcycle_model, material)')
|
||||
return
|
||||
}
|
||||
if (!form.labelTh.trim() && !form.labelEn.trim()) {
|
||||
alert('กรุณาระบุชื่อ Label อย่างน้อย 1 ภาษา (ไทย หรือ อังกฤษ)')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
code: form.code.trim().toLowerCase().replace(/\s+/g, '_'),
|
||||
type: form.type,
|
||||
unit: form.unit.trim() || null,
|
||||
isFilterable: form.isFilterable,
|
||||
categoryId: form.isCategorySpecific && categoryId ? categoryId : null,
|
||||
label: {
|
||||
th: form.labelTh.trim() || form.labelEn.trim(),
|
||||
en: form.labelEn.trim() || form.labelTh.trim(),
|
||||
},
|
||||
options: form.type === 'SELECT'
|
||||
? form.options.map((opt) => ({
|
||||
value: opt.toLowerCase().replace(/\s+/g, '_'),
|
||||
label: { th: opt, en: opt }
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const url = form.id
|
||||
? `${API_BASE}/product-attributes/definitions/${form.id}`
|
||||
: `${API_BASE}/product-attributes/definitions`
|
||||
const method = form.id ? 'PUT' : 'POST'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => null)
|
||||
throw new Error(errData?.message || 'Failed to save attribute definition')
|
||||
}
|
||||
|
||||
await fetchDefinitions()
|
||||
resetForm()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Save failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter definitions by search and scope
|
||||
const filteredDefinitions = useMemo(() => {
|
||||
return definitions.filter((def) => {
|
||||
// Scope filter
|
||||
if (filterScope === 'category' && def.categoryId !== categoryId) return false
|
||||
if (filterScope === 'global' && def.categoryId != null) return false
|
||||
|
||||
// Search term
|
||||
if (!searchTerm.trim()) return true
|
||||
const query = searchTerm.toLowerCase()
|
||||
const codeMatch = def.code.toLowerCase().includes(query)
|
||||
const labelStr = typeof def.label === 'string'
|
||||
? def.label
|
||||
: Object.values(def.label || {}).join(' ')
|
||||
const labelMatch = labelStr.toLowerCase().includes(query)
|
||||
return codeMatch || labelMatch
|
||||
})
|
||||
}, [definitions, filterScope, searchTerm, categoryId])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-6 bg-night-950/80 backdrop-blur-md animate-fade-in">
|
||||
<div className="relative w-full max-w-6xl max-h-[92vh] flex flex-col rounded-3xl bg-night-900 border border-white/10 shadow-2xl overflow-hidden">
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-2xl bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
<Sliders className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
ตัวจัดการตัวกรอง & คุณลักษณะเฉพาะหมวดหมู่
|
||||
</h2>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono font-bold bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||
{categoryName} (#{categoryId})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
กำหนดค่า Facets, Attribute Definitions และ Options สำหรับการค้นหาและกรองสินค้าในหมวดหมู่นี้
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body: Two Column Layout */}
|
||||
<div className="flex-1 overflow-y-auto grid grid-cols-1 lg:grid-cols-12 divide-y lg:divide-y-0 lg:divide-x divide-white/10">
|
||||
|
||||
{/* Left Column: Attribute List & Facet Toggles (7 cols) */}
|
||||
<div className="lg:col-span-7 p-6 space-y-5">
|
||||
{/* Search & Scope Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="ค้นหา Code หรือชื่อคุณลักษณะ..."
|
||||
className="admin-input w-full text-xs pl-9 pr-3 py-2 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 rounded-xl bg-white/5 border border-white/10 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterScope('all')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
filterScope === 'all'
|
||||
? 'bg-orange-500 text-night-950 font-bold shadow'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ทั้งหมด ({definitions.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterScope('category')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
filterScope === 'category'
|
||||
? 'bg-orange-500 text-night-950 font-bold shadow'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
เฉพาะหมวดนี้
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterScope('global')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
filterScope === 'global'
|
||||
? 'bg-orange-500 text-night-950 font-bold shadow'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ใช้ร่วมกัน (Global)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
{loading ? (
|
||||
<div className="py-16 flex flex-col items-center justify-center gap-2 text-slate-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-orange-400" />
|
||||
<span className="text-xs">กำลังโหลดคุณลักษณะตัวกรอง...</span>
|
||||
</div>
|
||||
) : filteredDefinitions.length === 0 ? (
|
||||
<div className="py-16 px-4 text-center border-2 border-dashed border-white/10 rounded-2xl space-y-2">
|
||||
<Sliders className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-sm font-semibold text-slate-300">ไม่พบคุณลักษณะตัวกรอง</p>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
คุณสามารถสร้าง Attribute ตัวกรองใหม่สำหรับหมวดหมู่นี้ได้ที่แบบฟอร์มด้านขวา
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDefinitions.map((item) => {
|
||||
const isCatSpecific = item.categoryId === categoryId
|
||||
const labelTh = typeof item.label === 'object' ? (item.label as any)?.th : item.label
|
||||
const labelEn = typeof item.label === 'object' ? (item.label as any)?.en : ''
|
||||
const rawOpts = Array.isArray(item.options) ? item.options : []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`p-4 rounded-2xl border transition-all ${
|
||||
form.id === item.id
|
||||
? 'bg-orange-500/10 border-orange-500/50 shadow-md shadow-orange-500/5'
|
||||
: 'bg-white/[0.02] border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold text-white">
|
||||
{labelTh || item.code}
|
||||
</span>
|
||||
{labelEn && (
|
||||
<span className="text-xs text-slate-400">({labelEn})</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-white/5 text-slate-300 border border-white/10">
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-cyan-glow/10 text-cyan-400 border border-cyan-glow/20">
|
||||
{item.type}
|
||||
</span>
|
||||
{isCatSpecific ? (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
เฉพาะหมวดนี้
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options pills if SELECT */}
|
||||
{rawOpts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{rawOpts.slice(0, 8).map((opt: any, idx: number) => {
|
||||
const text = typeof opt === 'string' ? opt : opt.value || opt.label?.th || opt.label?.en || String(idx)
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] bg-white/5 border border-white/10 text-slate-300"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{rawOpts.length > 8 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] text-slate-500">
|
||||
+{rawOpts.length - 8} เพิ่มเติม
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Filterable Switch Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleFilterable(item)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
|
||||
item.isFilterable
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20'
|
||||
: 'bg-white/5 text-slate-500 border-white/10 hover:text-slate-300'
|
||||
}`}
|
||||
title="สลับการแสดงผลเป็นตัวกรองในหน้าเว็บ"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${item.isFilterable ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'}`} />
|
||||
<span>{item.isFilterable ? 'เป็นตัวกรอง' : 'ไม่กรอง'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(item)}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||
title="แก้ไข Attribute"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isCatSpecific && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/10 text-slate-400 hover:text-rose-400 transition-colors cursor-pointer"
|
||||
title="ลบ Attribute"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Add / Edit Form (5 cols) */}
|
||||
<div className="lg:col-span-5 p-6 space-y-5 bg-white/[0.01]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-orange-400" />
|
||||
<span>{isEditing ? 'แก้ไขคุณลักษณะตัวกรอง' : 'สร้างคุณลักษณะตัวกรองใหม่'}</span>
|
||||
</h3>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="text-xs text-slate-400 hover:text-white underline cursor-pointer"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* Attribute Code */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Attribute Code <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
placeholder="e.g. motorcycle_model, material, damping"
|
||||
disabled={isEditing}
|
||||
className="admin-input w-full text-xs font-mono py-2 disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
รหัสภาษาอังกฤษสำหรับสืบค้นใน Query String (เช่น ?attr_motorcycle_model=...)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Labels (TH & EN) */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">ชื่อภาษาไทย (TH)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.labelTh}
|
||||
onChange={(e) => setForm({ ...form, labelTh: e.target.value })}
|
||||
placeholder="เช่น รุ่นรถมอเตอร์ไซค์"
|
||||
className="admin-input w-full text-xs py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">ชื่อภาษาอังกฤษ (EN)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.labelEn}
|
||||
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
|
||||
placeholder="e.g. Motorcycle Model"
|
||||
className="admin-input w-full text-xs py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type & Unit */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">ประเภทข้อมูล (Type)</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setForm({ ...form, type: e.target.value as AttributeType })}
|
||||
className="admin-input w-full text-xs py-2 cursor-pointer"
|
||||
>
|
||||
<option value="SELECT">SELECT (ตัวเลือก Dropdown/Checkbox)</option>
|
||||
<option value="TEXT">TEXT (ข้อความทั่วไป)</option>
|
||||
<option value="NUMBER">NUMBER (ตัวเลข / ขนาด)</option>
|
||||
<option value="BOOLEAN">BOOLEAN (เปิด/ปิด ใช่/ไม่ใช่)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">หน่วยนับ (Unit)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.unit}
|
||||
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
||||
placeholder="เช่น mm, kg, CC"
|
||||
className="admin-input w-full text-xs py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope & Filterable Switches */}
|
||||
<div className="p-3.5 rounded-2xl bg-white/[0.02] border border-white/10 space-y-3">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-xs font-bold text-white block">เปิดใช้งานเป็นตัวกรอง (isFilterable)</span>
|
||||
<span className="text-[10px] text-slate-400">แสดงผลใน Sidebar ตัวกรองของหน้าสินค้า</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isFilterable}
|
||||
onChange={(e) => setForm({ ...form, isFilterable: e.target.checked })}
|
||||
className="w-4 h-4 accent-orange-500 rounded cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="border-t border-white/5 pt-2">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-xs font-bold text-white block">เฉพาะหมวดหมู่นี้ ({categoryName})</span>
|
||||
<span className="text-[10px] text-slate-400">หากปิด จะสามารถนำไปใช้ได้กับทุกหมวดหมู่ (Global)</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isCategorySpecific}
|
||||
onChange={(e) => setForm({ ...form, isCategorySpecific: e.target.checked })}
|
||||
className="w-4 h-4 accent-orange-500 rounded cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options Management (When type is SELECT) */}
|
||||
{form.type === 'SELECT' && (
|
||||
<div className="space-y-2 p-3.5 rounded-2xl bg-white/[0.02] border border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-white">
|
||||
รายการตัวเลือก (Options) ({form.options.length})
|
||||
</label>
|
||||
<span className="text-[10px] text-slate-500">พิมพ์แล้วกด Enter หรือกดปุ่มเพิ่ม</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.newOptionInput}
|
||||
onChange={(e) => setForm({ ...form, newOptionInput: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddOption()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. CBR 650R, MT-09, ZX-6R, Billet 6061"
|
||||
className="admin-input flex-1 text-xs py-1.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddOption}
|
||||
className="px-3 py-1.5 rounded-xl bg-orange-500/20 hover:bg-orange-500/30 text-orange-400 text-xs font-bold border border-orange-500/30 cursor-pointer"
|
||||
>
|
||||
เพิ่ม
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pills List */}
|
||||
<div className="flex flex-wrap gap-1.5 max-h-36 overflow-y-auto pt-1">
|
||||
{form.options.map((opt, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs bg-white/5 border border-white/10 text-white"
|
||||
>
|
||||
<span>{opt}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveOption(idx)}
|
||||
className="text-slate-400 hover:text-rose-400 cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{form.options.length === 0 && (
|
||||
<p className="text-[11px] text-slate-500 italic py-1">
|
||||
ยังไม่มีตัวเลือก (สามารถเพิ่มตัวเลือกเพื่อให้ลูกค้าคลิกเลือกในตัวกรองได้)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-night-950 font-bold text-xs flex items-center justify-center gap-2 shadow-lg shadow-orange-500/20 cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>{isEditing ? 'บันทึกการแก้ไข' : 'สร้างคุณลักษณะตัวกรอง'}</span>
|
||||
</button>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-xs font-semibold text-slate-300 cursor-pointer"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-white/[0.01] flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>เชื่อมต่อกับระบบฐานข้อมูล Product Attributes แบบ Real-time</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
ปิดหน้าต่าง
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Sparkles,
|
||||
Shield,
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
Trash2,
|
||||
Image as ImageIcon,
|
||||
CheckCircle2,
|
||||
Tag
|
||||
Tag,
|
||||
Sliders,
|
||||
Maximize2
|
||||
} from 'lucide-react'
|
||||
import type { CategoryFeaturedSeries, CategoryUspBenefit, CategoryFilterConfig } from '@homaroma/types'
|
||||
import { API_BASE, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig, type AttributeDefinition } from '@homaroma/types'
|
||||
import CategoryFilterManagerModal from './CategoryFilterManagerModal'
|
||||
|
||||
interface CategoryHybridCmsEditorProps {
|
||||
featuredSeries: CategoryFeaturedSeries
|
||||
@@ -23,6 +26,8 @@ interface CategoryHybridCmsEditorProps {
|
||||
filterConfig: CategoryFilterConfig
|
||||
onChangeFilterConfig: (val: CategoryFilterConfig) => void
|
||||
onOpenMediaModal: (target: 'featured_image' | 'featured_action') => void
|
||||
categoryId?: number
|
||||
categoryName?: string
|
||||
}
|
||||
|
||||
const AVAILABLE_ICONS = [
|
||||
@@ -42,9 +47,36 @@ export default function CategoryHybridCmsEditor({
|
||||
filterConfig,
|
||||
onChangeFilterConfig,
|
||||
onOpenMediaModal,
|
||||
categoryId,
|
||||
categoryName,
|
||||
}: CategoryHybridCmsEditorProps) {
|
||||
const [newFeatureTag, setNewFeatureTag] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'featured' | 'benefits' | 'filters'>('featured')
|
||||
const [isFilterManagerOpen, setIsFilterManagerOpen] = useState(false)
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([])
|
||||
const [loadingAttrs, setLoadingAttrs] = useState(false)
|
||||
|
||||
const fetchAttributes = async () => {
|
||||
if (!categoryId) return
|
||||
setLoadingAttrs(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions?categoryId=${categoryId}&lang=all`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAttributes(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load category attributes', e)
|
||||
} finally {
|
||||
setLoadingAttrs(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryId && activeTab === 'filters') {
|
||||
fetchAttributes()
|
||||
}
|
||||
}, [categoryId, activeTab])
|
||||
|
||||
// Feature Tags handlers
|
||||
const handleAddFeatureTag = () => {
|
||||
@@ -469,49 +501,173 @@ export default function CategoryHybridCmsEditor({
|
||||
|
||||
{/* ── TAB 3: Filter System Configuration ──────────────────────────── */}
|
||||
{activeTab === 'filters' && (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
<p className="text-xs text-slate-400">
|
||||
Select which filter facets should be visible in the Category Filter Sidebar on the website:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ key: 'showCategoryNav', label: 'Category Pills Navigation', desc: 'Display top category switch pills bar above the content' },
|
||||
{ key: 'showSeries', label: 'Suspension Series', desc: 'Allow shoppers to filter by series (PD, AB, GP, FX)' },
|
||||
{ key: 'showType', label: 'Product Type', desc: 'Rear Shock, Front Fork, Steering Damper checkboxes' },
|
||||
{ key: 'showColor', label: 'Color Swatches', desc: 'Visual color dots (Red, Orange, Blue, Yellow, Silver, Black)' },
|
||||
{ key: 'showFitment', label: 'Fitment Usage', desc: 'Street Use, Racing Use, Off-road Use tags' },
|
||||
{ key: 'showPrice', label: 'Price Range Slider', desc: 'Interactive min & max price range slider' },
|
||||
{ key: 'showStock', label: 'Stock Status', desc: 'In Stock & Pre-order checkboxes' },
|
||||
].map(({ key, label, desc }) => {
|
||||
const checked = (filterConfig as any)[key] ?? true
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => onChangeFilterConfig({ ...filterConfig, [key]: !checked })}
|
||||
className={`p-4 rounded-xl border-2 transition-all cursor-pointer flex flex-col justify-between ${
|
||||
checked
|
||||
? 'bg-orange-500/10 border-orange-500/40 text-white'
|
||||
: 'bg-white/[0.01] border-white/10 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-bold block">{label}</span>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">{desc}</p>
|
||||
</div>
|
||||
<div className={`w-4 h-4 rounded mt-0.5 flex items-center justify-center border ${
|
||||
checked ? 'bg-orange-500 border-orange-500 text-night-950 font-bold' : 'border-white/20'
|
||||
}`}>
|
||||
{checked && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Section 1: Standard Global Facet Presets */}
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h5 className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||
Standard Facet Presets (ตัวกรองสากลมาตรฐาน)
|
||||
</h5>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
ตัวกรองพื้นฐานที่มีใช้งานร่วมกันทุกหมวดหมู่สินค้า
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
key: 'showCategoryNav',
|
||||
label: 'Category Navigation Pills',
|
||||
desc: 'แสดงแถบเมนูปุ่มลัดสลับหมวดหมู่สินค้าด้านบนเนื้อหา'
|
||||
},
|
||||
{
|
||||
key: 'showPrice',
|
||||
label: 'Price Range Slider',
|
||||
desc: 'แถบสไลเดอร์กำหนดช่วงราคาสินค้าต่ำสุด-สูงสุด (Min - Max)'
|
||||
},
|
||||
{
|
||||
key: 'showStock',
|
||||
label: 'Stock Availability Status',
|
||||
desc: 'ตัวเลือกกรองสินค้าพร้อมส่ง (In Stock) และ สั่งผลิต (Pre-Order)'
|
||||
},
|
||||
].map(({ key, label, desc }) => {
|
||||
const checked = (filterConfig as any)[key] ?? true
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => onChangeFilterConfig({ ...filterConfig, [key]: !checked })}
|
||||
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between ${
|
||||
checked
|
||||
? 'bg-orange-500/10 border-orange-500/40 text-white shadow-lg shadow-orange-500/5'
|
||||
: 'bg-white/[0.01] border-white/10 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-bold block">{label}</span>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">{desc}</p>
|
||||
</div>
|
||||
<div className={`w-4 h-4 rounded mt-0.5 flex items-center justify-center border ${
|
||||
checked ? 'bg-orange-500 border-orange-500 text-night-950 font-bold' : 'border-white/20'
|
||||
}`}>
|
||||
{checked && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Dynamic Category Attributes & Facets */}
|
||||
<div className="space-y-4 pt-2 border-t border-white/5">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 p-4 rounded-2xl bg-gradient-to-r from-orange-500/10 via-amber-500/5 to-transparent border border-orange-500/30">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-1.5 rounded-lg bg-orange-500/20 text-orange-400">
|
||||
<Sliders className="w-4 h-4" />
|
||||
</span>
|
||||
<h4 className="text-sm font-bold text-white">
|
||||
Category Dynamic Facets (คุณลักษณะตัวกรองเฉพาะของหมวดหมู่นี้)
|
||||
</h4>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-xs text-slate-300">
|
||||
ปรับแต่งคุณลักษณะตัวกรองที่ตรงกับสินค้าในหมวดหมู่นี้ เช่น รุ่นรถ, ซีรีส์, วัสดุ, สเปกทางเทคนิค
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{categoryId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFilterManagerOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-orange-500 hover:bg-orange-600 text-night-950 font-bold text-xs flex items-center gap-2 shadow-lg shadow-orange-500/20 cursor-pointer transition-all shrink-0 hover:scale-105"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
<span>เปิดหน้าต่างปรับแต่งตัวกรองเต็มรูปแบบ (Full UI) ↗</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic bg-white/5 px-3 py-1.5 rounded-lg">
|
||||
บันทึกหมวดหมู่นี้ก่อนเพื่อเปิดใช้งานตัวจัดการ Attributes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attributes Live Preview Cards */}
|
||||
{categoryId && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 px-1">
|
||||
<span>คุณลักษณะที่เปิดเป็นตัวกรองในหมวดนี้ ({attributes.filter(a => a.isFilterable).length})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFilterManagerOpen(true)}
|
||||
className="text-orange-400 hover:underline flex items-center gap-1 cursor-pointer font-medium"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>เพิ่ม/แก้ไข ตัวกรอง</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingAttrs ? (
|
||||
<div className="py-6 text-center text-xs text-slate-500">
|
||||
กำลังโหลดคุณลักษณะตัวกรอง...
|
||||
</div>
|
||||
) : attributes.filter(a => a.isFilterable).length === 0 ? (
|
||||
<div className="p-4 rounded-xl border border-dashed border-white/10 text-center space-y-1 bg-white/[0.01]">
|
||||
<p className="text-xs text-slate-400">ยังไม่ได้เปิดใช้งานตัวกรองคุณลักษณะเฉพาะสำหรับหมวดหมู่นี้</p>
|
||||
<p className="text-[11px] text-slate-600">กดปุ่ม "เปิดหน้าต่างปรับแต่งตัวกรองเต็มรูปแบบ" ด้านบน เพื่อสร้างคุณลักษณะตัวกรองและตัวเลือก</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{attributes.filter(a => a.isFilterable).map((attr) => {
|
||||
const label = typeof attr.label === 'object' ? (attr.label as any)?.th || (attr.label as any)?.en : attr.label
|
||||
const options = Array.isArray(attr.options) ? attr.options : []
|
||||
|
||||
return (
|
||||
<div key={attr.id} className="p-3.5 rounded-xl bg-white/[0.02] border border-white/10 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-white">{label || attr.code}</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
{attr.code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.slice(0, 4).map((opt: any, idx: number) => {
|
||||
const val = typeof opt === 'string' ? opt : opt.value || opt.label?.th || String(idx)
|
||||
return (
|
||||
<span key={idx} className="text-[10px] px-2 py-0.5 rounded-full bg-orange-500/10 text-orange-300 border border-orange-500/20">
|
||||
{val}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{options.length > 4 && (
|
||||
<span className="text-[10px] text-slate-500 self-center">
|
||||
+{options.length - 4} เพิ่มเติม
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Page UI Category Filter & Attribute Manager Modal */}
|
||||
{categoryId && (
|
||||
<CategoryFilterManagerModal
|
||||
isOpen={isFilterManagerOpen}
|
||||
onClose={() => {
|
||||
setIsFilterManagerOpen(false)
|
||||
fetchAttributes()
|
||||
}}
|
||||
categoryId={categoryId}
|
||||
categoryName={categoryName || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -373,10 +373,15 @@ export default function CategoryEditPage() {
|
||||
})
|
||||
}
|
||||
|
||||
setSuccessToast(isNew ? 'Category created successfully!' : 'Category updated successfully!')
|
||||
setSuccessToast(isNew ? 'บันทึกสร้างหมวดหมู่สำเร็จ!' : 'บันทึกการเปลี่ยนแปลงสำเร็จ!')
|
||||
setTimeout(() => {
|
||||
navigate('/categories')
|
||||
}, 800)
|
||||
setSuccessToast(null)
|
||||
}, 3000)
|
||||
|
||||
// If newly created, seamlessly switch URL to the new category ID edit page
|
||||
if (isNew && targetId) {
|
||||
navigate(`/categories/${targetId}`, { replace: true })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Save error:', err)
|
||||
alert(err.message || 'An error occurred while saving.')
|
||||
@@ -934,6 +939,8 @@ export default function CategoryEditPage() {
|
||||
setMediaTarget(target)
|
||||
setIsMediaModalOpen(true)
|
||||
}}
|
||||
categoryId={!isNew && id ? parseInt(id) : undefined}
|
||||
categoryName={name.th || name.en || 'Category'}
|
||||
/>
|
||||
|
||||
{/* Card 3: Associated Products & Quick Link */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'
|
||||
import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||
import { Edit3, FileJson, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
||||
import ProductAttributeBulkJsonModal from '../components/ProductAttributeBulkJsonModal'
|
||||
|
||||
const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
|
||||
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const
|
||||
@@ -67,6 +68,7 @@ export default function ProductAttributesPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isJsonModalOpen, setIsJsonModalOpen] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
@@ -209,18 +211,36 @@ export default function ProductAttributesPage() {
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">Define multilingual specs, units, and select options for products.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(emptyDefinition())
|
||||
setActiveLang('th')
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('common.addNew', 'Add Definition')}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsJsonModalOpen(true)}
|
||||
className="px-4 py-2.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-sm font-semibold transition-all flex items-center gap-2 cursor-pointer shadow-sm hover:border-cyan-glow/30"
|
||||
>
|
||||
<FileJson className="w-4 h-4 text-cyan-glow" />
|
||||
<span>JSON Import / Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(emptyDefinition())
|
||||
setActiveLang('th')
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('common.addNew', 'Add Definition')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductAttributeBulkJsonModal
|
||||
isOpen={isJsonModalOpen}
|
||||
onClose={() => setIsJsonModalOpen(false)}
|
||||
definitions={definitions}
|
||||
categories={categories}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-2xl border border-rose-500/20 bg-rose-500/10 text-rose-400 text-sm">
|
||||
{error}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 []
|
||||
|
||||
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')
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 2. Also inspect products in this category to see what values exist in product.attributes
|
||||
const productFoundValues = new Set<string>()
|
||||
products.forEach((p) => {
|
||||
p.attributes?.forEach((pa) => {
|
||||
if (pa.attributeDefinitionId === attr.id || pa.attributeDefinition?.code === attr.code) {
|
||||
if (pa.valueText) productFoundValues.add(pa.valueText)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
types[detectedType] = (types[detectedType] || 0) + 1
|
||||
})
|
||||
}, [availableFilters, products])
|
||||
|
||||
// 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']++
|
||||
|
||||
// Stock
|
||||
// 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}
|
||||
/>
|
||||
|
||||
{/* 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 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" />
|
||||
</div>
|
||||
<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,194 +121,108 @@ 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 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>
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* 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]`} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{!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={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.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
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 className={isChecked ? 'font-bold text-brand-primary' : ''}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-text font-mono">
|
||||
({opt.count})
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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)
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
/>
|
||||
<span>{fitment}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-text font-mono">({count})</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
<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({
|
||||
...filters,
|
||||
priceRange: [filters.priceRange[0], Number(e.target.value)],
|
||||
})
|
||||
}
|
||||
className="w-full accent-brand-primary cursor-pointer"
|
||||
/>
|
||||
<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="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds[0]}
|
||||
max={priceBounds[1]}
|
||||
value={filters.priceRange[1]}
|
||||
onChange={(e) =>
|
||||
onChangeFilters({
|
||||
...filters,
|
||||
priceRange: [filters.priceRange[0], Number(e.target.value)],
|
||||
})
|
||||
}
|
||||
className="w-full h-1.5 bg-surface-page rounded-lg appearance-none cursor-pointer accent-brand-primary"
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user