325 lines
9.2 KiB
TypeScript
325 lines
9.2 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useRef, useCallback } from "react";
|
||
import Image from "next/image";
|
||
import menuData from "../menu/menu.json";
|
||
|
||
/* ────── Helpers ────── */
|
||
interface MenuItem {
|
||
name: string;
|
||
ingredients?: string;
|
||
taste_profile?: string;
|
||
grape_variety?: string;
|
||
price:
|
||
| string
|
||
| { single?: string; double?: string; glass?: string; bottle?: string };
|
||
}
|
||
|
||
interface Category {
|
||
id: string;
|
||
title: string;
|
||
items: MenuItem[];
|
||
}
|
||
|
||
interface MenuData {
|
||
restaurant_name: string;
|
||
footer_note: string;
|
||
categories: Category[];
|
||
}
|
||
|
||
const data = menuData as MenuData;
|
||
|
||
/* ────── Stars component ────── */
|
||
interface Star {
|
||
id: number;
|
||
left: string;
|
||
top: string;
|
||
duration: string;
|
||
delay: string;
|
||
maxOpacity: number;
|
||
}
|
||
|
||
function HeroStars() {
|
||
const [stars, setStars] = useState<Star[]>([]);
|
||
|
||
useEffect(() => {
|
||
setStars(
|
||
Array.from({ length: 40 }, (_, i) => ({
|
||
id: i,
|
||
left: `${Math.random() * 100}%`,
|
||
top: `${Math.random() * 100}%`,
|
||
duration: `${3 + Math.random() * 4}s`,
|
||
delay: `${Math.random() * 5}s`,
|
||
maxOpacity: 0.3 + Math.random() * 0.5,
|
||
}))
|
||
);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="hero-stars">
|
||
{stars.map((s) => (
|
||
<span
|
||
key={s.id}
|
||
className="star"
|
||
style={
|
||
{
|
||
left: s.left,
|
||
top: s.top,
|
||
"--duration": s.duration,
|
||
"--max-opacity": s.maxOpacity,
|
||
animationDelay: s.delay,
|
||
} as React.CSSProperties
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ────── Price renderer ────── */
|
||
function PriceDisplay({ price }: { price: MenuItem["price"] }) {
|
||
if (typeof price === "string") {
|
||
return <span className="item-price">{price}</span>;
|
||
}
|
||
|
||
const entries: [string, string][] = [];
|
||
if (price.single) entries.push(["Tek", price.single]);
|
||
if (price.double) entries.push(["Dbl", price.double]);
|
||
if (price.glass) entries.push(["Kadeh", price.glass]);
|
||
if (price.bottle) entries.push(["Şişe", price.bottle]);
|
||
|
||
return (
|
||
<div className="item-price-multi">
|
||
{entries.map(([label, val]) => (
|
||
<div key={label} className="price-row">
|
||
<span className="price-label">{label}</span>
|
||
<span className="item-price">{val}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ────── Category Icon (decorative) ────── */
|
||
function getCategoryIcon(id: string): string {
|
||
const icons: Record<string, string> = {
|
||
classic_cocktails: "🍸",
|
||
lunas_cocktails: "🌙",
|
||
shots: "🥃",
|
||
gin: "🫒",
|
||
whiskey: "🥂",
|
||
rum: "🏴☠️",
|
||
tequila: "🌵",
|
||
vodka: "🧊",
|
||
cognac: "🍷",
|
||
red_wine: "🍷",
|
||
white_wine: "🥂",
|
||
roze_blush: "🌸",
|
||
champagne_prosecco: "🍾",
|
||
draft_beer: "🍺",
|
||
bottle_beer: "🍻",
|
||
coffee: "☕",
|
||
soft_drinks: "🧃",
|
||
snacks: "🥜",
|
||
};
|
||
return icons[id] || "✦";
|
||
}
|
||
|
||
/* ────── Main Page ────── */
|
||
export default function MenuPage() {
|
||
const [activeCategory, setActiveCategory] = useState<string>(
|
||
data.categories[0]?.id || ""
|
||
);
|
||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||
const navRef = useRef<HTMLDivElement>(null);
|
||
const sectionRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||
|
||
/* Intersection observer to update active category */
|
||
useEffect(() => {
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
const visible = entries
|
||
.filter((e) => e.isIntersecting)
|
||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||
if (visible.length > 0) {
|
||
const id = visible[0].target.getAttribute("data-category-id");
|
||
if (id) setActiveCategory(id);
|
||
}
|
||
},
|
||
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 }
|
||
);
|
||
|
||
sectionRefs.current.forEach((el) => observer.observe(el));
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
/* Back-to-top visibility */
|
||
useEffect(() => {
|
||
const onScroll = () => setShowBackToTop(window.scrollY > 600);
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => window.removeEventListener("scroll", onScroll);
|
||
}, []);
|
||
|
||
/* Scroll nav button into view */
|
||
useEffect(() => {
|
||
if (!navRef.current) return;
|
||
const btn = navRef.current.querySelector(
|
||
`[data-nav-id="${activeCategory}"]`
|
||
);
|
||
if (btn) {
|
||
btn.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||
}
|
||
}, [activeCategory]);
|
||
|
||
const scrollToCategory = useCallback((id: string) => {
|
||
const el = sectionRefs.current.get(id);
|
||
if (el) {
|
||
const offset = 56;
|
||
const y = el.getBoundingClientRect().top + window.scrollY - offset;
|
||
window.scrollTo({ top: y, behavior: "smooth" });
|
||
}
|
||
}, []);
|
||
|
||
const setSectionRef = useCallback(
|
||
(id: string) => (el: HTMLElement | null) => {
|
||
if (el) sectionRefs.current.set(id, el);
|
||
},
|
||
[]
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{/* ───── HERO ───── */}
|
||
<section className="hero" id="hero">
|
||
<HeroStars />
|
||
<div className="hero-moon" />
|
||
|
||
{/* Background illustration */}
|
||
|
||
|
||
<div className="hero-brand">
|
||
<Image
|
||
src="/logo1.png"
|
||
alt="Luna Cocktail and More"
|
||
width={360}
|
||
height={360}
|
||
className="hero-logo"
|
||
priority
|
||
/>
|
||
<div className="hero-divider" />
|
||
<p className="hero-subtitle">MENÜ</p>
|
||
</div>
|
||
|
||
<a
|
||
href="#menu"
|
||
className="hero-scroll-cta"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
scrollToCategory(data.categories[0]?.id);
|
||
}}
|
||
>
|
||
MENÜYÜ KEŞFET
|
||
<span className="scroll-arrow" />
|
||
</a>
|
||
</section>
|
||
|
||
{/* ───── CATEGORY NAV ───── */}
|
||
<nav className="category-nav" id="menu">
|
||
<div className="category-nav-inner" ref={navRef}>
|
||
{data.categories.map((cat) => (
|
||
<button
|
||
key={cat.id}
|
||
data-nav-id={cat.id}
|
||
className={`category-nav-btn ${activeCategory === cat.id ? "active" : ""
|
||
}`}
|
||
onClick={() => scrollToCategory(cat.id)}
|
||
>
|
||
{cat.title.toLocaleUpperCase('en')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</nav>
|
||
|
||
{/* ───── MENU CONTENT ───── */}
|
||
<main className="menu-container">
|
||
{data.categories.map((cat) => (
|
||
<section
|
||
key={cat.id}
|
||
className="category-section"
|
||
data-category-id={cat.id}
|
||
ref={setSectionRef(cat.id)}
|
||
>
|
||
<div className="category-header">
|
||
<h2 className="category-title">
|
||
<span style={{ marginRight: "0.5rem", fontSize: "0.85em" }}>
|
||
{getCategoryIcon(cat.id)}
|
||
</span>
|
||
{cat.title}
|
||
</h2>
|
||
<p className="category-item-count">
|
||
{cat.items.length} ÜRÜN
|
||
</p>
|
||
</div>
|
||
|
||
<div className="menu-items">
|
||
{cat.items.map((item, idx) => (
|
||
<div className="menu-item" key={`${cat.id}-${idx}`}>
|
||
<div className="item-info">
|
||
<h3 className="item-name">{item.name}</h3>
|
||
<div className="item-meta">
|
||
{item.grape_variety && (
|
||
<span className="item-grape">{item.grape_variety}</span>
|
||
)}
|
||
{item.ingredients && (
|
||
<span className="item-ingredients">
|
||
{item.ingredients}
|
||
</span>
|
||
)}
|
||
{item.taste_profile && (
|
||
<div className="item-taste-profile">
|
||
{item.taste_profile.split("-").map((t, i) => (
|
||
<span key={i} className="taste-tag">
|
||
{t.trim()}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="item-price-area">
|
||
<PriceDisplay price={item.price} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</main>
|
||
|
||
{/* ───── FOOTER ───── */}
|
||
<footer className="menu-footer">
|
||
<p className="footer-note">{data.footer_note}</p>
|
||
<p className="footer-brand">LUNA ✦</p>
|
||
</footer>
|
||
|
||
{/* ───── BACK TO TOP ───── */}
|
||
<button
|
||
className={`back-to-top ${showBackToTop ? "visible" : ""}`}
|
||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||
aria-label="Yukarı git"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<polyline points="18 15 12 9 6 15" />
|
||
</svg>
|
||
</button>
|
||
</>
|
||
);
|
||
}
|