This commit is contained in:
Your Name committed 2026-06-28 21:34:58 -04:00
1 parent 3965f4f4da
commit 70877882b0
82 files changed
+4353 -186

No files matched your search

+86 -24
View File
@@ -13,21 +13,41 @@ const getLocalizedText = (textObj: Record<string, string> | string | undefined,
return textObj[lang] || textObj['en'] || Object.values(textObj)[0] || '';
};
// Image block shared between Root and Hover areas
function MenuImage({ src, alt, placeholder }: { src?: string | null; alt: string; placeholder?: string }) {
if (!src) {
if (!placeholder) return null;
return (
<div className="w-full h-full flex items-center justify-center">
<span className="text-pop-black/30 text-xs text-center px-3 leading-relaxed">{placeholder}</span>
</div>
);
}
return <img src={src} alt={alt} className="w-full h-full object-cover" loading="lazy" />;
}
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null);
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { i18n } = useTranslation();
const lang = i18n.language ? i18n.language.substring(0, 2) : 'en';
// Open: cancel any pending close, then open this menu
// Open: cancel any pending close, reset hover image when switching root menu
const handleOpen = (id: string) => {
if (closeTimer.current) clearTimeout(closeTimer.current);
if (openId !== id) {
setHoveredLinkImage(null);
}
setOpenId(id);
};
// Close: wait 150ms — if mouse enters the dropdown in time, the close is cancelled
const handleClose = () => {
closeTimer.current = setTimeout(() => setOpenId(null), 150);
closeTimer.current = setTimeout(() => {
setOpenId(null);
setHoveredLinkImage(null);
}, 150);
};
return (
@@ -37,6 +57,11 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const isOpen = openId === uniqueId;
const hasColumns = item.columns && item.columns.length > 0;
// Determine if any column has an icon OR any link has an image
const hasAnyColumnIcon = item.columns?.some(col => !!col.iconUrl);
const hasAnyLinkImage = item.columns?.some(col => col.links.some(l => !!l.imageUrl));
const showRightPanel = item.rootImageUrl || hasAnyLinkImage;
return (
<div
key={uniqueId}
@@ -66,9 +91,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
)}
</Link>
{/* Dropdown panel — fixed, full-width.
It handles its own onMouseEnter/Leave so the delay timer is
cancelled the moment the mouse enters the panel. */}
{/* Dropdown panel — fixed, full-width */}
{hasColumns && (
<div
className={`fixed left-0 right-0 z-50 transition-opacity duration-200 ${
@@ -79,26 +102,65 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
onMouseLeave={handleClose}
>
<div className="w-full bg-pop-bg border-b border-pop-black/10 shadow-sm cursor-default">
<div className="flex justify-center gap-16 py-10 max-w-7xl mx-auto px-6">
{item.columns!.map((col, idx) => (
<div key={idx} className="flex flex-col min-w-[140px] text-left">
<h4 className="text-[10px] uppercase tracking-widest text-pop-black/70 font-bold mb-4">
{getLocalizedText(col.title, lang)}
</h4>
<ul className="space-y-3">
{col.links.map((link, lIdx) => (
<li key={lIdx}>
<Link
to={link.url || '#'}
className="text-sm font-normal normal-case tracking-normal text-pop-black/80 hover:text-pop-black hover:opacity-80 block transition-colors"
>
{getLocalizedText(link.label, lang)}
</Link>
</li>
))}
</ul>
<div className="flex items-start gap-10 py-10 max-w-7xl mx-auto px-6">
{/* 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>
))}
)}
{/* CENTER: Columns */}
<div className="flex gap-12 flex-1">
{item.columns!.map((col, idx) => (
<div key={idx} className="flex gap-4 min-w-[160px] text-left items-start">
{/* Column icon (1:1) — shown only if this column has an iconUrl */}
{(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">
<MenuImage
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)}
</h4>
<ul className="space-y-2.5">
{col.links.map((link, lIdx) => (
<li key={lIdx}>
<Link
to={link.url || '#'}
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)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
{getLocalizedText(link.label, lang)}
</Link>
</li>
))}
</ul>
</div>
</div>
))}
</div>
{/* RIGHT: Hover preview image (1:1) — shown only if any link has an imageUrl */}
{showRightPanel && (
<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">
<MenuImage
src={hoveredLinkImage ?? item.rootImageUrl}
alt="Preview"
placeholder="Hover a link to preview"
/>
</div>
)}
</div>
</div>
</div>
+170 -46
View File
@@ -1,55 +1,179 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ArrowRight, Home, Search, ShoppingBag, AlertCircle } from 'lucide-react'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, type Category } from '../types'
const copy = {
th: {
eyebrow: 'ไม่พบหน้านี้',
title: 'เส้นทางนี้ยังไม่มีกลิ่นที่ตามหา',
description: 'ลิงก์อาจถูกย้าย ลบ หรือยังไม่ได้เผยแพร่ ลองกลับไปเลือกสินค้าหรือค้นหากลิ่นที่เหมาะกับพื้นที่ของคุณ',
home: 'กลับหน้าแรก',
shop: 'เลือกสินค้า',
search: 'ค้นหาสินค้า',
guide: 'คู่มือเลือกกลิ่น',
contact: 'ติดต่อเรา',
hint: '404',
},
en: {
eyebrow: 'Page not found',
title: 'This scent trail has faded',
description: 'The link may have moved, expired, or is not published yet. Head back to the collection or search for the right aroma.',
home: 'Back home',
shop: 'Shop products',
search: 'Search products',
guide: 'Scent Guide',
contact: 'Contact',
hint: '404',
},
ja: {
eyebrow: 'ページが見つかりません',
title: 'お探しの香りのページは見つかりません',
description: 'リンクが移動、削除、または未公開の可能性があります。コレクションに戻るか商品を検索してください。',
home: 'ホームへ戻る',
shop: '商品を見る',
search: '商品を検索',
guide: '香りガイド',
contact: 'お問い合わせ',
hint: '404',
},
zh: {
eyebrow: '页面未找到',
title: '这条香气路径暂时不存在',
description: '链接可能已移动、删除或尚未发布。你可以返回首页、浏览商品或搜索合适的香氛。',
home: '返回首页',
shop: '浏览商品',
search: '搜索商品',
guide: '香氛指南',
contact: '联系我们',
hint: '404',
},
vi: {
eyebrow: 'Không tìm thấy trang',
title: 'Đường dẫn hương thơm này không còn tồn tại',
description: 'Liên kết có thể đã được di chuyển, xóa hoặc chưa xuất bản. Hãy quay lại bộ sưu tập hoặc tìm sản phẩm phù hợp.',
home: 'Về trang chủ',
shop: 'Xem sản phẩm',
search: 'Tìm sản phẩm',
guide: 'Hướng dẫn mùi hương',
contact: 'Liên hệ',
hint: '404',
},
ko: {
eyebrow: '페이지를 찾을 수 없습니다',
title: '찾으시는 향의 경로가 사라졌습니다',
description: '링크가 이동되었거나 삭제되었거나 아직 공개되지 않았을 수 있습니다. 컬렉션으로 돌아가거나 상품을 검색해 보세요.',
home: '홈으로',
shop: '상품 보기',
search: '상품 검색',
guide: '향 가이드',
contact: '문의하기',
hint: '404',
},
} as const
type CopyLang = keyof typeof copy
function getCopy(lang: string) {
return copy[(lang in copy ? lang : 'th') as CopyLang]
}
export default function NotFoundPage() {
const [categories, setCategories] = useState<Category[]>([])
const { i18n } = useTranslation()
const text = getCopy(i18n.language)
useEffect(() => {
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
.then(r => r.json())
.then(setCategories)
.catch(console.error)
}, [i18n.language])
return (
<div className="min-h-screen flex items-center justify-center px-6 bg-spa-light">
{/* Background glow */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-spa-forest/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-spa-stone/10 rounded-full blur-3xl" />
</div>
<div className="min-h-screen bg-pop-bg flex flex-col">
<Navbar categories={categories} />
<div className="relative z-10 text-center space-y-8 max-w-lg">
{/* 404 Large */}
<div className="relative">
<div className="text-[10rem] font-bold leading-none text-transparent bg-clip-text bg-gradient-to-b from-white/20 to-white/5 select-none">
404
<main className="flex-1">
<section className="px-6 py-16 text-pop-black md:py-24">
<div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[1.05fr_0.95fr]">
<div>
<div className="mb-8 inline-flex items-center gap-3 rounded-full border border-pop-black/10 bg-white/50 px-4 py-2 shadow-sm">
<AlertCircle className="h-5 w-5 text-pop-pink" aria-hidden="true" />
<span className="text-xs font-bold uppercase tracking-widest text-pop-pink">{text.eyebrow}</span>
</div>
<p className="text-8xl font-black leading-none text-pop-pink/20 md:text-9xl">{text.hint}</p>
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-pop-black md:text-6xl">
{text.title}
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-pop-black/60 md:text-lg">
{text.description}
</p>
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
<Link
to="/"
className="ui-button-primary gap-2"
>
<Home className="h-4 w-4" />
{text.home}
</Link>
<Link
to="/shop"
className="ui-button-secondary gap-2 border border-pop-black/10 shadow-sm"
>
<ShoppingBag className="h-4 w-4" />
{text.shop}
</Link>
</div>
</div>
<div className="brutal-box bg-white/80 p-6 backdrop-blur md:p-8">
<div className="rounded-[1.5rem] bg-pop-bg p-6 shadow-inner border border-pop-black/5">
<div className="flex items-center gap-3 border-b border-pop-black/10 pb-5">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-pop-pink shadow-sm border border-pop-black/5">
<Search className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-pop-black/60">{text.hint}</p>
<p className="mt-1 text-sm font-semibold text-pop-black">{text.search}</p>
</div>
</div>
<div className="mt-6 grid gap-3">
<Link
to="/search"
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink shadow-sm border border-pop-black/5"
>
<span>{text.search}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
</Link>
<Link
to="/scent-guide"
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink shadow-sm border border-pop-black/5"
>
<span>{text.guide}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
</Link>
<Link
to="/contact"
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink shadow-sm border border-pop-black/5"
>
<span>{text.contact}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
</Link>
</div>
</div>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-5xl animate-float">🌿</div>
</div>
</div>
<div className="space-y-3">
<h1 className="text-3xl font-bold">Page Not Found</h1>
<p className="text-spa-stone leading-relaxed">
The page you're looking for seems to have drifted away like morning mist. Let's guide you back to serenity.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-2">
<Link
to="/"
className="px-8 py-3.5 bg-spa-light text-spa-forest font-bold rounded-lg tracking-widest uppercase hover:bg-spa-forest text-spa-light hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 text-sm"
>
Go Home
</Link>
<Link
to="/shop"
className="px-8 py-3.5 bg-spa-light shadow-sm border border-spa-stone/10 text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-light transition-all duration-300 border border-spa-stone/10 text-sm"
>
Browse Products
</Link>
</div>
<div className="pt-4 border-t border-spa-stone/10">
<div className="flex justify-center gap-6 text-xs text-spa-stone">
<Link to="/scent-guide" className="hover:text-spa-light transition-colors">Scent Guide</Link>
<Link to="/faq" className="hover:text-spa-light transition-colors">FAQ</Link>
<Link to="/contact" className="hover:text-spa-light transition-colors">Contact</Link>
</div>
</div>
</div>
</section>
</main>
<Footer categories={categories} />
</div>
)
}
+3
View File
@@ -5,11 +5,13 @@ export interface MenuLink {
label: Record<string, string>;
url?: string;
links?: MenuLink[];
imageUrl?: string | null; // รูปที่แสดงเมื่อ Hover ลิงก์
}
export interface MenuColumn {
title: Record<string, string>;
links: MenuLink[];
iconUrl?: string | null; // รูปประจำคอลัมน์ (1:1)
}
export interface MegaMenuPayload {
@@ -18,6 +20,7 @@ export interface MegaMenuPayload {
label: Record<string, string>;
url?: string;
columns?: MenuColumn[];
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
}
interface MenuState {