This commit is contained in:
Your Name committed 2026-07-14 19:26:20 -04:00
1 parent f6c188bd93
commit b78c9467bc
9 files changed
+654 -47

No files matched your search

@@ -1,6 +1,25 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { API_BASE, type Menu } from '@homaroma/types' import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react' import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react'
import { ICON_REGISTRY, type AvailableIcons } from '../../utils/iconRegistry'
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
return (
<select
className="admin-input w-full text-xs py-1 px-2 mt-2 bg-night-900 border border-white/10 text-white rounded"
value={value || ''}
onChange={e => onChange(e.target.value)}
>
<option value="">Default Icon (Sparkles/Box)</option>
{Object.keys(ICON_REGISTRY).map(iconName => (
<option key={iconName} value={iconName}>
{iconName}
</option>
))}
</select>
)
}
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko']; const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
@@ -133,6 +152,15 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
onChange={e => updateFeatured('imageUrl', e.target.value || null)} onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/> />
</div> </div>
{featured.imageUrl && (
<div className="pt-2">
<FocalPointPicker
imageUrl={featured.imageUrl}
focalPoint={featured.focalPoint}
onChange={(focalPoint) => updateFeatured('focalPoint', focalPoint)}
/>
</div>
)}
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured URL</label> <label className="text-[10px] uppercase font-bold text-slate-500">Featured URL</label>
<input <input
@@ -233,8 +261,22 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
const featured = node.featured || {}; const featured = node.featured || {};
const featuredTitle = featured.title?.en || featured.title?.th || featured.sectionTitle?.en || featured.sectionTitle?.th || 'Not configured'; const featuredTitle = featured.title?.en || featured.title?.th || featured.sectionTitle?.en || featured.sectionTitle?.th || 'Not configured';
const [isCollapsed, setIsCollapsed] = useState(type === 'column');
return ( return (
<div className={`border border-white/10 p-4 rounded-xl mt-4 ${depth > 0 ? 'ml-6 border-l-cyan-500/30' : ''}`}> <div className={`border border-white/10 p-4 rounded-xl mt-4 ${depth > 0 ? 'ml-6 border-l-cyan-500/30' : ''}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-white uppercase">{type}</span>
<span className="text-xs text-slate-400">{nodeLabel.en || nodeLabel.th || nodeLabel.ja || 'Untitled'}</span>
</div>
<button type="button" onClick={() => setIsCollapsed(!isCollapsed)} className="text-xs text-cyan-glow bg-white/5 hover:bg-white/10 px-2 py-1 rounded">
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
</div>
{!isCollapsed && (
<>
<div className="flex gap-4 items-start"> <div className="flex gap-4 items-start">
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -272,6 +314,12 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
placeholder="https://cdn.example.com/image.webp" placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })} onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/> />
{type !== 'root' && (
<IconSelect
value={node.icon}
onChange={val => onChange({ ...node, icon: val || undefined })}
/>
)}
</div> </div>
</div> </div>
@@ -338,6 +386,8 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
); );
} }
+103
View File
@@ -0,0 +1,103 @@
import {
// Business
Sparkles, Leaf, Wind, Droplets, Flower2, Flame, Sofa, BedDouble, Bath,
Sun, Moon, TreePine, Sprout, Coffee, Waves, Cherry, Citrus, Snowflake,
Palmtree, Crown, Gem, Feather, Smile, Zap, Cloud, Beaker, SprayCan,
Wine, MountainSnow, Vegan,
// E-commerce
ShoppingBag, ShoppingCart, Package, Truck, CreditCard, Wallet, Tag, Percent,
Gift, Ticket, Store, User, Heart, Clock, MapPin, Settings, LogOut, LogIn,
Bell, Bookmark, Search, Menu, X, ArrowRight, ArrowLeft, ChevronRight,
ChevronDown, Plus, Minus, Trash2, Eye, Filter, SlidersHorizontal, RefreshCw,
Camera, Share2, Award, ShieldCheck, Star, ThumbsUp, HelpCircle, Info,
Phone, Mail, MessageCircle, CheckCircle2, XCircle, AlertCircle, Calendar, Box
} from 'lucide-react';
export const ICON_REGISTRY = {
// Business
'sparkles': Sparkles,
'leaf': Leaf,
'wind': Wind,
'droplets': Droplets,
'flower': Flower2,
'flame': Flame,
'sofa': Sofa,
'bed': BedDouble,
'bath': Bath,
'sun': Sun,
'moon': Moon,
'tree-pine': TreePine,
'sprout': Sprout,
'coffee': Coffee,
'waves': Waves,
'cherry': Cherry,
'citrus': Citrus,
'snowflake': Snowflake,
'palmtree': Palmtree,
'crown': Crown,
'gem': Gem,
'feather': Feather,
'smile': Smile,
'zap': Zap,
'cloud': Cloud,
'beaker': Beaker,
'spray-can': SprayCan,
'wine': Wine,
'mountain': MountainSnow,
'vegan': Vegan,
// E-commerce
'shopping-bag': ShoppingBag,
'shopping-cart': ShoppingCart,
'package': Package,
'truck': Truck,
'credit-card': CreditCard,
'wallet': Wallet,
'tag': Tag,
'percent': Percent,
'gift': Gift,
'ticket': Ticket,
'store': Store,
'user': User,
'heart': Heart,
'clock': Clock,
'map-pin': MapPin,
'settings': Settings,
'logout': LogOut,
'login': LogIn,
'bell': Bell,
'bookmark': Bookmark,
'search': Search,
'menu': Menu,
'close': X,
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'chevron-right': ChevronRight,
'chevron-down': ChevronDown,
'plus': Plus,
'minus': Minus,
'trash': Trash2,
'eye': Eye,
'filter': Filter,
'sliders': SlidersHorizontal,
'refresh': RefreshCw,
'camera': Camera,
'share': Share2,
'award': Award,
'shield-check': ShieldCheck,
'star': Star,
'thumbs-up': ThumbsUp,
'help': HelpCircle,
'info': Info,
'phone': Phone,
'mail': Mail,
'chat': MessageCircle,
'check-circle': CheckCircle2,
'x-circle': XCircle,
'alert': AlertCircle,
'calendar': Calendar,
'box': Box,
} as const;
export type AvailableIcons = keyof typeof ICON_REGISTRY;
+32 -25
View File
@@ -4,17 +4,21 @@ import type { MegaMenuPayload, MenuColumn } from '../store/menuStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
ArrowRight, ArrowRight,
Award,
Box,
ChevronRight, ChevronRight,
Gift,
Leaf,
Package,
ShoppingBag,
Sparkles,
Star, Star,
type LucideIcon, Sparkles,
Box,
} from 'lucide-react'; } from 'lucide-react';
import { ICON_REGISTRY, type AvailableIcons } from '../utils/iconRegistry';
function DynamicIcon({ iconName, defaultIcon: DefaultIcon, className, strokeWidth }: { iconName?: string, defaultIcon: any, className?: string, strokeWidth?: number }) {
const IconComponent = (iconName && ICON_REGISTRY[iconName as AvailableIcons])
? ICON_REGISTRY[iconName as AvailableIcons]
: DefaultIcon;
return <IconComponent className={className} strokeWidth={strokeWidth} />;
}
interface DesktopMegaMenuProps { interface DesktopMegaMenuProps {
items: MegaMenuPayload[]; items: MegaMenuPayload[];
@@ -26,14 +30,7 @@ const getLocalizedText = (textObj: Record<string, string> | string | undefined,
return textObj[lang] || textObj.en || textObj.th || Object.values(textObj)[0] || ''; return textObj[lang] || textObj.en || textObj.th || Object.values(textObj)[0] || '';
}; };
const MENU_ICONS: LucideIcon[] = [
ShoppingBag,
Sparkles,
Leaf,
Package,
Gift,
Award,
];
const MAX_TOP_LEVEL_LINKS = 5; const MAX_TOP_LEVEL_LINKS = 5;
const MAX_INLINE_CHILD_LINKS = 3; const MAX_INLINE_CHILD_LINKS = 3;
@@ -48,16 +45,16 @@ const getMoreLabel = (count: number, lang: string) => {
}; };
// Image block shared between Root and Hover areas // Image block shared between Root and Hover areas
function MenuImage({ src, alt, placeholder }: { src?: string | null; alt: string; placeholder?: string }) { function MenuImage({ src, alt, placeholder, className, style }: { src?: string | null; alt: string; placeholder?: string; className?: string; style?: React.CSSProperties }) {
if (!src) { if (!src) {
if (!placeholder) return null; if (!placeholder) return null;
return ( return (
<div className="w-full h-full flex items-center justify-center"> <div className={`w-full h-full flex items-center justify-center ${className || ''}`} style={style}>
<span className="text-pop-black/30 text-xs text-center px-3 leading-relaxed">{placeholder}</span> <span className="text-pop-black/30 text-xs text-center px-3 leading-relaxed">{placeholder}</span>
</div> </div>
); );
} }
return <img src={src} alt={alt} className="w-full h-full object-cover" loading="lazy" />; return <img src={src} alt={alt} className={`w-full h-full object-cover ${className || ''}`} style={style} loading="lazy" />;
} }
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) { export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
@@ -105,6 +102,8 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const showFeatured = featuredConfig?.enabled !== false; const showFeatured = featuredConfig?.enabled !== false;
const configuredImage = featuredConfig?.imageUrl || null; const configuredImage = featuredConfig?.imageUrl || null;
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl; const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl;
const focalPoint = featuredConfig?.focalPoint;
const objectPosition = focalPoint ? `${focalPoint.x} ${focalPoint.y}` : 'center';
const featuredHeading = getLocalizedText(featuredConfig?.sectionTitle, lang) || 'Featured'; const featuredHeading = getLocalizedText(featuredConfig?.sectionTitle, lang) || 'Featured';
const featuredBadge = getLocalizedText(featuredConfig?.badge, lang) || 'Recommended'; const featuredBadge = getLocalizedText(featuredConfig?.badge, lang) || 'Recommended';
const featuredTitle = getLocalizedText(featuredConfig?.title, lang) const featuredTitle = getLocalizedText(featuredConfig?.title, lang)
@@ -165,15 +164,16 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
className={`pr-8 text-left ${idx > 0 ? 'border-l border-pop-black/10 pl-8' : ''}`} className={`pr-8 text-left ${idx > 0 ? 'border-l border-pop-black/10 pl-8' : ''}`}
> >
<h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-pop-black"> <h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-pop-black">
{(() => { <DynamicIcon
const HeadingIcon = MENU_ICONS[idx] ?? Sparkles; iconName={col.icon}
return <HeadingIcon className="h-4 w-4 text-pop-black/70" strokeWidth={1.5} />; defaultIcon={Sparkles}
})()} className="h-4 w-4 text-pop-black/70"
strokeWidth={1.5}
/>
{getLocalizedText(col.title, lang)} {getLocalizedText(col.title, lang)}
</h4> </h4>
<ul className="space-y-2"> <ul className="space-y-2">
{col.links.slice(0, MAX_TOP_LEVEL_LINKS).map((link, lIdx) => { {col.links.slice(0, MAX_TOP_LEVEL_LINKS).map((link, lIdx) => {
const RowIcon = MENU_ICONS[(idx * 3 + lIdx + 1) % MENU_ICONS.length] ?? Box;
const hasNestedLinks = !!link.links?.length; const hasNestedLinks = !!link.links?.length;
const visibleChildLinks = link.links?.slice(0, MAX_INLINE_CHILD_LINKS) ?? []; const visibleChildLinks = link.links?.slice(0, MAX_INLINE_CHILD_LINKS) ?? [];
const hiddenChildCount = Math.max((link.links?.length ?? 0) - MAX_INLINE_CHILD_LINKS, 0); const hiddenChildCount = Math.max((link.links?.length ?? 0) - MAX_INLINE_CHILD_LINKS, 0);
@@ -191,7 +191,12 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-pop-black/5" className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-pop-black/5"
> >
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-pop-black/10 bg-pop-bg text-pop-black/70 transition-colors group-hover:border-pop-black/30"> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-pop-black/10 bg-pop-bg text-pop-black/70 transition-colors group-hover:border-pop-black/30">
<RowIcon className="h-5 w-5" strokeWidth={1.35} /> <DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-5 w-5"
strokeWidth={1.35}
/>
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm font-bold normal-case tracking-normal text-pop-black"> <span className="block text-sm font-bold normal-case tracking-normal text-pop-black">
@@ -261,6 +266,8 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
src={featuredImage} src={featuredImage}
alt={featuredTitle} alt={featuredTitle}
placeholder={getLocalizedText(item.label, lang)} placeholder={getLocalizedText(item.label, lang)}
className="transition-transform duration-700 group-hover:scale-105"
style={{ objectPosition }}
/> />
</div> </div>
<div className="space-y-2 p-4"> <div className="space-y-2 p-4">
+3
View File
@@ -6,12 +6,14 @@ export interface MenuLink {
url?: string; url?: string;
links?: MenuLink[]; links?: MenuLink[];
imageUrl?: string | null; // รูปที่แสดงเมื่อ Hover ลิงก์ imageUrl?: string | null; // รูปที่แสดงเมื่อ Hover ลิงก์
icon?: string; // ชื่อ icon จาก registry (lucide)
} }
export interface MenuColumn { export interface MenuColumn {
title: Record<string, string> | string; title: Record<string, string> | string;
links: MenuLink[]; links: MenuLink[];
iconUrl?: string | null; // รูปประจำคอลัมน์ (1:1) iconUrl?: string | null; // รูปประจำคอลัมน์ (1:1)
icon?: string; // ชื่อ icon จาก registry (lucide)
} }
export interface MenuFeaturedCard { export interface MenuFeaturedCard {
@@ -22,6 +24,7 @@ export interface MenuFeaturedCard {
subtitle?: Record<string, string> | string; subtitle?: Record<string, string> | string;
price?: string; price?: string;
imageUrl?: string | null; imageUrl?: string | null;
focalPoint?: { x: string; y: string };
url?: string; url?: string;
ctaLabel?: Record<string, string> | string; ctaLabel?: Record<string, string> | string;
} }
+103
View File
@@ -0,0 +1,103 @@
import {
// Business
Sparkles, Leaf, Wind, Droplets, Flower2, Flame, Sofa, BedDouble, Bath,
Sun, Moon, TreePine, Sprout, Coffee, Waves, Cherry, Citrus, Snowflake,
Palmtree, Crown, Gem, Feather, Smile, Zap, Cloud, Beaker, SprayCan,
Wine, MountainSnow, Vegan,
// E-commerce
ShoppingBag, ShoppingCart, Package, Truck, CreditCard, Wallet, Tag, Percent,
Gift, Ticket, Store, User, Heart, Clock, MapPin, Settings, LogOut, LogIn,
Bell, Bookmark, Search, Menu, X, ArrowRight, ArrowLeft, ChevronRight,
ChevronDown, Plus, Minus, Trash2, Eye, Filter, SlidersHorizontal, RefreshCw,
Camera, Share2, Award, ShieldCheck, Star, ThumbsUp, HelpCircle, Info,
Phone, Mail, MessageCircle, CheckCircle2, XCircle, AlertCircle, Calendar, Box
} from 'lucide-react';
export const ICON_REGISTRY = {
// Business
'sparkles': Sparkles,
'leaf': Leaf,
'wind': Wind,
'droplets': Droplets,
'flower': Flower2,
'flame': Flame,
'sofa': Sofa,
'bed': BedDouble,
'bath': Bath,
'sun': Sun,
'moon': Moon,
'tree-pine': TreePine,
'sprout': Sprout,
'coffee': Coffee,
'waves': Waves,
'cherry': Cherry,
'citrus': Citrus,
'snowflake': Snowflake,
'palmtree': Palmtree,
'crown': Crown,
'gem': Gem,
'feather': Feather,
'smile': Smile,
'zap': Zap,
'cloud': Cloud,
'beaker': Beaker,
'spray-can': SprayCan,
'wine': Wine,
'mountain': MountainSnow,
'vegan': Vegan,
// E-commerce
'shopping-bag': ShoppingBag,
'shopping-cart': ShoppingCart,
'package': Package,
'truck': Truck,
'credit-card': CreditCard,
'wallet': Wallet,
'tag': Tag,
'percent': Percent,
'gift': Gift,
'ticket': Ticket,
'store': Store,
'user': User,
'heart': Heart,
'clock': Clock,
'map-pin': MapPin,
'settings': Settings,
'logout': LogOut,
'login': LogIn,
'bell': Bell,
'bookmark': Bookmark,
'search': Search,
'menu': Menu,
'close': X,
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'chevron-right': ChevronRight,
'chevron-down': ChevronDown,
'plus': Plus,
'minus': Minus,
'trash': Trash2,
'eye': Eye,
'filter': Filter,
'sliders': SlidersHorizontal,
'refresh': RefreshCw,
'camera': Camera,
'share': Share2,
'award': Award,
'shield-check': ShieldCheck,
'star': Star,
'thumbs-up': ThumbsUp,
'help': HelpCircle,
'info': Info,
'phone': Phone,
'mail': Mail,
'chat': MessageCircle,
'check-circle': CheckCircle2,
'x-circle': XCircle,
'alert': AlertCircle,
'calendar': Calendar,
'box': Box,
} as const;
export type AvailableIcons = keyof typeof ICON_REGISTRY;
@@ -5,19 +5,24 @@ import Image from 'next/image'
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { import {
ArrowRight, ArrowRight,
Award,
Box,
ChevronRight, ChevronRight,
Gift,
Leaf,
Package,
ShoppingBag,
Sparkles,
Star, Star,
type LucideIcon, Sparkles,
Box,
} from 'lucide-react' } from 'lucide-react'
import type { MegaMenuPayload, MenuColumn } from './menuTypes' import type { MegaMenuPayload, MenuColumn } from './menuTypes'
import { getLocalizedMenuText } from './menuTypes' import { getLocalizedMenuText } from './menuTypes'
import { ICON_REGISTRY, type AvailableIcons } from './iconRegistry'
function DynamicIcon({ iconName, defaultIcon: DefaultIcon, className, strokeWidth }: { iconName?: string, defaultIcon: any, className?: string, strokeWidth?: number }) {
const IconComponent = (iconName && ICON_REGISTRY[iconName as AvailableIcons])
? ICON_REGISTRY[iconName as AvailableIcons]
: DefaultIcon;
return <IconComponent className={className} strokeWidth={strokeWidth} />;
}
interface DesktopMegaMenuProps { interface DesktopMegaMenuProps {
items: MegaMenuPayload[] items: MegaMenuPayload[]
@@ -25,14 +30,7 @@ interface DesktopMegaMenuProps {
currentPath: string currentPath: string
} }
const MENU_ICONS: LucideIcon[] = [
ShoppingBag,
Sparkles,
Leaf,
Package,
Gift,
Award,
]
const MAX_TOP_LEVEL_LINKS = 5 const MAX_TOP_LEVEL_LINKS = 5
const MAX_INLINE_CHILD_LINKS = 3 const MAX_INLINE_CHILD_LINKS = 3
@@ -52,11 +50,13 @@ function MenuImage({
alt, alt,
placeholder, placeholder,
className = '', className = '',
style,
}: { }: {
src?: string | null src?: string | null
alt: string alt: string
placeholder?: string placeholder?: string
className?: string className?: string
style?: React.CSSProperties
}) { }) {
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
@@ -74,6 +74,7 @@ function MenuImage({
fill fill
sizes="(max-width: 1024px) 0px, 224px" sizes="(max-width: 1024px) 0px, 224px"
className={`object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'} ${className}`} className={`object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'} ${className}`}
style={style}
onLoad={() => setLoaded(true)} onLoad={() => setLoaded(true)}
loading="lazy" loading="lazy"
/> />
@@ -120,6 +121,8 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const showFeatured = featuredConfig?.enabled !== false const showFeatured = featuredConfig?.enabled !== false
const configuredImage = featuredConfig?.imageUrl || null const configuredImage = featuredConfig?.imageUrl || null
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl
const focalPoint = featuredConfig?.focalPoint
const objectPosition = focalPoint ? `${focalPoint.x} ${focalPoint.y}` : 'center'
const featuredHeading = getLocalizedMenuText(featuredConfig?.sectionTitle, lang) || 'Featured' const featuredHeading = getLocalizedMenuText(featuredConfig?.sectionTitle, lang) || 'Featured'
const featuredBadge = getLocalizedMenuText(featuredConfig?.badge, lang) || 'Recommended' const featuredBadge = getLocalizedMenuText(featuredConfig?.badge, lang) || 'Recommended'
const featuredTitle = getLocalizedMenuText(featuredConfig?.title, lang) const featuredTitle = getLocalizedMenuText(featuredConfig?.title, lang)
@@ -179,15 +182,16 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
className={`pr-8 ${columnIndex > 0 ? 'border-l border-line-primary/15 pl-8' : ''}`} className={`pr-8 ${columnIndex > 0 ? 'border-l border-line-primary/15 pl-8' : ''}`}
> >
<h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-text-main"> <h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-text-main">
{(() => { <DynamicIcon
const HeadingIcon = MENU_ICONS[columnIndex] ?? Sparkles iconName={column.icon}
return <HeadingIcon className="h-4 w-4 text-brand-primary" strokeWidth={1.5} /> defaultIcon={Sparkles}
})()} className="h-4 w-4 text-brand-primary"
strokeWidth={1.5}
/>
{getLocalizedMenuText(column.title, lang)} {getLocalizedMenuText(column.title, lang)}
</h4> </h4>
<ul className="space-y-2"> <ul className="space-y-2">
{column.links.slice(0, MAX_TOP_LEVEL_LINKS).map((link, linkIndex) => { {column.links.slice(0, MAX_TOP_LEVEL_LINKS).map((link, linkIndex) => {
const RowIcon = MENU_ICONS[(columnIndex * 3 + linkIndex + 1) % MENU_ICONS.length] ?? Box
const hasNestedLinks = !!link.links?.length const hasNestedLinks = !!link.links?.length
const visibleChildLinks = link.links?.slice(0, MAX_INLINE_CHILD_LINKS) ?? [] const visibleChildLinks = link.links?.slice(0, MAX_INLINE_CHILD_LINKS) ?? []
const hiddenChildCount = Math.max((link.links?.length ?? 0) - MAX_INLINE_CHILD_LINKS, 0) const hiddenChildCount = Math.max((link.links?.length ?? 0) - MAX_INLINE_CHILD_LINKS, 0)
@@ -205,7 +209,12 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-surface-muted" className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-surface-muted"
> >
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary transition-colors group-hover:border-brand-primary/40"> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary transition-colors group-hover:border-brand-primary/40">
<RowIcon className="h-5 w-5" strokeWidth={1.35} /> <DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-5 w-5"
strokeWidth={1.35}
/>
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm font-bold normal-case tracking-normal text-text-main"> <span className="block text-sm font-bold normal-case tracking-normal text-text-main">
@@ -275,6 +284,8 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
src={featuredImage} src={featuredImage}
alt={featuredTitle} alt={featuredTitle}
placeholder={getLocalizedMenuText(item.label, lang)} placeholder={getLocalizedMenuText(item.label, lang)}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
style={{ objectPosition }}
/> />
</div> </div>
<div className="space-y-2 p-4"> <div className="space-y-2 p-4">
@@ -0,0 +1,103 @@
import {
// Business
Sparkles, Leaf, Wind, Droplets, Flower2, Flame, Sofa, BedDouble, Bath,
Sun, Moon, TreePine, Sprout, Coffee, Waves, Cherry, Citrus, Snowflake,
Palmtree, Crown, Gem, Feather, Smile, Zap, Cloud, Beaker, SprayCan,
Wine, MountainSnow, Vegan,
// E-commerce
ShoppingBag, ShoppingCart, Package, Truck, CreditCard, Wallet, Tag, Percent,
Gift, Ticket, Store, User, Heart, Clock, MapPin, Settings, LogOut, LogIn,
Bell, Bookmark, Search, Menu, X, ArrowRight, ArrowLeft, ChevronRight,
ChevronDown, Plus, Minus, Trash2, Eye, Filter, SlidersHorizontal, RefreshCw,
Camera, Share2, Award, ShieldCheck, Star, ThumbsUp, HelpCircle, Info,
Phone, Mail, MessageCircle, CheckCircle2, XCircle, AlertCircle, Calendar, Box
} from 'lucide-react';
export const ICON_REGISTRY = {
// Business
'sparkles': Sparkles,
'leaf': Leaf,
'wind': Wind,
'droplets': Droplets,
'flower': Flower2,
'flame': Flame,
'sofa': Sofa,
'bed': BedDouble,
'bath': Bath,
'sun': Sun,
'moon': Moon,
'tree-pine': TreePine,
'sprout': Sprout,
'coffee': Coffee,
'waves': Waves,
'cherry': Cherry,
'citrus': Citrus,
'snowflake': Snowflake,
'palmtree': Palmtree,
'crown': Crown,
'gem': Gem,
'feather': Feather,
'smile': Smile,
'zap': Zap,
'cloud': Cloud,
'beaker': Beaker,
'spray-can': SprayCan,
'wine': Wine,
'mountain': MountainSnow,
'vegan': Vegan,
// E-commerce
'shopping-bag': ShoppingBag,
'shopping-cart': ShoppingCart,
'package': Package,
'truck': Truck,
'credit-card': CreditCard,
'wallet': Wallet,
'tag': Tag,
'percent': Percent,
'gift': Gift,
'ticket': Ticket,
'store': Store,
'user': User,
'heart': Heart,
'clock': Clock,
'map-pin': MapPin,
'settings': Settings,
'logout': LogOut,
'login': LogIn,
'bell': Bell,
'bookmark': Bookmark,
'search': Search,
'menu': Menu,
'close': X,
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'chevron-right': ChevronRight,
'chevron-down': ChevronDown,
'plus': Plus,
'minus': Minus,
'trash': Trash2,
'eye': Eye,
'filter': Filter,
'sliders': SlidersHorizontal,
'refresh': RefreshCw,
'camera': Camera,
'share': Share2,
'award': Award,
'shield-check': ShieldCheck,
'star': Star,
'thumbs-up': ThumbsUp,
'help': HelpCircle,
'info': Info,
'phone': Phone,
'mail': Mail,
'chat': MessageCircle,
'check-circle': CheckCircle2,
'x-circle': XCircle,
'alert': AlertCircle,
'calendar': Calendar,
'box': Box,
} as const;
export type AvailableIcons = keyof typeof ICON_REGISTRY;
@@ -3,12 +3,14 @@ export interface MenuLink {
url?: string url?: string
links?: MenuLink[] links?: MenuLink[]
imageUrl?: string | null // รูปที่แสดงเมื่อ Hover ลิงก์ imageUrl?: string | null // รูปที่แสดงเมื่อ Hover ลิงก์
icon?: string // ชื่อ icon จาก registry (lucide)
} }
export interface MenuColumn { export interface MenuColumn {
title: Record<string, string> | string title: Record<string, string> | string
links: MenuLink[] links: MenuLink[]
iconUrl?: string | null // รูปประจำคอลัมน์ (1:1) iconUrl?: string | null // รูปประจำคอลัมน์ (1:1)
icon?: string // ชื่อ icon จาก registry (lucide)
} }
export interface MenuFeaturedCard { export interface MenuFeaturedCard {
@@ -19,6 +21,7 @@ export interface MenuFeaturedCard {
subtitle?: Record<string, string> | string subtitle?: Record<string, string> | string
price?: string price?: string
imageUrl?: string | null imageUrl?: string | null
focalPoint?: { x: string; y: string }
url?: string url?: string
ctaLabel?: Record<string, string> | string ctaLabel?: Record<string, string> | string
} }
+224
View File
@@ -0,0 +1,224 @@
# HomAroma: Curated Lucide Icon Registry
This document contains a curated list of 80 `lucide-react` icons tailored specifically for the **HomAroma** project. It is divided into 30 business-specific icons (Home Fragrance & Wellness) and 50 general E-commerce icons.
You can use this list directly in your `iconRegistry.ts` file to populate the Admin Dropdown and Frontend components.
## 🌿 30 Business-Specific Icons (HomAroma / Fragrance / Wellness)
These icons are selected to represent product categories, scent notes, moods, and the core identity of a home fragrance and wellness brand.
| Icon Name | Suggested Usage / Meaning |
| :--- | :--- |
| `Sparkles` | Signature products, Best sellers, Magic / Premium |
| `Leaf` | Organic, Natural ingredients, Green notes |
| `Wind` | Aroma, Diffusers, Air circulation, Scent throw |
| `Droplets` | Essential oils, Serums, Liquid extracts |
| `Flower2` | Floral scent notes (Rose, Jasmine, etc.) |
| `Flame` | Candles, Warmth, Fireplace |
| `Sofa` | Home decor, Living room ambiance |
| `BedDouble` | Bedroom, Sleep wellness, Pillow mists |
| `Bath` | Bath & Body products, Spa, Relaxation |
| `Sun` | Morning routines, Bright / Uplifting scents |
| `Moon` | Night routines, Calming / Sleep scents |
| `TreePine` | Woody scent notes (Cedar, Sandalwood, Pine) |
| `Sprout` | Plant-based, Vegan, Clean beauty |
| `Coffee` | Gourmand notes (Coffee, Vanilla, Warmth) |
| `Waves` | Ocean breeze, Aquatic scent notes |
| `Cherry` | Fruity / Berry scent notes |
| `Citrus` | Citrus notes (Orange, Lemon, Bergamot) |
| `Snowflake` | Cooling, Mint, Crisp winter notes |
| `Palmtree` | Tropical / Summer collections |
| `Crown` | Luxury / Exclusive collections |
| `Gem` | Rare / Premium ingredients |
| `Feather` | Light, Gentle, Sensitive skin |
| `Smile` | Mood enhancing, Happiness |
| `Zap` | Energizing, Invigorating scents |
| `Cloud` | Airy, Soft, Linen sprays |
| `Beaker` | Formulation, Lab, Science of scent |
| `SprayCan` | Room sprays, Body mists |
| `Wine` | Rich / Boozy notes (Bourbon, Rum) |
| `MountainSnow` | Earthy, Fresh mountain air |
| `Vegan` | Cruelty-free, Vegan certification |
---
## 🛒 50 General E-commerce & UI Icons
These icons are essential for standard E-commerce navigation, user accounts, checkout flows, and UI controls.
### Shopping & Cart
| Icon Name | Suggested Usage / Meaning |
| :--- | :--- |
| `ShoppingBag` | Cart (Bag style) |
| `ShoppingCart` | Cart (Trolley style) |
| `Package` | Orders, Physical products |
| `Truck` | Shipping, Delivery |
| `CreditCard` | Payment methods |
| `Wallet` | Store credit, Points |
| `Tag` | Sale, Price tags |
| `Percent` | Discounts, Promotions |
| `Gift` | Gift cards, Wrapping service |
| `Ticket` | Vouchers, Coupons |
| `Store` | Physical stores, About the brand |
### Account & User
| Icon Name | Suggested Usage / Meaning |
| :--- | :--- |
| `User` | My Account, Profile |
| `Heart` | Wishlist, Favorites |
| `Clock` | Order history, Recently viewed |
| `MapPin` | Shipping address, Store locator |
| `Settings` | Account settings |
| `LogOut` | Sign out |
| `LogIn` | Sign in |
| `Bell` | Notifications |
| `Bookmark` | Saved for later |
### Navigation & Actions
| Icon Name | Suggested Usage / Meaning |
| :--- | :--- |
| `Search` | Search bar |
| `Menu` | Mobile hamburger menu |
| `X` | Close modals / drawers |
| `ArrowRight` | Next step, Call to action |
| `ArrowLeft` | Go back |
| `ChevronRight` | Breadcrumbs, Accordion |
| `ChevronDown` | Dropdowns |
| `Plus` | Add to cart / Expand |
| `Minus` | Decrease quantity / Collapse |
| `Trash2` | Remove item |
| `Eye` | Quick view |
| `Filter` | Filter products |
| `SlidersHorizontal` | Sort & Filter options |
| `RefreshCw` | Returns, Exchanges, Sync |
| `Camera` | Upload photo (Reviews) |
| `Share2` | Share product |
### Information & Trust
| Icon Name | Suggested Usage / Meaning |
| :--- | :--- |
| `Award` | Quality guarantee, Awards |
| `ShieldCheck` | Secure payment, Warranty |
| `Star` | Reviews, Ratings |
| `ThumbsUp` | Helpful review |
| `HelpCircle` | FAQ, Help center |
| `Info` | Product details, About |
| `Phone` | Customer service (Phone) |
| `Mail` | Newsletter, Email support |
| `MessageCircle` | Live chat |
| `CheckCircle2` | Success states, Validation |
| `XCircle` | Error states |
| `AlertCircle` | Warnings, Important notices |
| `Calendar` | Delivery dates, Events |
| `Box` | Inventory, General item |
---
## 💻 Example Implementation (TypeScript)
You can copy and paste this into your `iconRegistry.ts` file:
```typescript
import {
// Business
Sparkles, Leaf, Wind, Droplets, Flower2, Flame, Sofa, BedDouble, Bath,
Sun, Moon, TreePine, Sprout, Coffee, Waves, Cherry, Citrus, Snowflake,
Palmtree, Crown, Gem, Feather, Smile, Zap, Cloud, Beaker, SprayCan,
Wine, MountainSnow, Vegan,
// E-commerce
ShoppingBag, ShoppingCart, Package, Truck, CreditCard, Wallet, Tag, Percent,
Gift, Ticket, Store, User, Heart, Clock, MapPin, Settings, LogOut, LogIn,
Bell, Bookmark, Search, Menu, X, ArrowRight, ArrowLeft, ChevronRight,
ChevronDown, Plus, Minus, Trash2, Eye, Filter, SlidersHorizontal, RefreshCw,
Camera, Share2, Award, ShieldCheck, Star, ThumbsUp, HelpCircle, Info,
Phone, Mail, MessageCircle, CheckCircle2, XCircle, AlertCircle, Calendar, Box
} from 'lucide-react';
export const ICON_REGISTRY = {
// Business
'sparkles': Sparkles,
'leaf': Leaf,
'wind': Wind,
'droplets': Droplets,
'flower': Flower2,
'flame': Flame,
'sofa': Sofa,
'bed': BedDouble,
'bath': Bath,
'sun': Sun,
'moon': Moon,
'tree-pine': TreePine,
'sprout': Sprout,
'coffee': Coffee,
'waves': Waves,
'cherry': Cherry,
'citrus': Citrus,
'snowflake': Snowflake,
'palmtree': Palmtree,
'crown': Crown,
'gem': Gem,
'feather': Feather,
'smile': Smile,
'zap': Zap,
'cloud': Cloud,
'beaker': Beaker,
'spray-can': SprayCan,
'wine': Wine,
'mountain': MountainSnow,
'vegan': Vegan,
// E-commerce
'shopping-bag': ShoppingBag,
'shopping-cart': ShoppingCart,
'package': Package,
'truck': Truck,
'credit-card': CreditCard,
'wallet': Wallet,
'tag': Tag,
'percent': Percent,
'gift': Gift,
'ticket': Ticket,
'store': Store,
'user': User,
'heart': Heart,
'clock': Clock,
'map-pin': MapPin,
'settings': Settings,
'logout': LogOut,
'login': LogIn,
'bell': Bell,
'bookmark': Bookmark,
'search': Search,
'menu': Menu,
'close': X,
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'chevron-right': ChevronRight,
'chevron-down': ChevronDown,
'plus': Plus,
'minus': Minus,
'trash': Trash2,
'eye': Eye,
'filter': Filter,
'sliders': SlidersHorizontal,
'refresh': RefreshCw,
'camera': Camera,
'share': Share2,
'award': Award,
'shield-check': ShieldCheck,
'star': Star,
'thumbs-up': ThumbsUp,
'help': HelpCircle,
'info': Info,
'phone': Phone,
'mail': Mail,
'chat': MessageCircle,
'check-circle': CheckCircle2,
'x-circle': XCircle,
'alert': AlertCircle,
'calendar': Calendar,
'box': Box,
} as const;
export type AvailableIcons = keyof typeof ICON_REGISTRY;
```