Build Kozmos QR menu site — full implementation
Next.js 16 App Router, TR/EN i18n via [lang] routing and proxy.ts, Tailwind v4, minimal list menu style, sticky bottom tab bar with Intersection Observer, PWA manifest, WhatsApp fixed button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f879d65cfc
commit
718e0a6228
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Category } from '@/data/menu'
|
||||
|
||||
type Props = {
|
||||
categories: Category[]
|
||||
lang: string
|
||||
}
|
||||
|
||||
export default function BottomTabBar({ categories, lang }: Props) {
|
||||
const l = lang as 'tr' | 'en'
|
||||
const [activeId, setActiveId] = useState<string>(categories[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = []
|
||||
|
||||
categories.forEach((cat) => {
|
||||
const el = document.getElementById(cat.id)
|
||||
if (!el) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(cat.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: '-20% 0px -75% 0px', threshold: 0 }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
observers.push(observer)
|
||||
})
|
||||
|
||||
return () => {
|
||||
observers.forEach((obs) => obs.disconnect())
|
||||
}
|
||||
}, [categories])
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-40 bg-coffee border-t border-coffee-light"
|
||||
style={{ height: 'var(--tabbar-h)' }}
|
||||
>
|
||||
<div className="flex items-start justify-around pt-2.5 h-full">
|
||||
{categories.map((cat) => {
|
||||
const isActive = activeId === cat.id
|
||||
return (
|
||||
<a
|
||||
key={cat.id}
|
||||
href={`#${cat.id}`}
|
||||
className={`tab-item flex flex-col items-center gap-0.5 flex-1 py-0.5 ${
|
||||
isActive ? 'text-teal' : 'text-cream/35'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg leading-none">{cat.emoji}</span>
|
||||
<span className="font-body text-[0.55rem] font-medium leading-none tracking-wide text-center px-0.5 truncate w-full text-center">
|
||||
{cat.label[l]}
|
||||
</span>
|
||||
<div
|
||||
className="tab-active-dot"
|
||||
style={{ opacity: isActive ? 1 : 0 }}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Category } from '@/data/menu'
|
||||
import MenuItemRow from './MenuItemRow'
|
||||
|
||||
type Props = {
|
||||
category: Category
|
||||
lang: string
|
||||
currency: string
|
||||
}
|
||||
|
||||
export default function CategorySection({ category, lang, currency }: Props) {
|
||||
const l = lang as 'tr' | 'en'
|
||||
return (
|
||||
<section id={category.id} className="category-section px-4 pt-8 pb-2">
|
||||
{/* Category heading */}
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<span className="text-xl">{category.emoji}</span>
|
||||
<h2 className="font-display text-coffee text-lg font-semibold italic">
|
||||
{category.label[l]}
|
||||
</h2>
|
||||
{/* Decorative line */}
|
||||
<div className="flex-1 h-px bg-beige-dark" />
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div>
|
||||
{category.items.map((item, i) => (
|
||||
<MenuItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
lang={lang}
|
||||
currency={currency}
|
||||
index={i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
type Dict = {
|
||||
header: { tagline: string }
|
||||
langSwitch: { current: string; other: string; otherHref: string }
|
||||
}
|
||||
|
||||
export default function Header({ dict }: { dict: Dict }) {
|
||||
return (
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-40 bg-coffee"
|
||||
style={{ height: 'var(--header-h)' }}
|
||||
>
|
||||
{/* Thin teal accent line at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-teal opacity-40" />
|
||||
|
||||
<div className="flex items-center justify-between h-full px-5">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="font-display text-cream text-xl font-semibold tracking-[0.18em] uppercase">
|
||||
KOZMOS
|
||||
</span>
|
||||
<span className="font-body text-teal text-[0.6rem] font-medium tracking-[0.22em] uppercase mt-0.5">
|
||||
{dict.header.tagline}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Language switcher */}
|
||||
<Link
|
||||
href={dict.langSwitch.otherHref}
|
||||
className="flex items-center gap-1.5 group"
|
||||
aria-label={`Switch to ${dict.langSwitch.other}`}
|
||||
>
|
||||
<span className="font-body text-xs font-semibold text-cream/40 tracking-wider">
|
||||
{dict.langSwitch.current}
|
||||
</span>
|
||||
<span className="font-body text-xs text-cream/20">·</span>
|
||||
<span className="font-body text-xs font-semibold text-teal tracking-wider group-hover:text-cream transition-colors duration-200">
|
||||
{dict.langSwitch.other}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { MenuItem } from '@/data/menu'
|
||||
|
||||
type Props = {
|
||||
item: MenuItem
|
||||
lang: string
|
||||
currency: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export default function MenuItemRow({ item, lang, currency, index }: Props) {
|
||||
const l = lang as 'tr' | 'en'
|
||||
return (
|
||||
<div
|
||||
className="menu-item-row flex items-center gap-3.5 py-3.5 border-b border-dashed border-beige last:border-0"
|
||||
style={{ '--item-index': index } as React.CSSProperties}
|
||||
>
|
||||
{/* Emoji circle */}
|
||||
<div className="w-10 h-10 rounded-full bg-beige flex items-center justify-center flex-shrink-0 text-lg">
|
||||
{item.emoji}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-display text-coffee text-sm font-semibold leading-snug">
|
||||
{item.name[l]}
|
||||
</p>
|
||||
<p className="font-body text-muted text-xs leading-relaxed mt-0.5 line-clamp-2">
|
||||
{item.description[l]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<span className="font-body text-green text-sm font-semibold flex-shrink-0 pl-2">
|
||||
{currency}{item.price}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { WHATSAPP_NUMBER } from '@/data/menu'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function WhatsAppButton({ message, label }: Props) {
|
||||
const href = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(message)}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="wa-btn fixed right-4 z-50 w-14 h-14 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg"
|
||||
style={{ bottom: 'calc(var(--tabbar-h) + 16px)' }}
|
||||
>
|
||||
{/* WhatsApp icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
className="w-7 h-7"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413Z" />
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user