x
This commit is contained in:
1 parent
0c3faaba29
commit
b8a255821b
6 files changed
+1820
-432
No files matched your search
@@ -52,6 +52,7 @@ import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
|
||||
import MediaLibraryPage from './pages/MediaLibraryPage'
|
||||
import ContactMessagesPage from './pages/ContactMessagesPage'
|
||||
import ProductAttributesPage from './pages/ProductAttributesPage'
|
||||
import ProductAttributeEditPage from './pages/ProductAttributeEditPage'
|
||||
import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage'
|
||||
import TiktokShopCasePage from './pages/TiktokShopCasePage'
|
||||
import ShopeeShopCasePage from './pages/ShopeeShopCasePage'
|
||||
@@ -95,6 +96,9 @@ function App() {
|
||||
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||
<Route path="/product-attributes/new" element={<ProductAttributeEditPage />} />
|
||||
<Route path="/product-attributes/:id" element={<ProductAttributeEditPage />} />
|
||||
<Route path="/product-attributes/:id/edit" element={<ProductAttributeEditPage />} />
|
||||
<Route path="/products/new" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,74 +1,32 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'
|
||||
import { Edit3, FileJson, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
||||
import {
|
||||
Edit3,
|
||||
FileJson,
|
||||
Plus,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Search,
|
||||
Filter,
|
||||
Layers,
|
||||
Tag,
|
||||
ListFilter,
|
||||
} from 'lucide-react'
|
||||
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
|
||||
|
||||
type EditableDefinition = {
|
||||
id?: number
|
||||
code: string
|
||||
type: typeof ATTRIBUTE_TYPES[number]
|
||||
unit: string
|
||||
isRequired: boolean
|
||||
isFilterable: boolean
|
||||
sortOrder: number
|
||||
categoryId: number | null
|
||||
translations: Record<string, { label: string; description: string }>
|
||||
options: Record<string, string>[]
|
||||
}
|
||||
|
||||
function emptyDefinition(): EditableDefinition {
|
||||
return {
|
||||
code: '',
|
||||
type: 'TEXT',
|
||||
unit: '',
|
||||
isRequired: false,
|
||||
isFilterable: false,
|
||||
sortOrder: 0,
|
||||
categoryId: null,
|
||||
translations: Object.fromEntries(LANGUAGES.map((lang) => [lang, { label: '', description: '' }])),
|
||||
options: [],
|
||||
}
|
||||
}
|
||||
|
||||
function parseDefinition(definition: AttributeDefinition): EditableDefinition {
|
||||
const translations = Object.fromEntries(
|
||||
LANGUAGES.map((lang) => [lang, {
|
||||
label: typeof definition.label === 'string' ? (lang === 'th' ? definition.label : '') : (definition.label?.[lang] || ''),
|
||||
description: typeof definition.description === 'string' ? (lang === 'th' ? definition.description : '') : (definition.description?.[lang] || ''),
|
||||
}])
|
||||
)
|
||||
|
||||
return {
|
||||
id: definition.id,
|
||||
code: definition.code,
|
||||
type: definition.type,
|
||||
unit: definition.unit || '',
|
||||
isRequired: definition.isRequired,
|
||||
isFilterable: definition.isFilterable,
|
||||
sortOrder: definition.sortOrder,
|
||||
categoryId: definition.categoryId ?? null,
|
||||
translations,
|
||||
options: Array.isArray(definition.options)
|
||||
? definition.options.map((option) => typeof option === 'string' ? { th: option } : option)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProductAttributesPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [editing, setEditing] = useState<EditableDefinition | null>(null)
|
||||
const [activeLang, setActiveLang] = useState('th')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isJsonModalOpen, setIsJsonModalOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategoryFilter, setSelectedCategoryFilter] = useState<string>('all')
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
@@ -92,67 +50,8 @@ export default function ProductAttributesPage() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const baseLabel = editing.translations.th?.label || editing.translations.en?.label
|
||||
if (!baseLabel) throw new Error('Thai or English label is required')
|
||||
|
||||
const payload = {
|
||||
code: editing.code,
|
||||
type: editing.type,
|
||||
unit: editing.unit,
|
||||
isRequired: editing.isRequired,
|
||||
isFilterable: editing.isFilterable,
|
||||
sortOrder: Number(editing.sortOrder) || 0,
|
||||
categoryId: editing.categoryId ? Number(editing.categoryId) : null,
|
||||
label: Object.fromEntries(
|
||||
Object.entries(editing.translations)
|
||||
.filter(([, val]) => val.label.trim())
|
||||
.map(([lang, val]) => [lang, val.label.trim()])
|
||||
),
|
||||
description: Object.fromEntries(
|
||||
Object.entries(editing.translations)
|
||||
.filter(([, val]) => val.description.trim())
|
||||
.map(([lang, val]) => [lang, val.description.trim()])
|
||||
),
|
||||
options: editing.type === 'SELECT'
|
||||
? editing.options.map((opt) => Object.fromEntries(
|
||||
Object.entries(opt).filter(([, val]) => typeof val === 'string' && val.trim())
|
||||
)).filter((opt) => Object.keys(opt).length > 0)
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const url = editing.id
|
||||
? `${API_BASE}/product-attributes/definitions/${editing.id}`
|
||||
: `${API_BASE}/product-attributes/definitions`
|
||||
const method = editing.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 fetchData()
|
||||
setEditing(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this attribute definition?'))) return
|
||||
const handleDelete = async (id: number, label: string) => {
|
||||
if (!confirm(t('common.confirmDelete', `Are you sure you want to delete attribute "${label}"?`))) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
@@ -160,7 +59,6 @@ export default function ProductAttributesPage() {
|
||||
throw new Error(errData?.message || 'Failed to delete attribute definition')
|
||||
}
|
||||
await fetchData()
|
||||
if (editing?.id === id) setEditing(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
@@ -170,47 +68,58 @@ export default function ProductAttributesPage() {
|
||||
const map = new Map<number, string>()
|
||||
const currentLang = i18n.language
|
||||
categories.forEach((cat) => {
|
||||
const name = typeof cat.name === 'string'
|
||||
? cat.name
|
||||
: (cat.name?.[currentLang] || cat.name?.th || cat.name?.en || `Category #${cat.id}`)
|
||||
const name =
|
||||
typeof cat.name === 'string'
|
||||
? cat.name
|
||||
: cat.name?.[currentLang] || cat.name?.th || cat.name?.en || `Category #${cat.id}`
|
||||
map.set(cat.id, name)
|
||||
})
|
||||
return map
|
||||
}, [categories, i18n.language])
|
||||
|
||||
const handleCopyFromLang = (fromLang: string) => {
|
||||
if (!editing) return
|
||||
const src = editing.translations[fromLang]
|
||||
if (!src) return
|
||||
setEditing({
|
||||
...editing,
|
||||
translations: {
|
||||
...editing.translations,
|
||||
[activeLang]: {
|
||||
label: src.label || '',
|
||||
description: src.description || '',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
// Filter & Search attributes
|
||||
const filteredDefinitions = useMemo(() => {
|
||||
return definitions.filter((def) => {
|
||||
// Category filter
|
||||
if (selectedCategoryFilter === 'global' && def.categoryId !== null) return false
|
||||
if (selectedCategoryFilter !== 'all' && selectedCategoryFilter !== 'global') {
|
||||
if (def.categoryId !== Number(selectedCategoryFilter)) return false
|
||||
}
|
||||
|
||||
const labelTranslationsMap = useMemo(() => {
|
||||
if (!editing) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(editing.translations).map(([lang, val]) => [lang, val.label])
|
||||
)
|
||||
}, [editing])
|
||||
// Search query
|
||||
if (!searchQuery.trim()) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
|
||||
const codeMatch = def.code.toLowerCase().includes(q)
|
||||
const labelMatch =
|
||||
typeof def.label === 'string'
|
||||
? def.label.toLowerCase().includes(q)
|
||||
: Object.values(def.label || {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))
|
||||
const descMatch =
|
||||
typeof def.description === 'string'
|
||||
? def.description.toLowerCase().includes(q)
|
||||
: Object.values(def.description || {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))
|
||||
|
||||
return codeMatch || labelMatch || descMatch
|
||||
})
|
||||
}, [definitions, searchQuery, selectedCategoryFilter])
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="space-y-6 max-w-7xl mx-auto pb-12 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-cyan-glow">
|
||||
<div className="flex items-center gap-2.5 text-cyan-glow">
|
||||
<Settings2 className="w-6 h-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('nav.productAttributes', 'Product Attributes')}</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white">
|
||||
{t('nav.productAttributes', 'Product Attributes')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">Define multilingual specs, units, and select options for products.</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Manage multilingual specifications, units, facet filters, and select options for products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -220,16 +129,14 @@ export default function ProductAttributesPage() {
|
||||
<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"
|
||||
|
||||
<Link
|
||||
to="/product-attributes/new"
|
||||
className="btn-primary flex items-center gap-2 px-4 py-2.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('common.addNew', 'Add Definition')}
|
||||
</button>
|
||||
<span>{t('common.addNew', 'Add Definition')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -247,252 +154,179 @@ export default function ProductAttributesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
<section className="admin-card space-y-4">
|
||||
<h2 className="font-bold text-lg text-white">Defined Attributes ({definitions.length})</h2>
|
||||
<div className="space-y-3">
|
||||
{loading && Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-2xl bg-white/5 animate-pulse" />
|
||||
{/* Toolbar & Filters */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white/[0.02] border border-white/5 p-4 rounded-2xl">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search attributes by name or code..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white placeholder-slate-500 focus:border-cyan-glow/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<Filter className="w-4 h-4 text-slate-400 hidden sm:block" />
|
||||
<select
|
||||
value={selectedCategoryFilter}
|
||||
onChange={(e) => setSelectedCategoryFilter(e.target.value)}
|
||||
className="w-full sm:w-60 px-3 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="all">All Scopes ({definitions.length})</option>
|
||||
<option value="global">🌐 Global Attributes Only</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
📁 {typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || `Category #${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && definitions.map((definition) => {
|
||||
const currentLang = i18n.language
|
||||
const label = typeof definition.label === 'string'
|
||||
? definition.label
|
||||
: definition.label?.[currentLang] || definition.label?.th || definition.label?.en || definition.code
|
||||
const description = typeof definition.description === 'string'
|
||||
? definition.description
|
||||
: definition.description?.[currentLang] || definition.description?.th || definition.description?.en || ''
|
||||
return (
|
||||
<div key={definition.id} className="p-4 rounded-2xl border border-white/5 bg-white/[0.02] flex items-start justify-between gap-4 hover:border-cyan-glow/30 transition-all">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-white">{label}</h3>
|
||||
<span className="text-[10px] uppercase tracking-widest text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 px-2 py-0.5 rounded-full font-bold">
|
||||
{definition.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] uppercase tracking-widest text-slate-500">{definition.code}</p>
|
||||
<p className="text-sm text-slate-400">{description || 'No description yet'}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{definition.categoryId ? `Category: ${categoryNameMap.get(definition.categoryId) || definition.categoryId}` : 'Global attribute'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(parseDefinition(definition))
|
||||
setActiveLang('th')
|
||||
}}
|
||||
title={t('common.edit', 'Edit')}
|
||||
className="p-2 rounded-xl text-slate-500 hover:text-cyan-glow hover:bg-cyan-glow/10 transition-all cursor-pointer"
|
||||
{/* Attributes List / Table */}
|
||||
<div className="admin-card overflow-hidden p-0 border border-white/5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 bg-white/[0.02] text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
<th className="py-3.5 px-5">Attribute & Code</th>
|
||||
<th className="py-3.5 px-4">Data Type</th>
|
||||
<th className="py-3.5 px-4">Category Scope</th>
|
||||
<th className="py-3.5 px-4">Options / Unit</th>
|
||||
<th className="py-3.5 px-4">Filterable</th>
|
||||
<th className="py-3.5 px-4">Required</th>
|
||||
<th className="py-3.5 px-5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 text-sm">
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
<td className="py-4 px-5">
|
||||
<div className="h-4 w-32 bg-white/10 rounded mb-1.5" />
|
||||
<div className="h-3 w-20 bg-white/5 rounded" />
|
||||
</td>
|
||||
<td className="py-4 px-4"><div className="h-4 w-16 bg-white/5 rounded" /></td>
|
||||
<td className="py-4 px-4"><div className="h-4 w-24 bg-white/5 rounded" /></td>
|
||||
<td className="py-4 px-4"><div className="h-4 w-16 bg-white/5 rounded" /></td>
|
||||
<td className="py-4 px-4"><div className="h-4 w-12 bg-white/5 rounded" /></td>
|
||||
<td className="py-4 px-4"><div className="h-4 w-12 bg-white/5 rounded" /></td>
|
||||
<td className="py-4 px-5 text-right"><div className="h-8 w-16 bg-white/5 rounded ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filteredDefinitions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-16 text-center text-slate-500">
|
||||
<Layers className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
<p className="font-semibold text-slate-400">No attribute definitions found</p>
|
||||
<p className="text-xs mt-1">Try clearing filters or click "Add Definition" to create one.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredDefinitions.map((def) => {
|
||||
const currentLang = i18n.language
|
||||
const label =
|
||||
typeof def.label === 'string'
|
||||
? def.label
|
||||
: def.label?.[currentLang] || def.label?.th || def.label?.en || def.code
|
||||
const optionsCount = Array.isArray(def.options) ? def.options.length : 0
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={def.id}
|
||||
onClick={() => navigate(`/product-attributes/${def.id}`)}
|
||||
className="hover:bg-white/[0.03] transition-colors cursor-pointer group"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(definition.id)}
|
||||
title={t('common.delete', 'Delete')}
|
||||
className="p-2 rounded-xl text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 transition-all cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<td className="py-4 px-5">
|
||||
<div className="font-bold text-white group-hover:text-cyan-glow transition-colors flex items-center gap-2">
|
||||
<span>{label}</span>
|
||||
<span className="text-[11px] font-mono text-slate-500">#{def.id}</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-slate-400 uppercase tracking-wider">
|
||||
{def.code}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<section className="admin-card space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-bold text-lg text-white">{editing?.id ? 'Edit Definition' : 'Create Definition'}</h2>
|
||||
<p className="text-slate-500 text-sm">Type-aware definitions with 4-language localized labels and SELECT options.</p>
|
||||
</div>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => setEditing(null)}
|
||||
className="p-2 rounded-xl text-slate-500 hover:text-white hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<td className="py-4 px-4">
|
||||
<span className="text-[10px] uppercase font-bold tracking-widest px-2.5 py-1 rounded-full bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow">
|
||||
{def.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{!editing ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-white/5 rounded-2xl text-slate-500 text-sm">
|
||||
Select an attribute definition to edit or click Create Definition.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="space-y-5">
|
||||
<ContentLanguageTabs
|
||||
activeLang={activeLang}
|
||||
onSelectLang={setActiveLang}
|
||||
translationsMap={labelTranslationsMap}
|
||||
onCopyFrom={handleCopyFromLang}
|
||||
/>
|
||||
<td className="py-4 px-4">
|
||||
{def.categoryId ? (
|
||||
<span className="text-xs text-slate-300 font-medium flex items-center gap-1.5">
|
||||
📁 {categoryNameMap.get(def.categoryId) || `Category #${def.categoryId}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
🌐 Global
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editing.code}
|
||||
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
placeholder="e.g. burn_time, weight"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Type</label>
|
||||
<select
|
||||
value={editing.type}
|
||||
onChange={(e) => setEditing({ ...editing, type: e.target.value as EditableDefinition['type'], options: e.target.value === 'SELECT' ? editing.options : [] })}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
>
|
||||
{ATTRIBUTE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Unit</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.unit}
|
||||
onChange={(e) => setEditing({ ...editing, unit: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
placeholder="e.g. g, ml, hrs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Sort Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
onChange={(e) => setEditing({ ...editing, sortOrder: Number(e.target.value) })}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<td className="py-4 px-4">
|
||||
{def.type === 'SELECT' ? (
|
||||
<span className="text-xs text-slate-300 font-medium">
|
||||
{optionsCount} {optionsCount === 1 ? 'option' : 'options'}
|
||||
</span>
|
||||
) : def.unit ? (
|
||||
<span className="text-xs font-mono text-slate-400 bg-white/5 px-2 py-0.5 rounded border border-white/10">
|
||||
{def.unit}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
|
||||
Label ({activeLang.toUpperCase()})
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required={activeLang === 'th'}
|
||||
value={editing.translations[activeLang]?.label || ''}
|
||||
onChange={(e) => setEditing({
|
||||
...editing,
|
||||
translations: {
|
||||
...editing.translations,
|
||||
[activeLang]: {
|
||||
...editing.translations[activeLang],
|
||||
label: e.target.value,
|
||||
},
|
||||
},
|
||||
})}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
placeholder={`Label in ${activeLang.toUpperCase()}`}
|
||||
/>
|
||||
</div>
|
||||
<td className="py-4 px-4">
|
||||
{def.isFilterable ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
||||
<ListFilter className="w-3 h-3" /> Yes
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">No</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
|
||||
Description ({activeLang.toUpperCase()})
|
||||
</label>
|
||||
<textarea
|
||||
value={editing.translations[activeLang]?.description || ''}
|
||||
onChange={(e) => setEditing({
|
||||
...editing,
|
||||
translations: {
|
||||
...editing.translations,
|
||||
[activeLang]: {
|
||||
...editing.translations[activeLang],
|
||||
description: e.target.value,
|
||||
},
|
||||
},
|
||||
})}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none min-h-[70px]"
|
||||
placeholder={`Description in ${activeLang.toUpperCase()}`}
|
||||
/>
|
||||
</div>
|
||||
<td className="py-4 px-4">
|
||||
{def.isRequired ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full border border-amber-500/20">
|
||||
<Tag className="w-3 h-3" /> Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">Optional</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Category Restriction</label>
|
||||
<select
|
||||
value={editing.categoryId ?? 0}
|
||||
onChange={(e) => setEditing({ ...editing, categoryId: Number(e.target.value) || null })}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
>
|
||||
<option value={0}>Global (All categories)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{editing.type === 'SELECT' && (
|
||||
<div className="space-y-3 pt-3 border-t border-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
|
||||
Options ({activeLang.toUpperCase()})
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing({
|
||||
...editing,
|
||||
options: [...editing.options, { [activeLang]: '' }],
|
||||
})}
|
||||
className="text-xs font-bold text-cyan-glow hover:text-cyan-glow/80 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Option
|
||||
</button>
|
||||
</div>
|
||||
{editing.options.map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={opt[activeLang] || ''}
|
||||
onChange={(e) => {
|
||||
const nextOptions = [...editing.options]
|
||||
nextOptions[idx] = { ...nextOptions[idx], [activeLang]: e.target.value }
|
||||
setEditing({ ...editing, options: nextOptions })
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
|
||||
placeholder={`Option ${idx + 1} (${activeLang.toUpperCase()})`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextOptions = editing.options.filter((_, i) => i !== idx)
|
||||
setEditing({ ...editing, options: nextOptions })
|
||||
}}
|
||||
className="p-2 text-slate-500 hover:text-rose-500 rounded-xl transition-all cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<td className="py-4 px-5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Link
|
||||
to={`/product-attributes/${def.id}`}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-cyan-glow hover:bg-cyan-glow/10 border border-transparent hover:border-cyan-glow/20 transition-all cursor-pointer"
|
||||
title="Edit Attribute Definition"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(def.id, label)}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all cursor-pointer"
|
||||
title="Delete Attribute Definition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? t('common.saving', 'Saving...') : t('common.saveChanges', 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user