1492 lines
73 KiB
TypeScript
1492 lines
73 KiB
TypeScript
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. "NORIFUMI Oil Cup GP Series" 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>
|
||
)
|
||
}
|