diff --git a/apps/admin/src/components/products/ProductOptionUxModal.tsx b/apps/admin/src/components/products/ProductOptionUxModal.tsx new file mode 100644 index 0000000..db79c7b --- /dev/null +++ b/apps/admin/src/components/products/ProductOptionUxModal.tsx @@ -0,0 +1,326 @@ +import React, { useState, useEffect } from 'react' +import { + X, + Sparkles, + LayoutGrid, + ListFilter, + Maximize2, + Check, + Loader2, + SlidersHorizontal, + Info +} from 'lucide-react' +import { API_BASE, type Product } from '@homaroma/types' + +export type OptionDisplayType = 'auto' | 'pills' | 'dropdown' | 'modal' + +interface OptionConfig { + name: any + values: any[] + displayType?: OptionDisplayType +} + +interface Props { + isOpen: boolean + product: Product | null + onClose: () => void + onSaveSuccess: (updatedProduct: Product) => void +} + +function getOptionLabel(nameObj: any): string { + if (!nameObj) return 'Option' + if (typeof nameObj === 'string') return nameObj + return nameObj.th || nameObj.en || Object.values(nameObj)[0] || 'Option' +} + +function getValueLabel(valObj: any): string { + if (!valObj) return '' + if (typeof valObj === 'string') return valObj + return valObj.th || valObj.en || Object.values(valObj)[0] || '' +} + +const DISPLAY_OPTIONS: Array<{ + type: OptionDisplayType + title: string + subtitle: string + icon: React.ComponentType<{ className?: string }> + badgeText: string + badgeColor: string +}> = [ + { + type: 'auto', + title: 'Auto (Smart Adaptive)', + subtitle: 'ระบบเลือกให้อัตโนมัติ: มี Swatch จะแสดงกล่องสี, ≤ 8 ตัวเลือกแสดงปุ่มกด, > 8 ตัวเลือกสลับเป็น Dropdown', + icon: Sparkles, + badgeText: 'แนะนำ', + badgeColor: 'bg-cyan-glow/15 text-cyan-glow border-cyan-glow/30' + }, + { + type: 'pills', + title: '1. แบบเดิม (Pills / Swatches)', + subtitle: 'ปุ่มกดเรียงกันแบบ Flex-wrap ทุกตัวเลือก เหมาะกับตัวเลือกน้อย (2–8 ตัวเลือก เช่น Size S/M/L/XL)', + icon: LayoutGrid, + badgeText: 'ปุ่มกด', + badgeColor: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' + }, + { + type: 'dropdown', + title: '2. Searchable Dropdown', + subtitle: 'กล่อง Dropdown กะทัดรัด เมื่อคลิกจะกางเมนูพร้อมช่องค้นหา เหมาะกับตัวเลือกปานกลาง (10–30 ตัวเลือก)', + icon: ListFilter, + badgeText: 'Dropdown', + badgeColor: 'bg-amber-500/15 text-amber-400 border-amber-500/30' + }, + { + type: 'modal', + title: '3. Modal Dialog / Bottom Sheet', + subtitle: 'ปุ่มกดเปิดหน้าต่าง Pop-up กลางจอ (Desktop) หรือ Bottom Sheet (Mobile) ค้นหาเต็มตา เหมาะกับตัวเลือกเยอะมาก (30–100+ ตัวเลือก)', + icon: Maximize2, + badgeText: 'Modal ค้นหา', + badgeColor: 'bg-purple-500/15 text-purple-400 border-purple-500/30' + } +] + +export default function ProductOptionUxModal({ isOpen, product, onClose, onSaveSuccess }: Props) { + const [optionsConfig, setOptionsConfig] = useState([]) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!product) { + setOptionsConfig([]) + return + } + const rawOptions = (product.options as any[]) || [] + setOptionsConfig( + rawOptions.map(opt => ({ + name: opt.name, + values: opt.values || [], + displayType: (opt.displayType as OptionDisplayType) || 'auto' + })) + ) + setError(null) + }, [product, isOpen]) + + if (!isOpen || !product) return null + + const handleTypeChange = (optIndex: number, type: OptionDisplayType) => { + setOptionsConfig(prev => { + const next = [...prev] + next[optIndex] = { ...next[optIndex], displayType: type } + return next + }) + } + + const handleSave = async () => { + try { + setSaving(true) + setError(null) + + const payloadOptions = optionsConfig.map(opt => ({ + name: opt.name, + values: opt.values, + displayType: opt.displayType + })) + + const res = await fetch(`${API_BASE}/products/${product.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + options: payloadOptions + }) + }) + + if (!res.ok) { + const errJson = await res.json().catch(() => ({})) + throw new Error(errJson.message || `บันทึกไม่สำเร็จ (Status ${res.status})`) + } + + const data = await res.json() + const updatedProduct = data.product || { ...product, options: payloadOptions } + onSaveSuccess(updatedProduct) + onClose() + } catch (err: any) { + setError(err.message || 'เกิดข้อผิดพลาดในการบันทึก') + } finally { + setSaving(false) + } + } + + const rawName = product.name + const resolvedName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || Object.values(rawName || {})[0] || '') + const productName = resolvedName || (product.brand?.name ? `${product.brand.name} Product` : `Product #${product.id}`) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

+ ตั้งค่ารูปแบบการเลือก Option (UX/UI Selector) +

+

+ {productName} +

+
+
+ +
+ + {/* Options List */} + {optionsConfig.length === 0 ? ( +
+
+ +
+

สินค้านี้ไม่มีตัวเลือก (Options / Variants)

+

+ สินค้านี้เป็นสินค้าเดี่ยวที่ไม่มีตัวเลือกขนาด สี หรือรุ่น จึงไม่ต้องตั้งค่า Option UX/UI +

+
+ ) : ( +
+ {optionsConfig.map((opt, optIdx) => { + const optLabel = getOptionLabel(opt.name) + const count = opt.values.length + const currentType = opt.displayType || 'auto' + + return ( +
+ {/* Option Header */} +
+
+ + Option {optIdx + 1} + +

+ {optLabel} +

+ + {count} ตัวเลือก + +
+ + {/* Quick values preview */} +
+ ตัวอย่าง: {opt.values.slice(0, 3).map(getValueLabel).join(', ')}{count > 3 ? ` ... (+${count - 3})` : ''} +
+
+ + {/* Selector Style Cards */} +
+ {DISPLAY_OPTIONS.map((style) => { + const Icon = style.icon + const isSelected = currentType === style.type + + return ( + + ) + })} +
+
+ ) + })} +
+ )} + + {/* Error Feedback */} + {error && ( +
+ + {error} +
+ )} + + {/* Footer */} +
+

+ * การตั้งค่านี้จะมีผลกับหน้า Storefront ของสินค้านี้ทันที +

+
+ + +
+
+
+
+ ) +} diff --git a/apps/admin/src/pages/ProductsPage.tsx b/apps/admin/src/pages/ProductsPage.tsx index 894731b..cb3a9df 100644 --- a/apps/admin/src/pages/ProductsPage.tsx +++ b/apps/admin/src/pages/ProductsPage.tsx @@ -16,12 +16,14 @@ import { FileJson, Download, CheckCircle2, - Sparkles + Sparkles, + SlidersHorizontal } from 'lucide-react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types' import ProductJsonGuideModal from '../components/ProductJsonGuideModal' +import ProductOptionUxModal from '../components/products/ProductOptionUxModal' interface PaginationMeta { @@ -58,6 +60,10 @@ export default function ProductsPage() { const [isGuideModalOpen, setIsGuideModalOpen] = useState(false) const [importJsonText, setImportJsonText] = useState('') const [importLoading, setImportLoading] = useState(false) + + // Option UX/UI Selector Modal State + const [optionUxProduct, setOptionUxProduct] = useState(null) + const [isOptionUxModalOpen, setIsOptionUxModalOpen] = useState(false) const [importError, setImportError] = useState(null) const [importSuccessMessage, setImportSuccessMessage] = useState(null) @@ -649,6 +655,16 @@ export default function ProductsPage() { > + + + {/* Floating Dropdown Menu */} + {isOpen && ( +
+ {/* Search Box */} +
+ + setSearchQuery(e.target.value)} + placeholder={placeholderText} + className="w-full pl-9 pr-8 py-2 bg-surface-page border border-line-primary/20 rounded-xl text-xs text-text-main placeholder:text-muted-text/50 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary transition" + /> + {searchQuery && ( + + )} +
+ + {/* Counter info */} +
+ + {searchQuery ? `พบ ${filteredValues.length} จาก ${values.length} รายการ` : `ตัวเลือกทั้งหมด ${values.length} รายการ`} + + คลิกเพื่อเลือก +
+ + {/* Options Scroll List */} +
+ {filteredValues.length > 0 ? ( + filteredValues.map(({ baseVal, displayVal, available }) => { + const isSelected = selectedValue === baseVal; + return ( + + ); + }) + ) : ( +
+

{emptyText}

+

ลองพิมพ์คำค้นหาอื่น เช่น ชื่อรุ่นรถ ยี่ห้อ หรือขนาด

+
+ )} +
+
+ )} + + ); +} + +function OptionModalSelect({ + optionName, + values, + selectedValue, + onSelect, + isOptionAvailable, + lang, + optionsMeta, +}: { + optionName: string + values: any[] + selectedValue: string + onSelect: (val: string) => void + isOptionAvailable: (val: string) => boolean + lang: string + optionsMeta: any[] | null | undefined +}) { + const [isOpen, setIsOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' && isOpen) { + setIsOpen(false) + } + } + if (isOpen) { + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', handleKeyDown) + setTimeout(() => inputRef.current?.focus(), 80) + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + window.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + + const selectedDisplayVal = useMemo(() => { + if (!selectedValue) return '' + return resolveOptionValue(selectedValue, optionsMeta, lang) + }, [selectedValue, optionsMeta, lang]) + + const isCurrentAvailable = useMemo(() => { + if (!selectedValue) return false + return isOptionAvailable(selectedValue) + }, [selectedValue, isOptionAvailable]) + + const filteredValues = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + return values + .map(val => { + const baseVal = getBaseString(val) + const displayVal = resolveLocalized(val, lang) + const available = isOptionAvailable(baseVal) + return { baseVal, displayVal, available } + }) + .filter(item => { + if (!q) return true + return ( + item.displayVal.toLowerCase().includes(q) || + item.baseVal.toLowerCase().includes(q) + ) + }) + }, [values, searchQuery, lang, isOptionAvailable]) + + const placeholderText = useMemo(() => { + switch (lang.slice(0, 2)) { + case 'th': return `ค้นหาตัวเลือก หรือชื่อรุ่น (${values.length} รายการ)...` + case 'ja': return `オプションまたはモデルを検索 (${values.length}件)...` + case 'zh': return `搜索规格或型号 (${values.length} 项)...` + default: return `Search options or model (${values.length} items)...` + } + }, [lang, values.length]) + + return ( +
+ {/* Trigger Button */} + + + {/* Modal Dialog (Desktop) / Bottom Sheet (Mobile) */} + {isOpen && ( +
setIsOpen(false)} + > +
e.stopPropagation()} + > + {/* Mobile Sheet Handle */} +
+ + {/* Modal Header */} +
+
+

+ {optionName} +

+

+ ตัวเลือกทั้งหมด {values.length} รายการ (คลิกที่ตัวเลือกเพื่อเลือกทันที) +

+
+ +
+ + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + placeholder={placeholderText} + className="w-full pl-10 pr-9 py-2.5 bg-surface-page border border-line-primary/20 rounded-xl text-xs md:text-sm text-text-main placeholder:text-muted-text/50 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary transition" + /> + {searchQuery && ( + + )} +
+ + {/* Counter info */} +
+ + {searchQuery ? `พบ ${filteredValues.length} จาก ${values.length} รายการ` : `รายการทั้งหมด ${values.length} ตัวเลือก`} + + กด ESC เพื่อปิด +
+ + {/* Grid of Options (2 cols on desktop, 1 col on mobile) */} +
+ {filteredValues.length > 0 ? ( + filteredValues.map(({ baseVal, displayVal, available }) => { + const isSelected = selectedValue === baseVal; + return ( + + ); + }) + ) : ( +
+

ไม่พบตัวเลือกที่ตรงกับคำค้นหา

+

ลองพิมพ์คำค้นหาอื่น เช่น ชื่อรุ่นรถ ยี่ห้อ หรือขนาด

+
+ )} +
+
+
+ )} +
+ ); +} + + export default function AddToCartSection({ product, showBrandMeta = true, @@ -424,79 +883,108 @@ export default function AddToCartSection({ return Boolean(sw.imageUrl || sw.colorCode); }); + const displayType = ((option as any).displayType || 'auto') as string; + const isLargeList = option.values.length > 8 && !hasAnySwatch; + const shouldRenderModal = displayType === 'modal'; + const shouldRenderDropdown = displayType === 'dropdown' || (displayType === 'auto' && isLargeList); + return (
-

- {t.selectPrefix(optNameDisplay)} -

+ {shouldRenderModal ? ( + handleOptionChange(optIndex, val)} + isOptionAvailable={(val) => isOptionAvailable(optIndex, val)} + lang={lang} + optionsMeta={product.options} + /> + ) : shouldRenderDropdown ? ( + handleOptionChange(optIndex, val)} + isOptionAvailable={(val) => isOptionAvailable(optIndex, val)} + lang={lang} + optionsMeta={product.options} + /> + ) : ( + <> +

+ {t.selectPrefix(optNameDisplay)} +

-
- {option.values.map(val => { - const baseVal = getBaseString(val); - const displayVal = resolveLocalized(val, lang); - const isSelected = selectedOptions[optIndex] === baseVal; - const isAvailable = isOptionAvailable(optIndex, baseVal); - const swatch = getOptionSwatch(optIndex, baseVal); +
+ {option.values.map(val => { + const baseVal = getBaseString(val); + const displayVal = resolveLocalized(val, lang); + const isSelected = selectedOptions[optIndex] === baseVal; + const isAvailable = isOptionAvailable(optIndex, baseVal); + const swatch = getOptionSwatch(optIndex, baseVal); - if (hasAnySwatch) { - return ( - + ); + } + + return ( + - ); - } - - return ( - - ); - })} -
+ + ); + })} +
+ + )}
); })} diff --git a/apps/website/src/components/ProductGallery.tsx b/apps/website/src/components/ProductGallery.tsx index 8d49589..138f49a 100644 --- a/apps/website/src/components/ProductGallery.tsx +++ b/apps/website/src/components/ProductGallery.tsx @@ -24,7 +24,7 @@ function ResponsiveProductImage({ image, alt, className, fetchPriority, sizes }: src: image.url, alt, width: 1200, - height: 1600, + height: 1200, sizes, fetchPriority, }).props @@ -82,9 +82,7 @@ export default function ProductGallery({ productName, categoryName, images, stre const frameClass = stretchDesktop ? 'relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card md:aspect-auto md:h-full md:min-h-0' : 'relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card' - const mainImageClass = stretchDesktop - ? 'h-full w-full object-cover md:object-contain' - : 'h-full w-full object-cover' + const mainImageClass = 'h-full w-full object-contain p-4 md:p-6' if (imageCount === 1) { return ( @@ -101,7 +99,7 @@ export default function ProductGallery({ productName, categoryName, images, stre return (
-
+
{sortedImages.map((image, idx) => (