Files
homaroma/apps/admin/src/pages/ProductEditPage.tsx
T

1492 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import {
ArrowLeft,
Save,
Trash2,
Plus,
Package,
Tag,
Info,
Loader2,
CheckCircle2,
AlertCircle,
Image as ImageIcon,
ImagePlus,
ExternalLink,
X,
ArrowUp,
ArrowDown,
Monitor,
Smartphone,
RotateCcw,
Download,
Upload
} from 'lucide-react'
import { API_BASE, type AttributeDefinition, type Brand, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
import { getAuthHeaders } from '../utils/auth'
import MDEditor from '@uiw/react-md-editor'
import MediaLibraryModal from '../components/MediaLibraryModal'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
import ProductTranslationModal from '../components/products/ProductTranslationModal'
import ColorSwatchPicker from '../components/products/ColorSwatchPicker'
type EditableAttribute = {
id?: number
attributeDefinitionId: number
attributeDefinition: AttributeDefinition
valueText: string
valueNumber: number | ''
valueBoolean: boolean
valueJson: string
translations: Record<string, string>
}
type EditableProductImage = Partial<ProductImage> & {
mobileUrl?: string | null
}
type ImageAspectPreviewProps = {
url?: string | null
label: string
expectedRatio: number
expectedLabel: string
frameClassName: string
fallbackUrl?: string | null
}
function ImageAspectPreview({
url,
label,
expectedRatio,
expectedLabel,
frameClassName,
fallbackUrl,
}: ImageAspectPreviewProps) {
const [loadedImage, setLoadedImage] = useState<{ url: string; width: number; height: number } | null>(null)
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const displayUrl = url || fallbackUrl || ''
const isFallback = !url && Boolean(fallbackUrl)
const dimensions = loadedImage?.url === displayUrl ? loadedImage : null
const failed = failedUrl === displayUrl
const actualRatio = dimensions ? dimensions.width / dimensions.height : null
const ratioMismatch = actualRatio !== null && Math.abs(actualRatio - expectedRatio) > 0.04
return (
<div className="space-y-2">
<div className={`relative overflow-hidden rounded-xl border border-white/10 bg-night-950 ${frameClassName}`}>
{displayUrl && !failed ? (
<img
src={displayUrl.replace('/public/uploads/', '/uploads/')}
alt={`${label} preview`}
className="h-full w-full object-contain"
onLoad={(event) => {
const image = event.currentTarget
setLoadedImage({ url: displayUrl, width: image.naturalWidth, height: image.naturalHeight })
}}
onError={() => setFailedUrl(displayUrl)}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-slate-600">
<ImageIcon className="h-6 w-6" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{failed ? 'Preview unavailable' : `No ${label} image`}
</span>
</div>
)}
{isFallback && displayUrl && !failed && (
<div className="absolute inset-x-2 bottom-2 flex items-center justify-center gap-1 rounded-lg border border-white/10 bg-night-950/90 px-2 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-300">
<RotateCcw className="h-3 w-3" />
Desktop fallback
</div>
)}
</div>
{dimensions && (
<p className={`text-[10px] ${ratioMismatch ? 'text-amber-400' : 'text-slate-500'}`}>
{dimensions.width} × {dimensions.height}px
{ratioMismatch ? ` · Recommended aspect ratio is ${expectedLabel}` : ` · ${expectedLabel} ratio`}
</p>
)}
</div>
)
}
export default function ProductEditPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [product, setProduct] = useState<Product | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
// Form State - Languages & Translations
const [activeLang, setActiveLang] = useState('th')
const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
const [categoryId, setCategoryId] = useState<number>(0)
const [brands, setBrands] = useState<Brand[]>([])
const [brandId, setBrandId] = useState<number | ''>('')
const [name, setName] = useState<Record<string, string>>({})
const [description, setDescription] = useState<Record<string, string>>({})
const [details, setDetails] = useState<Record<string, string>>({})
const [options, setOptions] = useState<{name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string; colorCode?: string })[]}[]>([])
const [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<EditableProductImage[]>([])
const [titleGraphicUrl, setTitleGraphicUrl] = useState<string>('')
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
const handleCopyFromLang = (fromLang: string) => {
if (name[fromLang]) {
setName(prev => ({ ...prev, [activeLang]: prev[fromLang] }))
}
if (description[fromLang]) {
setDescription(prev => ({ ...prev, [activeLang]: prev[fromLang] }))
}
if (details[fromLang]) {
setDetails(prev => ({ ...prev, [activeLang]: prev[fromLang] }))
}
}
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
// removed quillRef
const openMediaModal = (onSelect: (url: string) => void) => {
setOnSelectMedia(() => onSelect)
setIsMediaModalOpen(true)
}
const parseEditableAttribute = (attribute: ProductAttribute): EditableAttribute => ({
id: attribute.id,
attributeDefinitionId: attribute.attributeDefinitionId,
attributeDefinition: attribute.attributeDefinition,
valueText: attribute.valueText || '',
valueNumber: attribute.valueNumber ?? '',
valueBoolean: Boolean(attribute.valueBoolean),
valueJson: attribute.valueJson ? JSON.stringify(attribute.valueJson, null, 2) : '',
translations: attribute.valueTranslations
|| Object.fromEntries((attribute.translations || []).map((translation) => [translation.langCode, translation.valueText])),
})
const fetchAttributeDefinitions = async (nextCategoryId: number) => {
if (!nextCategoryId) {
setAvailableAttributes([])
return
}
const res = await fetch(`${API_BASE}/product-attributes/definitions?categoryId=${nextCategoryId}&lang=all`)
if (!res.ok) throw new Error('Failed to load attribute definitions')
const data = await res.json()
setAvailableAttributes(data)
}
// removed imageHandler and modules
useEffect(() => {
const fetchData = async () => {
try {
const [catRes, brandRes] = await Promise.all([
fetch(`${API_BASE}/categories`),
fetch(`${API_BASE}/products/brands`)
])
const catData: Category[] = await catRes.json()
const brandData: Brand[] = brandRes.ok ? await brandRes.json() : []
setCategories(catData)
setBrands(brandData)
if (id) {
const prodRes = await fetch(`${API_BASE}/products/${id}?lang=all`)
if (!prodRes.ok) throw new Error('Product not found')
const prodData: Product = await prodRes.json()
setProduct(prodData)
setCategoryId(prodData.categoryId)
setBrandId(prodData.brandId ?? prodData.brand?.id ?? (brandData[0]?.id || ''))
const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' }
setName(parseTranslatable(prodData.name))
setDescription(parseTranslatable(prodData.description))
setDetails(parseTranslatable(prodData.details))
const parseOptions = (val: any) => {
if (!Array.isArray(val)) return []
return val.map(opt => ({
name: typeof opt.name === 'string' ? { th: opt.name } : (opt.name || { th: '' }),
values: Array.isArray(opt.values)
? opt.values.map((v: any) => typeof v === 'string' ? { th: v } : (v || { th: '' }))
: []
}))
}
setOptions(parseOptions(prodData.options))
setVariants(prodData.variants || [])
setImages(prodData.images || [])
setTitleGraphicUrl(prodData.titleGraphicUrl || '')
setAttributes((prodData.attributes || []).map(parseEditableAttribute))
await fetchAttributeDefinitions(prodData.categoryId)
} else {
setProduct(null)
if (catData.length > 0) setCategoryId(catData[0].id)
if (brandData.length > 0) setBrandId(brandData[0].id)
setName({ th: '', en: '' })
setDescription({ th: '', en: '' })
setDetails({ th: '', en: '' })
setTitleGraphicUrl('')
setOptions([])
setVariants([])
setImages([])
setAttributes([])
if (catData.length > 0) {
await fetchAttributeDefinitions(catData[0].id)
}
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchData()
}, [id])
useEffect(() => {
if (!categoryId) return
fetchAttributeDefinitions(categoryId).catch((err) => setError(err.message))
}, [categoryId])
const handleRemoveVariant = (index: number) => {
setVariants(variants.filter((_, i) => i !== index))
}
const handleUpdateVariant = (index: number, field: keyof Variant, value: any) => {
const updated = [...variants]
updated[index] = { ...updated[index], [field]: value }
setVariants(updated)
}
const regenerateVariants = (newOptions: {name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string; colorCode?: string })[]}[]) => {
setVariants(prevVariants => {
const validOptions = newOptions.filter(o => o.name && (o.name['th'] || o.name['en']) && o.values.length > 0)
if (validOptions.length === 0) {
const defaultVar = prevVariants.find(v => !v.option1 || v.option1 === 'Default')
return defaultVar ? [defaultVar] : []
}
const arrays = validOptions.map(o => o.values.map(v => v['th'] || v['en'] || Object.values(v)[0] || ''))
const filteredArrays = arrays.filter(arr => arr.length > 0 && arr.some(v => v !== ''))
if (filteredArrays.length === 0) return []
const cartesian = filteredArrays.reduce((acc, curr) =>
acc.flatMap(c => curr.map(n => [...c, n])),
[[]] as string[][]
)
return cartesian.map(combo => {
const option1 = combo[0] || ''
const option2 = combo[1] || ''
const option3 = combo[2] || ''
const existing = prevVariants.find(v => v.option1 === option1 && v.option2 === option2 && v.option3 === option3)
const opt1Val = newOptions[0]?.values.find(v => (v['th'] || v['en'] || Object.values(v)[0]) === option1)
const swatchUrl = opt1Val?.imageUrl || null
const swatchColor = opt1Val?.colorCode || null
if (existing) {
return {
...existing,
imageUrl: existing.imageUrl || swatchUrl || null,
colorCode: existing.colorCode || swatchColor || null,
}
}
return { option1, option2, option3, price: 0, stock: 0, sku: '', barcode: '', imageUrl: swatchUrl, colorCode: swatchColor }
})
})
}
const handleRemoveImage = (index: number) => {
setImages((current) => current
.filter((_, imageIndex) => imageIndex !== index)
.map((image, order) => ({ ...image, order })))
}
const handleUpdateImage = (index: number, field: 'url' | 'mobileUrl', value: string) => {
setImages((current) => current.map((image, imageIndex) => (
imageIndex === index ? { ...image, [field]: value } : image
)))
}
const handleMoveImage = (index: number, direction: -1 | 1) => {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= images.length) return
setImages((current) => {
const reordered = [...current]
const [moved] = reordered.splice(index, 1)
reordered.splice(nextIndex, 0, moved)
return reordered.map((image, order) => ({ ...image, order }))
})
}
const attachedDefinitionIds = new Set(attributes.map((attribute) => attribute.attributeDefinitionId))
const addableDefinitions = availableAttributes.filter((definition) => !attachedDefinitionIds.has(definition.id))
const handleAddAttribute = () => {
const definition = addableDefinitions.find((item) => item.id === selectedDefinitionId) || addableDefinitions[0]
if (!definition) return
setAttributes((prev) => [
...prev,
{
attributeDefinitionId: definition.id,
attributeDefinition: definition,
valueText: '',
valueNumber: '',
valueBoolean: false,
valueJson: '',
translations: {},
},
])
setSelectedDefinitionId(0)
}
const handleRemoveAttribute = (index: number) => {
setAttributes((prev) => prev.filter((_, idx) => idx !== index))
}
const handleUpdateAttribute = (index: number, patch: Partial<EditableAttribute>) => {
setAttributes((prev) => {
const next = [...prev]
next[index] = { ...next[index], ...patch }
return next
})
}
const buildAttributePayload = (attribute: EditableAttribute) => {
const type = attribute.attributeDefinition.type
const base: Record<string, unknown> = {
attributeDefinitionId: attribute.attributeDefinitionId,
}
if (type === 'TEXT' || type === 'SELECT') {
base.valueText = attribute.valueText
} else if (type === 'NUMBER') {
base.valueNumber = Number(attribute.valueNumber)
} else if (type === 'BOOLEAN') {
base.valueBoolean = attribute.valueBoolean
} else if (type === 'JSON') {
base.valueJson = attribute.valueJson.trim() ? JSON.parse(attribute.valueJson) : {}
}
if (type === 'TEXT') {
const translations = Object.fromEntries(
Object.entries(attribute.translations).filter(([, value]) => typeof value === 'string' && value.trim())
)
if (Object.keys(translations).length > 0) base.translations = translations
}
return base
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
if (images.some((image) => !image.url?.trim() && image.mobileUrl?.trim())) {
throw new Error('Each mobile image needs a Desktop / Tablet image in the same gallery item.')
}
const isNew = !id
const method = isNew ? 'POST' : 'PUT'
const url = isNew ? `${API_BASE}/products` : `${API_BASE}/products/${id}`
const response = await fetch(url, {
method,
headers: getAuthHeaders(),
body: JSON.stringify({
categoryId,
brandId: brandId ? Number(brandId) : undefined,
name,
titleGraphicUrl: titleGraphicUrl.trim() || null,
description,
details,
options,
variants: variants.map(v => ({
...(v.id ? { id: v.id } : {}),
option1: v.option1 || '',
option2: v.option2 || '',
option3: v.option3 || '',
price: Number(v.price) || 0,
stock: Number(v.stock) || 0,
sku: v.sku || '',
barcode: v.barcode || '',
imageUrl: v.imageUrl || null,
colorCode: v.colorCode || null,
})),
images: images.filter(img => img.url).map((img, idx) => ({
...(img.id ? { id: img.id } : {}),
url: img.url,
mobileUrl: img.mobileUrl?.trim() || null,
order: idx
}))
}),
})
if (!response.ok) throw new Error(isNew ? 'Failed to create product' : 'Failed to update product')
const saved = await response.json()
const savedProductId = isNew ? saved.product?.id : Number(id)
const attributeResponse = await fetch(`${API_BASE}/products/${savedProductId}/attributes`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
attributes: attributes.map(buildAttributePayload),
}),
})
if (!attributeResponse.ok) {
const attributeError = await attributeResponse.json().catch(() => null)
throw new Error(attributeError?.message || 'Failed to save product attributes')
}
setSuccess(true)
if (isNew) {
setTimeout(() => navigate('/products'), 1500)
} else {
setTimeout(() => setSuccess(false), 3000)
}
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="h-[60vh] flex flex-col items-center justify-center space-y-4">
<Loader2 className="w-10 h-10 text-cyan-glow animate-spin" />
<p className="text-slate-500 animate-pulse">Loading product details...</p>
</div>
)
}
if (error && !product) {
return (
<div className="admin-card text-center py-20 space-y-4">
<AlertCircle className="w-16 h-16 text-rose-500 mx-auto" />
<h2 className="text-2xl font-bold">Error</h2>
<p className="text-slate-500">{error}</p>
<Link to="/products" className="btn-primary inline-flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Products
</Link>
</div>
)
}
return (
<div className="w-full space-y-8 animate-fade-in">
{/* Header & Actions */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/products" className="hover:text-cyan-glow transition-colors">Products</Link>
<span>/</span>
<span className="text-white">Edit</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
Edit <span className="text-cyan-glow">{product?.brand.name}</span>
</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setTranslationModalTab('export')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="ส่งออกข้อมูลสินค้าทุกภาษาเพื่อนำไปแปลด้วย AI (Export & Prompt)"
>
<Download className="w-4 h-4" />
<span>Export ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setTranslationModalTab('import')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="วาง JSON ผลลัพธ์จาก AI เพื่ออัปเดตข้อมูลสินค้าทุกภาษาพร้อมกัน"
>
<Upload className="w-4 h-4" />
<span>Import ภาษา</span>
</button>
<button
onClick={() => navigate('/products')}
className="px-6 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all cursor-pointer"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary py-2.5 px-8 flex items-center gap-2"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
{success && (
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-500 text-sm animate-fade-in">
<CheckCircle2 className="w-5 h-5 shrink-0" />
<p>Product updated successfully!</p>
</div>
)}
{error && (
<div className="flex items-center gap-3 p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-sm animate-fade-in">
<AlertCircle className="w-5 h-5 shrink-0" />
<p>{error}</p>
</div>
)}
<div>
{/* Main Content */}
<div className="space-y-8">
{/* Basic Info */}
<section className="admin-card space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-2 text-cyan-glow">
<Info className="w-5 h-5" />
<h2 className="font-bold text-lg">Basic Information</h2>
</div>
<ContentLanguageTabs
activeLang={activeLang}
onSelectLang={setActiveLang}
translationsMap={name}
onCopyFrom={handleCopyFromLang}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Product Name ({activeLang.toUpperCase()})</label>
<input
type="text"
value={name[activeLang] || ''}
onChange={e => setName({ ...name, [activeLang]: e.target.value })}
placeholder={`e.g. Scented Candle (${activeLang})`}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Category</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all appearance-none cursor-pointer"
>
{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>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Brand</label>
<Link to="/brands" className="text-[10px] text-cyan-glow hover:underline">จัดการแบรนด์</Link>
</div>
<select
value={brandId}
onChange={(e) => setBrandId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all appearance-none cursor-pointer"
required
>
<option value="">เลือกแบรนด์...</option>
{brands.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
<div className="md:col-span-2 space-y-2 p-4 rounded-xl border border-white/10 bg-white/[0.02]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Title Graphic / Series Logo (Wide PNG Transparent / SVG ~5:1)
</label>
</div>
{titleGraphicUrl && (
<button
type="button"
onClick={() => setTitleGraphicUrl('')}
className="text-xs text-rose-400 hover:text-rose-300 transition-colors"
>
Remove Graphic
</button>
)}
</div>
{titleGraphicUrl ? (
<div className="flex items-center justify-between gap-4 p-3 bg-night-950/80 border border-white/10 rounded-xl">
<div className="relative h-12 w-64 max-w-full bg-black/60 rounded-lg p-2 border border-white/5 flex items-center">
<img
src={titleGraphicUrl}
alt="Title graphic preview"
className="h-full w-auto max-w-full object-contain"
/>
</div>
<button
type="button"
onClick={() => openMediaModal((url) => setTitleGraphicUrl(url))}
className="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-semibold text-white transition-all shrink-0"
>
Change Graphic
</button>
</div>
) : (
<button
type="button"
onClick={() => openMediaModal((url) => setTitleGraphicUrl(url))}
className="w-full py-4 border-2 border-dashed border-white/10 hover:border-cyan-glow/40 bg-night-950/40 hover:bg-night-950/80 rounded-xl text-xs font-semibold text-slate-400 hover:text-cyan-glow transition-all flex items-center justify-center gap-2"
>
<ImagePlus className="w-4 h-4" />
Upload Title Graphic (e.g. &quot;NORIFUMI Oil Cup GP Series&quot; Logo PNG)
</button>
)}
<p className="text-[11px] text-slate-500">
Optional. Displays as a branded logo banner above the product title on the website.
</p>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Short Description ({activeLang.toUpperCase()})</label>
<textarea
value={description[activeLang] || ''}
onChange={e => setDescription({ ...description, [activeLang]: e.target.value })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all min-h-[80px]"
placeholder={`A brief summary of the product in ${activeLang}...`}
/>
</div>
<div className="md:col-span-2 space-y-2 bg-white text-black rounded-lg overflow-hidden pb-2">
<div className="bg-night-950 text-slate-400 p-2 text-[10px] uppercase font-bold tracking-widest px-4 border-b border-white/10">Full Details ({activeLang.toUpperCase()})</div>
<div data-color-mode="light">
<MDEditor
value={details[activeLang] || ''}
onChange={val => setDetails({ ...details, [activeLang]: val || '' })}
height={300}
preview="live"
/>
</div>
</div>
</div>
</section>
<section className="admin-card space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2 text-cyan-glow">
<Tag className="w-5 h-5" />
<h2 className="font-bold text-lg">Product Attributes</h2>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Link
to="/product-attributes"
className="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-xs font-bold uppercase tracking-widest text-slate-300 hover:bg-white/10 transition-all"
>
Manage Definitions
</Link>
<select
value={selectedDefinitionId}
onChange={(e) => setSelectedDefinitionId(Number(e.target.value))}
className="px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-sm text-white"
>
<option value={0}>Select attribute</option>
{addableDefinitions.map((definition) => (
<option key={definition.id} value={definition.id}>
{typeof definition.label === 'string' ? definition.label : definition.label.th || definition.label.en || definition.code}
</option>
))}
</select>
<button
type="button"
onClick={handleAddAttribute}
disabled={addableDefinitions.length === 0}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all disabled:opacity-40"
>
<Plus className="w-3.5 h-3.5" />
Add Attribute
</button>
</div>
</div>
<div className="space-y-4">
{attributes.length === 0 && (
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl">
<p className="text-slate-600 italic text-sm">No product attributes attached yet.</p>
</div>
)}
{attributes.map((attribute, idx) => {
const definition = attribute.attributeDefinition
const label = typeof definition.label === 'string' ? definition.label : definition.label[activeLang] || definition.label.th || definition.label.en || definition.code
const descriptionValue =
typeof definition.description === 'string'
? definition.description
: definition.description?.[activeLang] || definition.description?.th || definition.description?.en || ''
const translatedValue = attribute.translations[activeLang] || ''
const fallbackValue = attribute.translations.th || attribute.valueText
return (
<div key={`${attribute.attributeDefinitionId}-${idx}`} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white">{label}</h3>
<p className="text-[10px] uppercase tracking-widest text-slate-500">{definition.type} · {definition.code}</p>
{descriptionValue && <p className="text-sm text-slate-400 mt-2">{descriptionValue}</p>}
</div>
<button
type="button"
onClick={() => handleRemoveAttribute(idx)}
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{definition.type === 'TEXT' && (
<div className="space-y-3">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Base Value (TH)</label>
<input
type="text"
value={attribute.valueText}
onChange={(e) => handleUpdateAttribute(idx, { valueText: e.target.value })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Translated Value ({activeLang.toUpperCase()})</label>
<input
type="text"
value={translatedValue}
onChange={(e) => handleUpdateAttribute(idx, {
translations: { ...attribute.translations, [activeLang]: e.target.value }
})}
placeholder={activeLang === 'th' ? 'Optional if base value is already Thai' : `Translation for ${activeLang}`}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
{!translatedValue && activeLang !== 'th' && fallbackValue && (
<p className="text-[11px] text-amber-400">Fallback in use: {fallbackValue}</p>
)}
</div>
</div>
)}
{definition.type === 'NUMBER' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Number Value{definition.unit ? ` (${definition.unit})` : ''}</label>
<input
type="number"
value={attribute.valueNumber}
onChange={(e) => handleUpdateAttribute(idx, { valueNumber: e.target.value === '' ? '' : Number(e.target.value) })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
</div>
)}
{definition.type === 'BOOLEAN' && (
<label className="flex items-center gap-3 text-sm text-white">
<input
type="checkbox"
checked={attribute.valueBoolean}
onChange={(e) => handleUpdateAttribute(idx, { valueBoolean: e.target.checked })}
className="w-4 h-4"
/>
Enabled
</label>
)}
{definition.type === 'JSON' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">JSON Value</label>
<textarea
value={attribute.valueJson}
onChange={(e) => handleUpdateAttribute(idx, { valueJson: e.target.value })}
className="w-full min-h-[120px] px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner font-mono"
/>
</div>
)}
{definition.type === 'SELECT' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Selected Option</label>
<select
value={attribute.valueText}
onChange={(e) => handleUpdateAttribute(idx, { valueText: e.target.value })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all"
>
<option value="">Select an option</option>
{(definition.options || []).map((option, optionIdx) => {
const optionValue =
typeof option === 'string'
? option
: option.value || option.key || option.id || option.th || option.en || Object.values(option)[0]
const optionLabel =
typeof option === 'string'
? option
: option[activeLang] ||
option[`label_${activeLang}`] ||
option.label ||
option.th ||
option.en ||
optionValue
return (
<option key={`${optionValue}-${optionIdx}`} value={optionValue}>
{optionLabel}
</option>
)
})}
</select>
</div>
)}
</div>
)
})}
</div>
</section>
{/* Image Management */}
<section className="admin-card space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-cyan-glow">
<ImageIcon className="w-5 h-5" />
<h2 className="font-bold text-lg">Responsive Images Gallery</h2>
</div>
<p className="max-w-2xl text-sm text-slate-500">
Each gallery item keeps its Desktop / Tablet and Mobile assets together when reordered or removed.
</p>
</div>
<button
type="button"
onClick={() => openMediaModal((url) => {
setImages(prev => [...prev, { url, mobileUrl: null, order: prev.length }])
})}
className="flex shrink-0 items-center justify-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-all hover:bg-cyan-glow/20"
>
<ImagePlus className="w-3.5 h-3.5" />
Add Gallery Item
</button>
</div>
<div className="space-y-5">
{images.map((img, idx) => (
<div key={img.id ?? `new-image-${idx}`} className="animate-fade-in space-y-5 rounded-2xl border border-white/5 bg-white/[0.02] p-4 sm:p-5">
<div className="flex items-center justify-between gap-4 border-b border-white/5 pb-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-white">Gallery item {idx + 1}</p>
<p className="mt-1 text-[11px] text-slate-500">Both responsive assets move together.</p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleMoveImage(idx, -1)}
disabled={idx === 0}
aria-label={`Move gallery item ${idx + 1} up`}
title="Move up"
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/5 hover:text-cyan-glow disabled:cursor-not-allowed disabled:opacity-30"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleMoveImage(idx, 1)}
disabled={idx === images.length - 1}
aria-label={`Move gallery item ${idx + 1} down`}
title="Move down"
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/5 hover:text-cyan-glow disabled:cursor-not-allowed disabled:opacity-30"
>
<ArrowDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleRemoveImage(idx)}
aria-label={`Remove gallery item ${idx + 1}`}
title="Remove image pair"
className="rounded-lg p-2 text-slate-500 transition-all hover:bg-rose-500/10 hover:text-rose-500"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid gap-5 xl:grid-cols-2">
<div className="space-y-4 rounded-2xl border border-white/5 bg-night-950/40 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 p-2 text-cyan-glow">
<Monitor className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Desktop / Tablet · 3:4</h3>
<p className="mt-1 text-[11px] text-slate-500">Recommended 1200 × 1600px</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-[9rem_minmax(0,1fr)]">
<ImageAspectPreview
url={img.url}
label="Desktop"
expectedRatio={3 / 4}
expectedLabel="3:4"
frameClassName="aspect-[3/4] w-full"
/>
<div className="space-y-3">
<div className="space-y-2">
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Desktop image URL</label>
<div className="relative">
<input
type="text"
value={img.url || ''}
onChange={(e) => handleUpdateImage(idx, 'url', e.target.value)}
placeholder="https://... or /uploads/..."
className="w-full rounded-xl border border-white/10 bg-night-950 py-2.5 pl-4 pr-10 text-sm text-white shadow-inner outline-none transition-all focus:border-cyan-glow/30"
/>
{img.url && (
<a href={img.url} target="_blank" rel="noreferrer" aria-label="Open desktop image" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors hover:text-cyan-glow">
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateImage(idx, 'url', url))}
className="inline-flex items-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-colors hover:bg-cyan-glow/20"
>
<ImagePlus className="h-3.5 w-3.5" />
{img.url ? 'Replace / Browse' : 'Add / Browse'}
</button>
{img.url && (
<button
type="button"
onClick={() => handleUpdateImage(idx, 'url', '')}
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-rose-400 transition-colors hover:bg-rose-500/20"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
)}
</div>
{!img.url && img.mobileUrl && (
<p className="text-[11px] text-amber-400">Desktop / Tablet image is required before this pair can be saved.</p>
)}
</div>
</div>
</div>
<div className="space-y-4 rounded-2xl border border-white/5 bg-night-950/40 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 p-2 text-cyan-glow">
<Smartphone className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Mobile · 1:1</h3>
<p className="mt-1 text-[11px] text-slate-500">Recommended 1200 × 1200px · Optional</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-[9rem_minmax(0,1fr)]">
<ImageAspectPreview
url={img.mobileUrl}
fallbackUrl={img.url}
label="Mobile"
expectedRatio={1}
expectedLabel="1:1"
frameClassName="aspect-square w-full"
/>
<div className="space-y-3">
<div className="space-y-2">
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Mobile image URL</label>
<div className="relative">
<input
type="text"
value={img.mobileUrl || ''}
onChange={(e) => handleUpdateImage(idx, 'mobileUrl', e.target.value)}
placeholder="Optional — uses Desktop image when empty"
className="w-full rounded-xl border border-white/10 bg-night-950 py-2.5 pl-4 pr-10 text-sm text-white shadow-inner outline-none transition-all focus:border-cyan-glow/30"
/>
{img.mobileUrl && (
<a href={img.mobileUrl} target="_blank" rel="noreferrer" aria-label="Open mobile image" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors hover:text-cyan-glow">
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateImage(idx, 'mobileUrl', url))}
className="inline-flex items-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-colors hover:bg-cyan-glow/20"
>
<ImagePlus className="h-3.5 w-3.5" />
{img.mobileUrl ? 'Replace / Browse' : 'Add / Browse'}
</button>
{img.mobileUrl && (
<button
type="button"
onClick={() => handleUpdateImage(idx, 'mobileUrl', '')}
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-rose-400 transition-colors hover:bg-rose-500/20"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
)}
</div>
{!img.mobileUrl && img.url && (
<p className="flex items-center gap-1.5 text-[11px] text-slate-400">
<RotateCcw className="h-3 w-3" />
Mobile storefront will fall back to the Desktop image.
</p>
)}
</div>
</div>
</div>
</div>
</div>
))}
{images.length === 0 && (
<div className="text-center py-12 border-2 border-dashed border-white/5 rounded-2xl space-y-3">
<div className="w-12 h-12 bg-white/5 rounded-full flex items-center justify-center mx-auto text-slate-600">
<ImageIcon className="w-6 h-6" />
</div>
<p className="text-slate-600 italic text-sm">No gallery items added yet. The storefront will use its existing fallback image.</p>
</div>
)}
</div>
</section>
{/* Options Builder */}
<section className="admin-card space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<Package className="w-5 h-5" />
<h2 className="font-bold text-lg">Options Builder</h2>
</div>
{options.length < 3 && (
<button
onClick={() => setOptions([...options, { name: {}, values: [] }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Option
</button>
)}
</div>
<div className="space-y-4">
{options.map((opt, idx) => (
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl animate-fade-in space-y-4 relative group">
<div className="flex flex-col sm:flex-row gap-4 items-start">
<div className="w-full sm:w-1/3 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Name ({activeLang.toUpperCase()})</label>
<input
type="text"
value={opt.name[activeLang] || ''}
onChange={(e) => {
const newOpts = [...options]
newOpts[idx].name = { ...newOpts[idx].name, [activeLang]: e.target.value }
setOptions(newOpts)
regenerateVariants(newOpts)
}}
placeholder="e.g. Color, Size"
className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
</div>
<div className="flex-1 space-y-2 w-full">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Values ({activeLang.toUpperCase()})</label>
<div className="flex flex-col gap-2 mb-2">
{opt.values.map((val, vIdx) => (
<div key={vIdx} className="flex items-center gap-2">
<input
type="text"
value={val[activeLang] || ''}
onChange={(e) => {
const newOpts = [...options]
newOpts[idx].values[vIdx] = { ...newOpts[idx].values[vIdx], [activeLang]: e.target.value }
setOptions(newOpts)
regenerateVariants(newOpts)
}}
className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
placeholder={`Value ${vIdx + 1}`}
/>
{val.imageUrl ? (
<div className="relative group shrink-0 w-9 h-9 rounded-xl overflow-hidden border border-white/20 bg-black/60 flex items-center justify-center">
<img src={val.imageUrl} alt="Swatch" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/75 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-1 transition-opacity">
<button
type="button"
onClick={() => {
openMediaModal((url) => {
const newOpts = [...options]
newOpts[idx].values[vIdx] = { ...newOpts[idx].values[vIdx], imageUrl: url }
setOptions(newOpts)
const valName = val[activeLang] || val['th'] || val['en'] || ''
if (valName) {
setVariants(prev => prev.map(variant => {
const optMatch = idx === 0 ? variant.option1 : idx === 1 ? variant.option2 : variant.option3
if (optMatch === valName) {
return { ...variant, imageUrl: url }
}
return variant
}))
}
})
}}
className="p-1 text-cyan-400 hover:text-white"
title="Change swatch image"
>
<ImageIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => {
const newOpts = [...options]
const { imageUrl: _, ...rest } = newOpts[idx].values[vIdx]
newOpts[idx].values[vIdx] = rest
setOptions(newOpts)
}}
className="p-1 text-rose-400 hover:text-white"
title="Remove swatch image"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => {
openMediaModal((url) => {
const newOpts = [...options]
newOpts[idx].values[vIdx] = { ...newOpts[idx].values[vIdx], imageUrl: url }
setOptions(newOpts)
const valName = val[activeLang] || val['th'] || val['en'] || ''
if (valName) {
setVariants(prev => prev.map(variant => {
const optMatch = idx === 0 ? variant.option1 : idx === 1 ? variant.option2 : variant.option3
if (optMatch === valName) {
return { ...variant, imageUrl: url }
}
return variant
}))
}
})
}}
className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-white/20 hover:border-cyan-glow/50 text-slate-400 hover:text-cyan-glow bg-white/[0.02] text-xs font-medium transition-all"
title="Attach 1:1 image for this option"
>
<ImagePlus className="w-4 h-4 text-cyan-400/80" />
<span className="text-[11px] whitespace-nowrap">รูป 1:1</span>
</button>
)}
<ColorSwatchPicker
value={val.colorCode}
size="sm"
onChange={(color) => {
const newOpts = [...options]
newOpts[idx].values[vIdx] = { ...newOpts[idx].values[vIdx], colorCode: color }
setOptions(newOpts)
const valName = val[activeLang] || val['th'] || val['en'] || ''
if (valName) {
setVariants(prev => prev.map(variant => {
const optMatch = idx === 0 ? variant.option1 : idx === 1 ? variant.option2 : variant.option3
if (optMatch === valName) {
return { ...variant, colorCode: color }
}
return variant
}))
}
}}
onRemove={() => {
const newOpts = [...options]
const { colorCode: _, ...rest } = newOpts[idx].values[vIdx]
newOpts[idx].values[vIdx] = rest
setOptions(newOpts)
const valName = val[activeLang] || val['th'] || val['en'] || ''
if (valName) {
setVariants(prev => prev.map(variant => {
const optMatch = idx === 0 ? variant.option1 : idx === 1 ? variant.option2 : variant.option3
if (optMatch === valName) {
return { ...variant, colorCode: null }
}
return variant
}))
}
}}
/>
<button
onClick={() => {
const newOpts = [...options]
newOpts[idx].values.splice(vIdx, 1)
setOptions(newOpts)
regenerateVariants(newOpts)
}}
className="p-2 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-xl transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
onClick={() => {
const newOpts = [...options]
newOpts[idx].values.push({ [activeLang]: '' })
setOptions(newOpts)
}}
className="text-xs font-bold text-cyan-glow hover:text-cyan-glow/80 mt-2 flex items-center gap-1"
>
<Plus className="w-3 h-3" /> Add Value
</button>
</div>
<button
onClick={() => {
const newOpts = options.filter((_, i) => i !== idx)
setOptions(newOpts)
regenerateVariants(newOpts)
}}
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all sm:mt-6 self-end sm:self-auto"
title="Remove Option"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
{options.length === 0 && (
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl">
<p className="text-slate-600 italic text-sm">No options added. A product without options has a single default variant.</p>
</div>
)}
</div>
</section>
{/* Matrix Table */}
<section className="admin-card space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<Package className="w-5 h-5" />
<h2 className="font-bold text-lg">Variants Matrix</h2>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse whitespace-nowrap">
<thead>
<tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500">
{options.length > 0 ? options.map((opt, i) => (
<th key={i} className="py-3 px-4 font-bold">{opt.name[activeLang] || opt.name['th'] || opt.name['en'] || `Option ${i + 1}`}</th>
)) : (
<th className="py-3 px-4 font-bold">Variant</th>
)}
<th className="py-3 px-4 font-bold">Swatch (Image / Color)</th>
<th className="py-3 px-4 font-bold">Price (THB)</th>
<th className="py-3 px-4 font-bold">Stock</th>
<th className="py-3 px-4 font-bold">SKU</th>
<th className="py-3 px-4 font-bold">Barcode</th>
<th className="py-3 px-4 font-bold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{variants.length > 0 ? variants.map((v, idx) => (
<tr key={idx} className="hover:bg-white/[0.02] transition-colors">
{options.length > 0 ? options.map((_, i) => {
const optVal = i === 0 ? v.option1 : i === 1 ? v.option2 : v.option3
return (
<td key={i} className="py-3 px-4 text-sm font-medium text-white">{optVal || '-'}</td>
)
}) : (
<td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td>
)}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{/* 1:1 Image */}
{v.imageUrl ? (
<div className="flex items-center gap-1.5">
<div className="relative w-8 h-8 rounded-lg overflow-hidden border border-white/15 bg-black/60 shrink-0">
<img src={v.imageUrl} alt="Variant swatch" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
className="text-[10px] text-cyan-glow hover:underline text-left leading-none"
>
Change
</button>
<button
type="button"
onClick={() => handleUpdateVariant(idx, 'imageUrl', '')}
className="text-[10px] text-rose-400 hover:underline text-left leading-none"
>
Remove
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
className="px-2 py-1 rounded-lg border border-dashed border-white/15 hover:border-cyan-glow/40 bg-white/5 hover:bg-white/10 text-[11px] text-slate-400 hover:text-cyan-glow flex items-center gap-1 transition-all"
title="Attach 1:1 image"
>
<ImagePlus className="w-3.5 h-3.5" />
<span>1:1</span>
</button>
)}
{/* Color Swatch */}
<ColorSwatchPicker
value={v.colorCode}
size="sm"
onChange={(color) => handleUpdateVariant(idx, 'colorCode', color)}
onRemove={() => handleUpdateVariant(idx, 'colorCode', null)}
/>
</div>
</td>
<td className="py-3 px-4">
<input
type="number"
value={v.price ?? ''}
onChange={(e) => handleUpdateVariant(idx, 'price', e.target.value)}
className="w-24 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
/>
</td>
<td className="py-3 px-4">
<input
type="number"
value={v.stock ?? ''}
onChange={(e) => handleUpdateVariant(idx, 'stock', e.target.value)}
className="w-20 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
/>
</td>
<td className="py-3 px-4">
<input
type="text"
value={v.sku || ''}
onChange={(e) => handleUpdateVariant(idx, 'sku', e.target.value)}
placeholder="SKU"
className="w-32 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
/>
</td>
<td className="py-3 px-4">
<input
type="text"
value={v.barcode || ''}
onChange={(e) => handleUpdateVariant(idx, 'barcode', e.target.value)}
placeholder="Barcode"
className="w-32 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
/>
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleRemoveVariant(idx)}
className="p-1.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors inline-flex"
title="Remove Variant"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
)) : (
<tr>
<td colSpan={10} className="py-8 text-center text-slate-600 italic text-sm">
No variants available. {options.length > 0 ? "Add option values above to generate variants." : "Add options to create variants, or add a default variant."}
</td>
</tr>
)}
</tbody>
</table>
</div>
{options.length === 0 && variants.length === 0 && (
<div className="mt-4 flex justify-center">
<button
onClick={() => setVariants([{ option1: 'Default', price: 0, stock: 0, sku: '', barcode: '' }])}
className="btn-primary py-2 px-6"
>
Add Default Variant
</button>
</div>
)}
</section>
</div>
</div>
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
mediaType="image"
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
}}
/>
<ProductTranslationModal
isOpen={isTranslationModalOpen}
onClose={() => setIsTranslationModalOpen(false)}
productTitle={name[activeLang] || name.th || name.en || product?.brand?.name}
productId={id}
initialTab={translationModalTab}
data={{
name,
description,
details,
options,
attributes: attributes.map((attr) => ({
definitionId: attr.attributeDefinitionId,
name: typeof attr.attributeDefinition?.label === 'string'
? attr.attributeDefinition.label
: (attr.attributeDefinition?.code || 'Attribute'),
translations: attr.translations || {},
})),
}}
onApplyTranslations={(updated) => {
if (updated.name) setName(updated.name)
if (updated.description) setDescription(updated.description)
if (updated.details) setDetails(updated.details)
if (updated.options) setOptions(updated.options)
if (updated.attributes) {
setAttributes((prev) =>
prev.map((attr) => {
const matched = updated.attributes?.find((a) => a.definitionId === attr.attributeDefinitionId)
return matched ? { ...attr, translations: matched.translations } : attr
})
)
}
}}
/>
</div>
)
}