348 lines
16 KiB
TypeScript
348 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import Link from '../LocalizedLink'
|
|
import Image from 'next/image'
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import {
|
|
ArrowRight,
|
|
ChevronRight,
|
|
Star,
|
|
Sparkles,
|
|
Box,
|
|
} from 'lucide-react'
|
|
import type { MegaMenuPayload, MenuColumn } 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 {
|
|
items: MegaMenuPayload[]
|
|
lang: string
|
|
currentPath: string
|
|
}
|
|
|
|
|
|
|
|
const MAX_TOP_LEVEL_LINKS = 5
|
|
const MAX_INLINE_CHILD_LINKS = 3
|
|
|
|
const getMoreLabel = (count: number, lang: string) => {
|
|
if (lang === 'th') return `+ อีก ${count} รายการ`
|
|
if (lang === 'ja') return `+ 他${count}件`
|
|
if (lang === 'zh') return `+ 还有 ${count} 项`
|
|
if (lang === 'vi') return `+ ${count} mục khác`
|
|
if (lang === 'ko') return `+ ${count}개 더`
|
|
return `+ ${count} more`
|
|
}
|
|
|
|
// Render a 1:1 image using next/image for optimization (website uses CDN images)
|
|
function MenuImage({
|
|
src,
|
|
alt,
|
|
placeholder,
|
|
className = '',
|
|
style,
|
|
}: {
|
|
src?: string | null
|
|
alt: string
|
|
placeholder?: string
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
}) {
|
|
const [loaded, setLoaded] = useState(false)
|
|
|
|
if (!src) {
|
|
if (!placeholder) return null
|
|
return (
|
|
<span className="text-text-main/30 text-xs text-center px-3 leading-relaxed">{placeholder}</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
fill
|
|
sizes="(max-width: 1024px) 0px, 224px"
|
|
className={`object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'} ${className}`}
|
|
style={style}
|
|
onLoad={() => setLoaded(true)}
|
|
loading="lazy"
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) {
|
|
const [openId, setOpenId] = useState<string | null>(null)
|
|
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null)
|
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Automatically close mega menu when route changes
|
|
useEffect(() => {
|
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
|
setOpenId(null)
|
|
setHoveredLinkImage(null)
|
|
}, [currentPath])
|
|
|
|
const handleOpen = (id: string) => {
|
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
|
if (openId !== id) setHoveredLinkImage(null)
|
|
setOpenId(id)
|
|
}
|
|
|
|
const handleClose = () => {
|
|
closeTimer.current = setTimeout(() => {
|
|
setOpenId(null)
|
|
setHoveredLinkImage(null)
|
|
}, 150)
|
|
}
|
|
|
|
const handleLinkClick = () => {
|
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
|
setOpenId(null)
|
|
setHoveredLinkImage(null)
|
|
}
|
|
|
|
const getFirstImageLink = (columns: MenuColumn[] = []) => {
|
|
for (const column of columns) {
|
|
const found = column.links.find((link) => link.imageUrl)
|
|
if (found) return found
|
|
}
|
|
return columns[0]?.links[0]
|
|
}
|
|
|
|
return (
|
|
<div className="hidden space-x-7 text-sm font-medium uppercase tracking-wide md:flex">
|
|
{items.map((item, index) => {
|
|
const uniqueId = item.nodeId || item.id || `menu-${item.url || index}`
|
|
const isOpen = openId === uniqueId
|
|
const hasColumns = !!item.columns?.length
|
|
const isActive = item.url ? currentPath === item.url : false
|
|
|
|
const visibleColumns = item.columns?.slice(0, 2) ?? []
|
|
const featuredLink = getFirstImageLink(item.columns)
|
|
const featuredConfig = item.featured
|
|
const showFeatured = featuredConfig?.enabled !== false
|
|
const configuredImage = featuredConfig?.imageUrl || null
|
|
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 featuredBadge = getLocalizedMenuText(featuredConfig?.badge, lang) || 'Recommended'
|
|
const featuredTitle = getLocalizedMenuText(featuredConfig?.title, lang)
|
|
|| getLocalizedMenuText(featuredLink?.label, lang)
|
|
|| getLocalizedMenuText(item.label, lang)
|
|
const featuredSubtitle = getLocalizedMenuText(featuredConfig?.subtitle, lang) || (item.columns?.[0]
|
|
? getLocalizedMenuText(item.columns[0].title, lang)
|
|
: getLocalizedMenuText(item.label, lang))
|
|
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'
|
|
const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details'
|
|
|
|
return (
|
|
<div
|
|
key={uniqueId}
|
|
className="relative"
|
|
onMouseEnter={() => handleOpen(uniqueId)}
|
|
onMouseLeave={handleClose}
|
|
>
|
|
<Link
|
|
href={item.url || '#'}
|
|
onClick={handleLinkClick}
|
|
className={`flex items-center gap-1 border-b py-2 transition-colors ${
|
|
isOpen || isActive
|
|
? 'border-brand-primary text-text-main'
|
|
: 'border-transparent text-text-main hover:border-brand-primary hover:text-text-main'
|
|
}`}
|
|
>
|
|
{getLocalizedMenuText(item.label, lang)}
|
|
{hasColumns && (
|
|
<svg
|
|
className={`h-3 w-3 opacity-50 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
)}
|
|
</Link>
|
|
|
|
{hasColumns && (
|
|
<div
|
|
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${
|
|
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
|
|
}`}
|
|
style={{ top: 'var(--navbar-bottom, 64px)' }}
|
|
onMouseEnter={() => handleOpen(uniqueId)}
|
|
onMouseLeave={handleClose}
|
|
>
|
|
<div className="mx-auto h-3 w-20 overflow-hidden">
|
|
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
|
|
</div>
|
|
<div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
|
|
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}>
|
|
{visibleColumns.map((column, columnIndex) => (
|
|
<div
|
|
key={columnIndex}
|
|
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">
|
|
<DynamicIcon
|
|
iconName={column.icon}
|
|
defaultIcon={Sparkles}
|
|
className="h-4 w-4 text-brand-primary"
|
|
strokeWidth={1.5}
|
|
/>
|
|
{getLocalizedMenuText(column.title, lang)}
|
|
</h4>
|
|
<ul className="space-y-2">
|
|
{column.links.slice(0, MAX_TOP_LEVEL_LINKS).map((link, linkIndex) => {
|
|
const hasNestedLinks = !!link.links?.length
|
|
const visibleChildLinks = link.links?.slice(0, MAX_INLINE_CHILD_LINKS) ?? []
|
|
const hiddenChildCount = Math.max((link.links?.length ?? 0) - MAX_INLINE_CHILD_LINKS, 0)
|
|
const moreChildHref = link.url || link.links?.[MAX_INLINE_CHILD_LINKS]?.url || link.links?.[0]?.url || '#'
|
|
|
|
return (
|
|
<li key={linkIndex}>
|
|
<div
|
|
className="rounded-md"
|
|
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
|
|
onMouseLeave={() => setHoveredLinkImage(null)}
|
|
>
|
|
<Link
|
|
href={link.url || link.links?.[0]?.url || '#'}
|
|
onClick={handleLinkClick}
|
|
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">
|
|
<DynamicIcon
|
|
iconName={link.icon}
|
|
defaultIcon={Box}
|
|
className="h-5 w-5"
|
|
strokeWidth={1.35}
|
|
/>
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-bold normal-case tracking-normal text-text-main">
|
|
{getLocalizedMenuText(link.label, lang)}
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-xs font-normal normal-case tracking-normal text-muted-text">
|
|
{getLocalizedMenuText(column.title, lang)}
|
|
</span>
|
|
</span>
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-text-main/40 transition-colors group-hover:text-brand-primary" />
|
|
</Link>
|
|
|
|
{hasNestedLinks && (
|
|
<ul className="ml-[3.25rem] mt-1 space-y-1 border-l border-line-primary/15 pl-3">
|
|
{visibleChildLinks.map((childLink, childIndex) => (
|
|
<li key={childIndex}>
|
|
<Link
|
|
href={childLink.url || '#'}
|
|
onClick={handleLinkClick}
|
|
className="block rounded-sm px-2 py-1.5 text-xs font-normal normal-case tracking-normal text-muted-text transition-colors hover:bg-surface-muted hover:text-brand-primary"
|
|
onMouseEnter={() => setHoveredLinkImage(childLink.imageUrl || link.imageUrl || null)}
|
|
>
|
|
{getLocalizedMenuText(childLink.label, lang)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
{hiddenChildCount > 0 && (
|
|
<li>
|
|
<Link
|
|
href={moreChildHref}
|
|
onClick={handleLinkClick}
|
|
className="block rounded-sm px-2 py-1.5 text-xs font-semibold normal-case tracking-normal text-brand-primary transition-colors hover:bg-surface-muted"
|
|
>
|
|
{getMoreLabel(hiddenChildCount, lang)}
|
|
</Link>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
<Link
|
|
href={item.url || '#'}
|
|
onClick={handleLinkClick}
|
|
className="mt-7 flex items-center justify-between text-xs font-medium normal-case tracking-normal text-text-main/65 transition-colors hover:text-brand-primary"
|
|
>
|
|
<span>{getLocalizedMenuText(item.label, lang)}</span>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</div>
|
|
))}
|
|
|
|
{showFeatured && (
|
|
<div className="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">
|
|
<Star className="h-4 w-4 text-brand-primary" strokeWidth={1.5} />
|
|
{featuredHeading}
|
|
</h4>
|
|
<Link
|
|
href={featuredHref}
|
|
onClick={handleLinkClick}
|
|
className="group block overflow-hidden rounded-lg border border-line-primary/20 bg-surface-page transition-colors hover:border-brand-primary/40"
|
|
onMouseEnter={() => setHoveredLinkImage(configuredImage ?? featuredLink?.imageUrl ?? null)}
|
|
onMouseLeave={() => setHoveredLinkImage(null)}
|
|
>
|
|
<div className="relative h-40 w-full overflow-hidden bg-surface-muted">
|
|
<MenuImage
|
|
src={featuredImage}
|
|
alt={featuredTitle}
|
|
placeholder={getLocalizedMenuText(item.label, lang)}
|
|
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
|
style={{ objectPosition }}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2 p-4">
|
|
{featuredBadge && (
|
|
<span className="inline-flex rounded-full bg-surface-card px-2.5 py-1 text-[10px] font-bold normal-case tracking-normal text-brand-primary">
|
|
{featuredBadge}
|
|
</span>
|
|
)}
|
|
<div>
|
|
<p className="line-clamp-2 text-sm font-bold normal-case tracking-normal text-text-main">
|
|
{featuredTitle}
|
|
</p>
|
|
{featuredSubtitle && (
|
|
<p className="mt-1 line-clamp-1 text-xs font-normal normal-case tracking-normal text-muted-text">
|
|
{featuredSubtitle}
|
|
</p>
|
|
)}
|
|
{featuredConfig?.price && (
|
|
<p className="mt-2 text-sm font-bold normal-case tracking-normal text-text-main">
|
|
{featuredConfig.price}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span className="flex w-full items-center justify-center rounded-full border border-brand-primary/40 px-4 py-2 text-xs font-bold normal-case tracking-normal text-text-main transition-colors group-hover:bg-brand-primary group-hover:text-surface-card">
|
|
{featuredCta}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|