x
This commit is contained in:
1 parent
52a5217d9b
commit
752451b1d4
2 files changed
+197
-54
No files matched your search
@@ -1,7 +1,20 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { MegaMenuPayload } from '../store/menuStore';
|
import type { MegaMenuPayload, MenuColumn } from '../store/menuStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Award,
|
||||||
|
Box,
|
||||||
|
ChevronRight,
|
||||||
|
Gift,
|
||||||
|
Leaf,
|
||||||
|
Package,
|
||||||
|
ShoppingBag,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface DesktopMegaMenuProps {
|
interface DesktopMegaMenuProps {
|
||||||
items: MegaMenuPayload[];
|
items: MegaMenuPayload[];
|
||||||
@@ -10,7 +23,28 @@ interface DesktopMegaMenuProps {
|
|||||||
const getLocalizedText = (textObj: Record<string, string> | string | undefined, lang: string) => {
|
const getLocalizedText = (textObj: Record<string, string> | string | undefined, lang: string) => {
|
||||||
if (typeof textObj === 'string') return textObj;
|
if (typeof textObj === 'string') return textObj;
|
||||||
if (!textObj) return '';
|
if (!textObj) return '';
|
||||||
return textObj[lang] || textObj['en'] || 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_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`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Image block shared between Root and Hover areas
|
// Image block shared between Root and Hover areas
|
||||||
@@ -50,17 +84,37 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
|||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
||||||
{items.map((item) => {
|
{items.map((item, index) => {
|
||||||
const uniqueId = item.nodeId || item.id || `menu-${item.url}`;
|
const uniqueId = item.nodeId || item.id || `menu-${item.url || index}`;
|
||||||
const isOpen = openId === uniqueId;
|
const isOpen = openId === uniqueId;
|
||||||
const hasColumns = item.columns && item.columns.length > 0;
|
const hasColumns = item.columns && item.columns.length > 0;
|
||||||
|
|
||||||
// Determine if any column has an icon OR any link has an image
|
const visibleColumns = item.columns?.slice(0, 2) ?? [];
|
||||||
const hasAnyColumnIcon = item.columns?.some(col => !!col.iconUrl);
|
const featuredLink = getFirstImageLink(item.columns);
|
||||||
const hasAnyLinkImage = item.columns?.some(col => col.links.some(l => !!l.imageUrl));
|
const featuredConfig = item.featured;
|
||||||
const showRightPanel = item.rootImageUrl || hasAnyLinkImage;
|
const showFeatured = featuredConfig?.enabled !== false;
|
||||||
|
const configuredImage = featuredConfig?.imageUrl || null;
|
||||||
|
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl;
|
||||||
|
const featuredHeading = getLocalizedText(featuredConfig?.sectionTitle, lang) || 'Featured';
|
||||||
|
const featuredBadge = getLocalizedText(featuredConfig?.badge, lang) || 'Recommended';
|
||||||
|
const featuredTitle = getLocalizedText(featuredConfig?.title, lang)
|
||||||
|
|| getLocalizedText(featuredLink?.label, lang)
|
||||||
|
|| getLocalizedText(item.label, lang);
|
||||||
|
const featuredSubtitle = getLocalizedText(featuredConfig?.subtitle, lang) || (item.columns?.[0]
|
||||||
|
? getLocalizedText(item.columns[0].title, lang)
|
||||||
|
: getLocalizedText(item.label, lang));
|
||||||
|
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#';
|
||||||
|
const featuredCta = getLocalizedText(featuredConfig?.ctaLabel, lang) || 'View details';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -91,76 +145,152 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Dropdown panel — fixed, full-width */}
|
|
||||||
{hasColumns && (
|
{hasColumns && (
|
||||||
<div
|
<div
|
||||||
className={`fixed left-0 right-0 z-50 transition-opacity duration-200 ${
|
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${
|
||||||
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
|
||||||
}`}
|
}`}
|
||||||
style={{ top: 'var(--navbar-bottom, 56px)' }}
|
style={{ top: 'var(--navbar-bottom, 56px)' }}
|
||||||
onMouseEnter={() => handleOpen(uniqueId)}
|
onMouseEnter={() => handleOpen(uniqueId)}
|
||||||
onMouseLeave={handleClose}
|
onMouseLeave={handleClose}
|
||||||
>
|
>
|
||||||
<div className="w-full bg-pop-bg border-b border-pop-black/10 shadow-sm cursor-default">
|
<div className="mx-auto h-3 w-20 overflow-hidden">
|
||||||
<div className="flex items-start gap-10 py-10 max-w-7xl mx-auto px-6">
|
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
|
||||||
|
|
||||||
{/* LEFT: Root menu image (1:1) — hidden on smaller screens */}
|
|
||||||
{item.rootImageUrl && (
|
|
||||||
<div className="w-56 h-56 shrink-0 rounded-xl overflow-hidden border border-pop-black/8 hidden lg:block">
|
|
||||||
<MenuImage src={item.rootImageUrl} alt={getLocalizedText(item.label, lang)} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="w-full overflow-hidden rounded-lg border border-pop-black/10 bg-pop-bg/95 shadow-2xl cursor-default backdrop-blur-xl">
|
||||||
|
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}>
|
||||||
{/* CENTER: Columns */}
|
{visibleColumns.map((col, idx) => (
|
||||||
<div className="flex gap-12 flex-1">
|
<div
|
||||||
{item.columns!.map((col, idx) => (
|
key={idx}
|
||||||
<div key={idx} className="flex gap-4 min-w-[160px] text-left items-start">
|
className={`pr-8 text-left ${idx > 0 ? 'border-l border-pop-black/10 pl-8' : ''}`}
|
||||||
|
>
|
||||||
{/* Column icon (1:1) — shown only if this column has an iconUrl */}
|
<h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-pop-black">
|
||||||
{(hasAnyColumnIcon) && (
|
{(() => {
|
||||||
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden bg-pop-black/5 border border-pop-black/8 mt-0.5">
|
const HeadingIcon = MENU_ICONS[idx] ?? Sparkles;
|
||||||
<MenuImage
|
return <HeadingIcon className="h-4 w-4 text-pop-black/70" strokeWidth={1.5} />;
|
||||||
src={col.iconUrl}
|
})()}
|
||||||
alt={getLocalizedText(col.title, lang)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Title and links */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h4 className="text-[10px] uppercase tracking-widest text-pop-black/60 font-bold mb-3">
|
|
||||||
{getLocalizedText(col.title, lang)}
|
{getLocalizedText(col.title, lang)}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-2.5">
|
<ul className="space-y-2">
|
||||||
{col.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 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={lIdx}>
|
<li key={lIdx}>
|
||||||
<Link
|
<div
|
||||||
to={link.url || '#'}
|
className="rounded-md"
|
||||||
className="text-sm font-normal normal-case tracking-normal text-pop-black/75 hover:text-pop-black block transition-colors duration-150"
|
|
||||||
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
|
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
|
||||||
onMouseLeave={() => setHoveredLinkImage(null)}
|
onMouseLeave={() => setHoveredLinkImage(null)}
|
||||||
>
|
>
|
||||||
|
<Link
|
||||||
|
to={link.url || link.links?.[0]?.url || '#'}
|
||||||
|
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">
|
||||||
|
<RowIcon 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-pop-black">
|
||||||
{getLocalizedText(link.label, lang)}
|
{getLocalizedText(link.label, lang)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs font-normal normal-case tracking-normal text-pop-black/55">
|
||||||
|
{getLocalizedText(col.title, lang)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-pop-black/40 transition-colors group-hover:text-pop-black" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{hasNestedLinks && (
|
||||||
|
<ul className="ml-[3.25rem] mt-1 space-y-1 border-l border-pop-black/10 pl-3">
|
||||||
|
{visibleChildLinks.map((childLink, childIndex) => (
|
||||||
|
<li key={childIndex}>
|
||||||
|
<Link
|
||||||
|
to={childLink.url || '#'}
|
||||||
|
className="block rounded-sm px-2 py-1.5 text-xs font-normal normal-case tracking-normal text-pop-black/60 transition-colors hover:bg-pop-black/5 hover:text-pop-black"
|
||||||
|
onMouseEnter={() => setHoveredLinkImage(childLink.imageUrl || link.imageUrl || null)}
|
||||||
|
>
|
||||||
|
{getLocalizedText(childLink.label, lang)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{hiddenChildCount > 0 && (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to={moreChildHref}
|
||||||
|
className="block rounded-sm px-2 py-1.5 text-xs font-semibold normal-case tracking-normal text-pop-black transition-colors hover:bg-pop-black/5"
|
||||||
|
>
|
||||||
|
{getMoreLabel(hiddenChildCount, lang)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
to={item.url || '#'}
|
||||||
|
className="mt-7 flex items-center justify-between text-xs font-medium normal-case tracking-normal text-pop-black/60 transition-colors hover:text-pop-black"
|
||||||
|
>
|
||||||
|
<span>{getLocalizedText(item.label, lang)}</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT: Hover preview image (1:1) — shown only if any link has an imageUrl */}
|
{showFeatured && (
|
||||||
{showRightPanel && (
|
<div className="border-l border-pop-black/10 pl-8">
|
||||||
<div className="w-56 h-56 shrink-0 rounded-xl overflow-hidden border border-pop-black/8 bg-pop-black/5 hidden lg:flex items-center justify-center">
|
<h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-pop-black">
|
||||||
|
<Star className="h-4 w-4 text-pop-black/70" strokeWidth={1.5} />
|
||||||
|
{featuredHeading}
|
||||||
|
</h4>
|
||||||
|
<Link
|
||||||
|
to={featuredHref}
|
||||||
|
className="group block overflow-hidden rounded-lg border border-pop-black/10 bg-pop-black/5 transition-colors hover:border-pop-black/30"
|
||||||
|
onMouseEnter={() => setHoveredLinkImage(configuredImage ?? featuredLink?.imageUrl ?? null)}
|
||||||
|
onMouseLeave={() => setHoveredLinkImage(null)}
|
||||||
|
>
|
||||||
|
<div className="relative h-40 w-full overflow-hidden bg-pop-black/5">
|
||||||
<MenuImage
|
<MenuImage
|
||||||
src={hoveredLinkImage ?? item.rootImageUrl}
|
src={featuredImage}
|
||||||
alt="Preview"
|
alt={featuredTitle}
|
||||||
placeholder="Hover a link to preview"
|
placeholder={getLocalizedText(item.label, lang)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{featuredBadge && (
|
||||||
|
<span className="inline-flex rounded-full bg-pop-bg px-2.5 py-1 text-[10px] font-bold normal-case tracking-normal text-pop-black/70">
|
||||||
|
{featuredBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="line-clamp-2 text-sm font-bold normal-case tracking-normal text-pop-black">
|
||||||
|
{featuredTitle}
|
||||||
|
</p>
|
||||||
|
{featuredSubtitle && (
|
||||||
|
<p className="mt-1 line-clamp-1 text-xs font-normal normal-case tracking-normal text-pop-black/55">
|
||||||
|
{featuredSubtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{featuredConfig?.price && (
|
||||||
|
<p className="mt-2 text-sm font-bold normal-case tracking-normal text-pop-black">
|
||||||
|
{featuredConfig.price}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="flex w-full items-center justify-center rounded-full border border-pop-black/20 px-4 py-2 text-xs font-bold normal-case tracking-normal text-pop-black transition-colors group-hover:bg-pop-black group-hover:text-pop-bg">
|
||||||
|
{featuredCta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,25 +2,38 @@ import { create } from 'zustand';
|
|||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
label: Record<string, string>;
|
label: Record<string, string> | string;
|
||||||
url?: string;
|
url?: string;
|
||||||
links?: MenuLink[];
|
links?: MenuLink[];
|
||||||
imageUrl?: string | null; // รูปที่แสดงเมื่อ Hover ลิงก์
|
imageUrl?: string | null; // รูปที่แสดงเมื่อ Hover ลิงก์
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuColumn {
|
export interface MenuColumn {
|
||||||
title: Record<string, string>;
|
title: Record<string, string> | string;
|
||||||
links: MenuLink[];
|
links: MenuLink[];
|
||||||
iconUrl?: string | null; // รูปประจำคอลัมน์ (1:1)
|
iconUrl?: string | null; // รูปประจำคอลัมน์ (1:1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MenuFeaturedCard {
|
||||||
|
enabled?: boolean;
|
||||||
|
sectionTitle?: Record<string, string> | string;
|
||||||
|
badge?: Record<string, string> | string;
|
||||||
|
title?: Record<string, string> | string;
|
||||||
|
subtitle?: Record<string, string> | string;
|
||||||
|
price?: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
url?: string;
|
||||||
|
ctaLabel?: Record<string, string> | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MegaMenuPayload {
|
export interface MegaMenuPayload {
|
||||||
id?: string;
|
id?: string;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
label: Record<string, string>;
|
label: Record<string, string> | string;
|
||||||
url?: string;
|
url?: string;
|
||||||
columns?: MenuColumn[];
|
columns?: MenuColumn[];
|
||||||
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
|
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
|
||||||
|
featured?: MenuFeaturedCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuState {
|
interface MenuState {
|
||||||
|
|||||||
Reference in new issue
Block a user