This commit is contained in:
Your Name committed 2026-10-06 18:13:03 +07:00
1 parent 46968782b6
commit 08045eabff
18 files changed
+943 -104

No files matched your search

@@ -33,6 +33,15 @@ export default function ProductCarouselForm({
}: ProductCarouselFormProps) {
const [isPickerOpen, setIsPickerOpen] = useState(false);
React.useEffect(() => {
if (editingSection?.payload && !editingSection.payload.type) {
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), type: 'featured' }
} : null);
}
}, []);
const updateFlag = (field: 'showPrice' | 'showAddToCart', value: boolean) => {
setEditingSection({
...editingSection,
@@ -104,6 +113,61 @@ export default function ProductCarouselForm({
className="admin-input w-full"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Card Layout Style</label>
<select
value={editingSection.payload?.cardStyle || 'standard'}
onChange={(e) =>
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), cardStyle: e.target.value }
} : null)
}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="standard">Standard (Retail with Add to Cart)</option>
<option value="minimal_swatch">Minimal Swatch (Editorial with Color Dots)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">View All Link (URL)</label>
<input
type="text"
value={editingSection.payload?.viewAllLink || ''}
onChange={(e) =>
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), viewAllLink: e.target.value }
} : null)
}
className="admin-input w-full"
placeholder="e.g. /shop or /category/1"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">View All Label</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'viewAllText')}
onChange={(e) => updateLocalizedValue('viewAllText', e.target.value)}
className="admin-input w-full"
placeholder="e.g. View All / ดูทั้งหมด"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Badge Text (Optional)</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'badgeText')}
onChange={(e) => updateLocalizedValue('badgeText', e.target.value)}
className="admin-input w-full"
placeholder="e.g. NEW / แนะนำ"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -0,0 +1,254 @@
import { useState, useRef, useEffect } from 'react'
import { Palette, X, Pipette, Check } from 'lucide-react'
interface ColorSwatchPickerProps {
value?: string | null
onChange: (color: string) => void
onRemove?: () => void
buttonLabel?: string
size?: 'sm' | 'md'
}
const PRESET_COLORS = [
{ name: 'Black', hex: '#18181B' },
{ name: 'White', hex: '#FFFFFF' },
{ name: 'Charcoal', hex: '#374151' },
{ name: 'Slate', hex: '#64748B' },
{ name: 'Red', hex: '#EF4444' },
{ name: 'Crimson', hex: '#991B1B' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Amber', hex: '#F59E0B' },
{ name: 'Emerald', hex: '#10B981' },
{ name: 'Sky', hex: '#0EA5E9' },
{ name: 'Blue', hex: '#2563EB' },
{ name: 'Indigo', hex: '#6366F1' },
{ name: 'Violet', hex: '#8B5CF6' },
{ name: 'Rose', hex: '#F43F5E' },
{ name: 'Rose Gold', hex: '#E0A99E' },
{ name: 'Gold', hex: '#D4AF37' },
]
export default function ColorSwatchPicker({
value,
onChange,
onRemove,
buttonLabel = 'Color',
size = 'md',
}: ColorSwatchPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [tempHex, setTempHex] = useState(value || '#18181B')
const popoverRef = useRef<HTMLDivElement>(null)
const nativeColorInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (value) setTempHex(value)
}, [value])
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const handleSelectPreset = (hex: string) => {
setTempHex(hex)
onChange(hex)
}
const handleApplyHex = (hex: string) => {
let clean = hex.trim()
if (clean && !clean.startsWith('#')) {
clean = `#${clean}`
}
setTempHex(clean)
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(clean)) {
onChange(clean)
}
}
const isLightColor = (hex?: string | null) => {
if (!hex) return false
const color = hex.replace('#', '')
if (color.length !== 6) return false
const r = parseInt(color.substring(0, 2), 16)
const g = parseInt(color.substring(2, 4), 16)
const b = parseInt(color.substring(4, 6), 16)
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 180
}
return (
<div className="relative inline-block" ref={popoverRef}>
{value ? (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`group relative flex items-center gap-2 rounded-xl border border-white/20 bg-night-950 hover:border-cyan-glow/50 transition-all ${
size === 'sm' ? 'p-1 text-xs' : 'px-2 py-1.5 text-xs'
}`}
title={`Color: ${value}`}
>
<div
className={`rounded-lg border border-white/20 shrink-0 shadow-inner flex items-center justify-center ${
size === 'sm' ? 'w-6 h-6' : 'w-7 h-7'
}`}
style={{ backgroundColor: value }}
>
<div
className={`w-1.5 h-1.5 rounded-full ${
isLightColor(value) ? 'bg-black/60' : 'bg-white/70'
}`}
/>
</div>
<span className="font-mono text-[11px] text-slate-300 group-hover:text-cyan-glow">
{value.toUpperCase()}
</span>
</button>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="p-1 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
title="Remove color"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
) : (
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`shrink-0 flex items-center gap-1.5 rounded-xl border border-dashed border-white/20 hover:border-cyan-glow/50 text-slate-400 hover:text-cyan-glow bg-white/[0.02] font-medium transition-all ${
size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-2.5 py-1.5 text-xs'
}`}
title="Pick color"
>
<Palette className={size === 'sm' ? 'w-3 h-3 text-cyan-400/80' : 'w-3.5 h-3.5 text-cyan-400/80'} />
<span className="whitespace-nowrap">{buttonLabel}</span>
</button>
)}
{/* Popover */}
{isOpen && (
<div className="absolute z-50 mt-2 left-0 w-64 p-3 rounded-2xl bg-night-900 border border-white/15 shadow-2xl backdrop-blur-xl space-y-3">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300 flex items-center gap-1.5">
<Palette className="w-3.5 h-3.5 text-cyan-glow" />
Select Color
</span>
<button
type="button"
onClick={() => setIsOpen(false)}
className="p-1 text-slate-500 hover:text-white rounded-lg transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Quick Preset Palette */}
<div>
<div className="text-[10px] text-slate-500 font-semibold mb-1.5 uppercase tracking-wider">Presets</div>
<div className="grid grid-cols-8 gap-1.5">
{PRESET_COLORS.map((preset) => {
const isSelected = value?.toUpperCase() === preset.hex.toUpperCase()
return (
<button
key={preset.hex}
type="button"
onClick={() => handleSelectPreset(preset.hex)}
title={`${preset.name} (${preset.hex})`}
className={`w-6 h-6 rounded-lg border flex items-center justify-center transition-all hover:scale-110 ${
isSelected
? 'border-cyan-glow ring-2 ring-cyan-glow/40'
: 'border-white/20 hover:border-white/50'
}`}
style={{ backgroundColor: preset.hex }}
>
{isSelected && (
<Check
className={`w-3 h-3 ${isLightColor(preset.hex) ? 'text-black' : 'text-white'}`}
/>
)}
</button>
)
})}
</div>
</div>
{/* Custom Color Input & Hex */}
<div className="pt-2 border-t border-white/10 space-y-2">
<div className="text-[10px] text-slate-500 font-semibold uppercase tracking-wider">Custom Shade</div>
<div className="flex items-center gap-2">
<input
ref={nativeColorInputRef}
type="color"
value={tempHex.startsWith('#') && tempHex.length === 7 ? tempHex : '#18181B'}
onChange={(e) => {
setTempHex(e.target.value)
onChange(e.target.value)
}}
className="sr-only"
/>
<button
type="button"
onClick={() => nativeColorInputRef.current?.click()}
className="w-9 h-9 rounded-xl border border-white/20 shrink-0 flex items-center justify-center shadow-inner hover:scale-105 transition-all"
style={{ backgroundColor: tempHex }}
title="Click to open color spectrum picker"
>
<Pipette
className={`w-4 h-4 ${isLightColor(tempHex) ? 'text-black' : 'text-white'}`}
/>
</button>
<div className="relative flex-1">
<input
type="text"
value={tempHex}
onChange={(e) => handleApplyHex(e.target.value)}
placeholder="#000000"
maxLength={7}
className="w-full px-3 py-1.5 bg-night-950 border border-white/15 focus:border-cyan-glow/50 rounded-xl outline-none text-xs font-mono text-white transition-all shadow-inner uppercase"
/>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
{value && onRemove && (
<button
type="button"
onClick={() => {
onRemove()
setIsOpen(false)
}}
className="text-[11px] text-rose-400 hover:text-rose-300 hover:underline"
>
Clear Color
</button>
)}
<button
type="button"
onClick={() => setIsOpen(false)}
className="ml-auto px-3 py-1 bg-cyan-glow/10 hover:bg-cyan-glow/20 border border-cyan-glow/30 text-cyan-glow rounded-lg text-xs font-semibold transition-colors"
>
Done
</button>
</div>
</div>
)}
</div>
)
}
+2 -1
View File
@@ -930,10 +930,11 @@ export default function PageBuilderPage() {
defaultPayload = {
title: 'Featured Collection',
description: 'Hand-picked products for this campaign.',
collectionId: '',
type: 'featured',
limit: 6,
showPrice: true,
showAddToCart: true,
cardStyle: 'standard',
};
break;
case 'PRODUCT_MAIN':
+86 -35
View File
@@ -28,6 +28,7 @@ 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
@@ -129,7 +130,7 @@ export default function ProductEditPage() {
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 })[]}[]>([])
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>('')
@@ -259,7 +260,7 @@ export default function ProductEditPage() {
setVariants(updated)
}
const regenerateVariants = (newOptions: {name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string })[]}[]) => {
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) {
@@ -284,13 +285,15 @@ export default function ProductEditPage() {
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 }
return { option1, option2, option3, price: 0, stock: 0, sku: '', barcode: '', imageUrl: swatchUrl, colorCode: swatchColor }
})
})
}
@@ -414,6 +417,7 @@ export default function ProductEditPage() {
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 } : {}),
@@ -1165,6 +1169,41 @@ export default function ProductEditPage() {
<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]
@@ -1231,7 +1270,7 @@ export default function ProductEditPage() {
)) : (
<th className="py-3 px-4 font-bold">Variant</th>
)}
<th className="py-3 px-4 font-bold">Swatch (1:1)</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>
@@ -1251,38 +1290,50 @@ export default function ProductEditPage() {
<td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td>
)}
<td className="py-3 px-4">
{v.imageUrl ? (
<div className="flex items-center gap-2">
<div className="relative w-10 h-10 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 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>
<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"
>
Change
</button>
<button
type="button"
onClick={() => handleUpdateVariant(idx, 'imageUrl', '')}
className="text-[10px] text-rose-400 hover:underline text-left"
>
Remove
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
className="px-2.5 py-1.5 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.5 transition-all"
>
<ImagePlus className="w-3.5 h-3.5" />
<span>1:1 Image</span>
</button>
)}
) : (
<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