first commit

This commit is contained in:
AyrisAI
2026-08-20 01:51:59 +03:00
commit 97b83c7fd4
109 changed files with 21215 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
ROOT_DOMAIN=menulio.app
+61
View File
@@ -0,0 +1,61 @@
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate
# 1. Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy root workspace configurations
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
COPY packages/shared/package.json ./packages/shared/
COPY apps/web/package.json ./apps/web/
# Install dependencies for web & shared workspace
RUN pnpm install --frozen-lockfile --filter @menulio/web...
# 2. Build the Next.js application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app ./
COPY packages/shared ./packages/shared
COPY apps/web ./apps/web
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Next.js Public envs (Coolify build args can be passed here)
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG ROOT_DOMAIN=menul.io
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV ROOT_DOMAIN=$ROOT_DOMAIN
RUN pnpm --filter @menulio/web build
# 3. Production runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output from builder
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@menulio/web",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
"dependencies": {
"@menulio/shared": "workspace:*",
"@supabase/supabase-js": "^2.45.4",
"next": "^15.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.5.4",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.19",
"typescript": "^5.6.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { PublicMenuClient } from "@/components/PublicMenuClient";
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
export const metadata: Metadata = {
title: "Gusto & Co. Brasserie | Menulio Canlı Demo Menü",
description: "Menulio dijital QR menü altyapısı ile çalışan örnek canlı restoran menüsü.",
};
type PageProps = {
searchParams?: Promise<{ theme?: string; table?: string }>;
};
export default async function DemoMenuPage({ searchParams }: PageProps) {
const resolved = searchParams ? await searchParams : {};
const theme = resolved.theme || "elegant";
return (
<PublicMenuClient
restaurant={DEMO_RESTAURANT}
categories={DEMO_CATEGORIES}
initialThemeKey={theme}
allowThemeSwitching={true}
/>
);
}
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;0,700;1,600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
:root {
--font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-heading: 'Outfit', 'Plus Jakarta Sans', sans-serif;
--font-serif: 'Playfair Display', Georgia, serif;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom Scrollbar for Category Tabs */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "Menulio | AI Destekli Yeni Nesil QR Menü",
description: "Restoranınız için yapay zeka destekli, modern dijital QR menü platformu.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="tr" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
);
}
+159
View File
@@ -0,0 +1,159 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { PublicMenuClient, type MenuCategory, type MenuItem, type RestaurantData } from "@/components/PublicMenuClient";
import type { ThemeConfig } from "@menulio/shared";
type PageProps = {
params: Promise<{ slug: string }>;
searchParams?: Promise<{ theme?: string }>;
};
interface MenuItemRow {
id: string;
name: string;
description: string | null;
price: number;
image_url: string | null;
is_active: boolean;
}
interface MenuCategoryRow {
id: string;
name: string;
description: string | null;
is_active: boolean;
menu_items: MenuItemRow[];
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
let restaurant: any = null;
const { data: bySlug } = await supabase
.from("restaurants")
.select("name, logo_url")
.eq("slug", slug)
.maybeSingle();
if (bySlug) {
restaurant = bySlug;
} else {
const { data: domainRow } = await supabase
.from("domains")
.select("restaurants(name, logo_url)")
.eq("hostname", slug)
.maybeSingle();
if (domainRow?.restaurants) {
restaurant = domainRow.restaurants;
}
}
if (!restaurant) {
return {
title: "Menü Bulunamadı | Menulio",
};
}
return {
title: `${restaurant.name} | QR Menü`,
description: `${restaurant.name} restoranının güncel dijital QR menüsü ve fiyatları.`,
openGraph: {
title: `${restaurant.name} QR Menü`,
description: `${restaurant.name} dijital menüsünü inceleyin.`,
images: restaurant.logo_url ? [restaurant.logo_url] : [],
},
};
}
export default async function PublicMenuPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const resolvedSearchParams = searchParams ? await searchParams : {};
const themeParam = resolvedSearchParams.theme;
// 1. Try finding restaurant by slug
let restaurant: any = null;
const { data: bySlug } = await supabase
.from("restaurants")
.select("id, name, slug, logo_url, phone, address")
.eq("slug", slug)
.maybeSingle();
if (bySlug) {
restaurant = bySlug;
} else {
// 2. Try finding restaurant by custom domain hostname
const { data: domainRow } = await supabase
.from("domains")
.select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address)")
.eq("hostname", slug)
.maybeSingle();
if (domainRow?.restaurants) {
restaurant = domainRow.restaurants;
}
}
if (!restaurant) notFound();
const { data: menu } = await supabase
.from("menus")
.select("id, name, locations!inner(restaurant_id)")
.eq("locations.restaurant_id", restaurant.id)
.eq("is_published", true)
.maybeSingle();
if (!menu) notFound();
const { data: rawCategories } = await supabase
.from("menu_categories")
.select("id, name, description, is_active, menu_items(id, name, description, price, image_url, is_active)")
.eq("menu_id", menu.id)
.eq("is_active", true)
.order("sort_order", { ascending: true })
.returns<MenuCategoryRow[]>();
const { data: themeRow } = await supabase
.from("restaurant_themes")
.select("overrides, themes(key, config)")
.eq("restaurant_id", restaurant.id)
.maybeSingle();
const themeRelation = themeRow?.themes as unknown as { key?: string; config?: Partial<ThemeConfig> } | null;
const themeKey = themeParam || themeRelation?.key || "elegant";
const customConfig = (themeRow?.overrides as Partial<ThemeConfig>) || themeRelation?.config;
const categories: MenuCategory[] = (rawCategories ?? []).map((cat) => ({
id: cat.id,
name: cat.name,
description: cat.description,
is_active: cat.is_active,
menu_items: (cat.menu_items ?? [])
.filter((item) => item.is_active)
.map((item): MenuItem => ({
id: item.id,
name: item.name,
description: item.description,
price: item.price,
image_url: item.image_url,
is_active: item.is_active,
})),
}));
const restaurantData: RestaurantData = {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
logo_url: restaurant.logo_url,
phone: restaurant.phone,
address: restaurant.address,
};
return (
<PublicMenuClient
restaurant={restaurantData}
categories={categories}
initialThemeKey={themeKey}
customThemeConfig={customConfig}
/>
);
}
+308
View File
@@ -0,0 +1,308 @@
import type { Metadata } from "next";
import Link from "next/link";
import { THEME_PRESETS } from "@/lib/theme";
export const metadata: Metadata = {
title: "Menulio | AI Destekli Yeni Nesil QR Menü Platformu",
description:
"Restoran menünüzün fotoğrafını çekin, yapay zeka saniyeler içinde dijitalleştirsin. 5 lüks şablon, anında açılan mobil web menüsü ve dinamik QR kodlar.",
};
export default function HomePage() {
return (
<div className="min-h-screen bg-stone-950 text-stone-100 selection:bg-amber-500 selection:text-stone-950 font-sans">
{/* Navigation */}
<header className="border-b border-stone-800/80 bg-stone-950/75 backdrop-blur-xl sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-amber-400 via-amber-600 to-amber-800 flex items-center justify-center font-black text-xl text-stone-950 shadow-lg shadow-amber-500/20">
M
</div>
<span className="font-extrabold text-2xl tracking-tight bg-gradient-to-r from-amber-200 via-amber-400 to-amber-500 bg-clip-text text-transparent">
MENULIO
</span>
</div>
<nav className="hidden md:flex items-center gap-8 text-sm font-medium text-stone-300">
<Link href="#features" className="hover:text-amber-400 transition-colors">
Özellikler
</Link>
<Link href="#templates" className="hover:text-amber-400 transition-colors">
Şablonlar (5 Tema)
</Link>
<Link href="#how-it-works" className="hover:text-amber-400 transition-colors">
Nasıl Çalışır?
</Link>
<Link href="/templates" className="hover:text-amber-400 transition-colors">
Canlı Demo
</Link>
</nav>
<div className="flex items-center gap-4">
<Link
href="/demo"
className="px-5 py-2.5 rounded-xl text-xs font-bold text-stone-200 bg-stone-900 hover:bg-stone-800 border border-stone-800 transition-all active:scale-95"
>
Menü Demosu Gör
</Link>
<Link
href="/templates"
className="px-5 py-2.5 rounded-xl text-xs font-bold text-stone-950 bg-gradient-to-r from-amber-400 to-amber-500 hover:from-amber-300 hover:to-amber-400 transition-all shadow-lg shadow-amber-500/25 active:scale-95"
>
Şablonları İncele
</Link>
</div>
</div>
</header>
{/* Hero Section */}
<section className="relative pt-24 pb-32 overflow-hidden px-6">
{/* Glow Effects */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-amber-500/10 rounded-full blur-[140px] pointer-events-none" />
<div className="absolute top-1/3 left-1/4 w-[400px] h-[400px] bg-blue-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="max-w-5xl mx-auto text-center space-y-8 relative z-10">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-amber-500/10 border border-amber-500/25 text-amber-400 text-xs font-bold tracking-wide animate-fade-in">
<span></span> YAPAY ZEKA DESTEKLİ DİJİTAL QR MENÜ SAAS
</div>
<h1 className="text-4xl sm:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] text-stone-100">
Menünüzün Fotoğrafını Çekin,{" "}
<span className="bg-gradient-to-r from-amber-300 via-amber-400 to-amber-600 bg-clip-text text-transparent">
AI Saniyeler İçinde Dijitalleştirsin.
</span>
</h1>
<p className="text-lg sm:text-xl text-stone-400 max-w-3xl mx-auto leading-relaxed">
Menulio ile restoranınızın basılı menüsünü cep telefonunuzdan fotoğraflayın. Vision AI tüm
kategorileri, ürünleri ve fiyatları anında tanısın; 5 lüks şablondan birini seçip hemen
masalarınıza QR koyun.
</p>
{/* Call to Actions */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
<Link
href="/templates"
className="w-full sm:w-auto px-8 py-4 rounded-2xl font-bold text-stone-950 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 hover:opacity-95 transition-all shadow-xl shadow-amber-500/25 text-base flex items-center justify-center gap-2"
>
<span>🌟</span> Canlı Şablonları Test Edin
</Link>
<Link
href="/demo"
className="w-full sm:w-auto px-8 py-4 rounded-2xl font-bold text-stone-200 bg-stone-900/90 hover:bg-stone-800 border border-stone-800 transition-all text-base flex items-center justify-center gap-2"
>
<span>📱</span> Canlı Demo Menüyü
</Link>
</div>
{/* Social Proof Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 pt-16 border-t border-stone-800/80 max-w-4xl mx-auto text-left sm:text-center">
<div>
<div className="text-3xl sm:text-4xl font-black text-amber-400">10 sn</div>
<div className="text-xs text-stone-400 mt-1">AI ile Menü Çıkarma</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-white">5 Adet</div>
<div className="text-xs text-stone-400 mt-1">Özel Tasarım Şablonu</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-amber-400">%100</div>
<div className="text-xs text-stone-400 mt-1">Dinamik QR (Yeniden Basılmaz)</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-white">&lt;0.5 sn</div>
<div className="text-xs text-stone-400 mt-1">Mobil Açılış Hızı</div>
</div>
</div>
</div>
</section>
{/* 5 Menu Templates Showcase Section */}
<section id="templates" className="py-24 bg-stone-900/50 border-y border-stone-800/80 px-6">
<div className="max-w-7xl mx-auto space-y-16">
<div className="text-center max-w-3xl mx-auto space-y-4">
<span className="text-amber-400 text-xs font-bold uppercase tracking-wider">
TEMALAR & TASARIM ŞABLONLARI
</span>
<h2 className="text-3xl sm:text-5xl font-extrabold tracking-tight">
Her Restoran Konseptine Özel 5 Şablon
</h2>
<p className="text-stone-400 text-sm sm:text-base">
Fine dining'den gurme burgerciye, gece kulübünden butik kahveciye kadar tek tıkla şablon
değiştirin. Menü verileriniz bozulmadan sunum katmanı anında güncellenir.
</p>
</div>
{/* 5 Template Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.values(THEME_PRESETS).map((preset) => (
<div
key={preset.key}
className="rounded-3xl border border-stone-800 bg-stone-900/80 p-6 flex flex-col justify-between hover:border-amber-500/50 transition-all group shadow-xl"
>
<div className="space-y-4">
{/* Theme Header with Accent Color Dot */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-5 h-5 rounded-full shadow-md"
style={{ backgroundColor: preset.config.primaryColor }}
/>
<h3 className="text-xl font-bold text-white group-hover:text-amber-300 transition-colors">
{preset.name}
</h3>
</div>
<span className="text-[10px] font-bold px-2.5 py-1 rounded-full bg-stone-800 text-stone-300 border border-stone-700">
{preset.fontFamily.toUpperCase()}
</span>
</div>
{/* Theme Description */}
<p className="text-xs text-stone-400 leading-relaxed">
{preset.key === "elegant" && "Fine dining restoranlar, şarap evleri ve lüks mekanlar için altın & krem tonlarında zarif mizanpaj."}
{preset.key === "modern" && "Kafeler, burgerciler ve fast-casual mekanlar için canlı mavi safir tonları ve hızlı arama odaklı ızgara düzeni."}
{preset.key === "dark" && "Gece kulüpleri, kokteyl barlar ve steakhouse'lar için obsidian siyahı ve kehribar parıltılı lüks mod."}
{preset.key === "minimal" && "Butik kahveciler, fırınlar ve üçüncü nesil mekanlar için bol boşluklu sakin Nordic tipografi."}
{preset.key === "classic" && "Geleneksel brasserie'ler, meyhaneler ve trattoria'lar için Toskana bordo ve sıcak rustik doku."}
</p>
{/* Visual Preview Box */}
<div
className="h-32 rounded-2xl p-4 flex flex-col justify-between border shadow-inner relative overflow-hidden"
style={{
backgroundColor: preset.config.background,
borderColor: preset.cardBorder,
}}
>
<div className="flex items-center justify-between">
<div className="h-3 w-20 rounded bg-stone-400/40" />
<div
className="h-4 w-12 rounded-full"
style={{ backgroundColor: preset.config.primaryColor }}
/>
</div>
<div
className="p-2.5 rounded-xl border flex items-center justify-between"
style={{
backgroundColor: preset.cardBg,
borderColor: preset.cardBorder,
}}
>
<div className="space-y-1">
<div
className="h-2.5 w-24 rounded font-bold text-[10px]"
style={{ color: preset.textPrimary }}
>
Trüflü Burrata
</div>
<div className="h-2 w-16 rounded bg-stone-300/30" />
</div>
<div
className="text-xs font-black"
style={{ color: preset.config.primaryColor }}
>
₺420
</div>
</div>
</div>
</div>
<div className="pt-6 mt-4 border-t border-stone-800/80">
<Link
href={`/templates?theme=${preset.key}`}
className="w-full py-2.5 rounded-xl text-xs font-bold text-center block bg-stone-800 hover:bg-stone-700 text-stone-100 transition-all border border-stone-700"
>
{preset.name} Önizle →
</Link>
</div>
</div>
))}
</div>
<div className="text-center pt-4">
<Link
href="/templates"
className="inline-flex items-center gap-2 px-8 py-4 rounded-2xl font-bold text-stone-950 bg-amber-400 hover:bg-amber-300 shadow-xl shadow-amber-400/20 text-sm transition-all"
>
<span>📱</span> Tüm Şablonları Cihaz Simülatöründe Canlı Dene
</Link>
</div>
</div>
</section>
{/* How It Works Section */}
<section id="how-it-works" className="py-24 px-6 max-w-7xl mx-auto space-y-16">
<div className="text-center max-w-2xl mx-auto space-y-4">
<span className="text-amber-400 text-xs font-bold uppercase tracking-wider">
KOLAY ENTEGRASYON
</span>
<h2 className="text-3xl sm:text-5xl font-extrabold tracking-tight">
3 Kolay Adımda Masanızda
</h2>
<p className="text-stone-400 text-sm sm:text-base">
Saatlerce menü girmeye son. Tek yapmanız gereken cep telefonunuzla fotoğraf çekmek.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Step 1 */}
<div className="rounded-3xl border border-stone-800 bg-stone-900/60 p-8 space-y-4 relative">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center font-black text-xl">
1
</div>
<h3 className="text-xl font-bold text-white">Menü Fotoğrafını Çekin</h3>
<p className="text-stone-400 text-sm leading-relaxed">
Mevcut basılı menünüzün, broşürünüzün veya tahtanızın fotoğrafını Menulio mobil uygulamasıyla çekin.
</p>
</div>
{/* Step 2 */}
<div className="rounded-3xl border border-stone-800 bg-stone-900/60 p-8 space-y-4 relative">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center font-black text-xl">
2
</div>
<h3 className="text-xl font-bold text-white">AI Ayıklasın & Onaylayın</h3>
<p className="text-stone-400 text-sm leading-relaxed">
Vision AI tüm kategorileri, ürünleri ve fiyatları saniyeler içinde çıkarır. İnceleyin, istediğiniz temayı seçin.
</p>
</div>
{/* Step 3 */}
<div className="rounded-3xl border border-stone-800 bg-stone-900/60 p-8 space-y-4 relative">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center font-black text-xl">
3
</div>
<h3 className="text-xl font-bold text-white">QR Kodunuzu Masaya Koyun</h3>
<p className="text-stone-400 text-sm leading-relaxed">
Müşterileriniz uygulama indirmeden saniyeler içinde menüyü açsın. Fiyat değiştiğinde asla yeni QR basmayın.
</p>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-stone-800/80 bg-stone-950 py-12 px-6">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6 text-xs text-stone-500">
<div className="flex items-center gap-3">
<span className="font-bold text-stone-300 text-sm tracking-wider">MENULIO</span>
<span>•</span>
<span>© {new Date().getFullYear()} Tüm hakları saklıdır.</span>
</div>
<div className="flex items-center gap-6">
<Link href="/templates" className="hover:text-stone-300 transition-colors">
Şablonlar
</Link>
<Link href="/demo" className="hover:text-stone-300 transition-colors">
Canlı Demo
</Link>
<Link href="/demo?theme=dark" className="hover:text-stone-300 transition-colors">
Dark Tema
</Link>
</div>
</div>
</footer>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse, type NextRequest } from "next/server";
import { supabase } from "@/lib/supabase";
type RouteParams = {
params: Promise<{ id: string }>;
};
// Stable QR redirect target (PRD §12) — printed QR codes encode this URL and
// never change; only the row's target_url is updated when domain/slug changes.
export async function GET(_req: NextRequest, { params }: RouteParams) {
const { id } = await params;
const { data } = await supabase.from("qr_codes").select("target_url").eq("id", id).maybeSingle();
if (!data) {
return NextResponse.json({ message: "not found" }, { status: 404 });
}
return NextResponse.redirect(data.target_url, { status: 302 });
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { TemplateGalleryClient } from "@/components/TemplateGalleryClient";
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
export const metadata: Metadata = {
title: "Menü Şablonları & Canlı Demo | Menulio",
description: "Menulio'nun 5 farklı lüks restoran şablonunu cihaz simülatöründe canlı olarak deneyimleyin.",
};
type PageProps = {
searchParams?: Promise<{ theme?: string }>;
};
export default async function TemplatesPage({ searchParams }: PageProps) {
const resolvedSearchParams = searchParams ? await searchParams : {};
const currentTheme = resolvedSearchParams.theme || "elegant";
return (
<TemplateGalleryClient
restaurant={DEMO_RESTAURANT}
categories={DEMO_CATEGORIES}
initialThemeKey={currentTheme}
/>
);
}
@@ -0,0 +1,733 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import type { ThemeConfig } from "@menulio/shared";
import { THEME_PRESETS, resolveThemePreset, type ThemePreset } from "@/lib/theme";
export interface MenuItem {
id: string;
name: string;
description: string | null;
price: number;
image_url: string | null;
is_active: boolean;
}
export interface MenuCategory {
id: string;
name: string;
description: string | null;
is_active: boolean;
menu_items: MenuItem[];
}
export interface RestaurantData {
id: string;
name: string;
slug: string;
logo_url: string | null;
phone?: string | null;
address?: string | null;
}
interface PublicMenuClientProps {
restaurant: RestaurantData;
categories: MenuCategory[];
initialThemeKey?: string;
customThemeConfig?: Partial<ThemeConfig>;
allowThemeSwitching?: boolean;
}
export function PublicMenuClient({
restaurant,
categories,
initialThemeKey = "elegant",
customThemeConfig,
allowThemeSwitching = true,
}: PublicMenuClientProps) {
const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey);
const [searchQuery, setSearchQuery] = useState("");
const [activeCategoryId, setActiveCategoryId] = useState<string>(categories[0]?.id ?? "");
const [selectedItem, setSelectedItem] = useState<MenuItem | null>(null);
const [showInfoModal, setShowInfoModal] = useState(false);
const [showThemePicker, setShowThemePicker] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
const [tableNumber, setTableNumber] = useState<string | null>(null);
const [serviceActionToast, setServiceActionToast] = useState<string | null>(null);
const theme: ThemePreset = useMemo(() => {
return resolveThemePreset(selectedThemeKey, customThemeConfig);
}, [selectedThemeKey, customThemeConfig]);
// Extract table number from URL (?table=X)
useEffect(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const table = params.get("table") || params.get("masa");
if (table) setTableNumber(table);
}
}, []);
// Track scroll position for active category & back-to-top button
useEffect(() => {
const handleScroll = () => {
setShowBackToTop(window.scrollY > 300);
const categoryElements = categories.map((c) => ({
id: c.id,
el: document.getElementById(`category-${c.id}`),
}));
const scrollPos = window.scrollY + 140;
for (let i = categoryElements.length - 1; i >= 0; i--) {
const item = categoryElements[i];
if (item && item.el && item.el.offsetTop <= scrollPos) {
setActiveCategoryId(item.id);
break;
}
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [categories]);
// Filter categories and items based on search
const filteredCategories = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return categories;
return categories
.map((cat) => ({
...cat,
menu_items: cat.menu_items.filter(
(item) =>
item.is_active &&
(item.name.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q))),
),
}))
.filter((cat) => cat.menu_items.length > 0);
}, [categories, searchQuery]);
const totalItemsCount = useMemo(() => {
return categories.reduce((acc, cat) => acc + cat.menu_items.filter((i) => i.is_active).length, 0);
}, [categories]);
const scrollToCategory = (categoryId: string) => {
setActiveCategoryId(categoryId);
const el = document.getElementById(`category-${categoryId}`);
if (el) {
const yOffset = -85;
const y = el.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: "smooth" });
}
};
const handleShare = async () => {
const url = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: `${restaurant.name} - Dijital Menü`,
text: `${restaurant.name} dijital menüsünü inceleyin!`,
url,
});
return;
} catch {
// fallback
}
}
await navigator.clipboard.writeText(url);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2500);
};
const triggerServiceAction = (msg: string) => {
setServiceActionToast(msg);
setTimeout(() => setServiceActionToast(null), 3500);
};
const fontClass =
theme.fontFamily === "serif" ? "font-serif" : theme.fontFamily === "heading" ? "font-heading" : "font-sans";
return (
<div
className={`min-h-screen ${fontClass} transition-colors duration-300`}
style={{
backgroundColor: theme.config.background,
color: theme.textPrimary,
}}
>
{/* Toast Notification */}
{serviceActionToast && (
<div className="fixed top-5 left-1/2 -translate-x-1/2 z-50 animate-bounce-in max-w-sm w-full px-4">
<div className="bg-stone-900 text-white px-5 py-3.5 rounded-2xl shadow-2xl flex items-center gap-3 border border-stone-700">
<span className="text-xl">🔔</span>
<p className="text-sm font-medium flex-1">{serviceActionToast}</p>
</div>
</div>
)}
{/* Main Container - Optimized for Mobile First & Desktop Centered */}
<div className="w-full max-w-2xl mx-auto min-h-screen flex flex-col shadow-2xl relative">
{/* Header Hero Banner */}
<header
style={{ background: theme.headerGradient }}
className="relative text-white px-4 sm:px-6 pt-8 sm:pt-10 pb-7 sm:pb-8 rounded-b-3xl shadow-lg overflow-hidden"
>
{/* Subtle Ambient Glow */}
<div
className="absolute top-0 right-0 w-64 h-64 rounded-full blur-3xl opacity-20 pointer-events-none"
style={{ backgroundColor: theme.config.primaryColor }}
/>
{/* Top Bar: Table & Action Buttons */}
<div className="flex items-center justify-between mb-4 sm:mb-5 relative z-10">
{tableNumber ? (
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 backdrop-blur-md border border-white/15 text-xs font-semibold tracking-wide">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
Masa {tableNumber}
</div>
) : (
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 backdrop-blur-md border border-white/15 text-xs font-medium tracking-wide">
<span></span> Dijital QR Menü
</div>
)}
<div className="flex items-center gap-1.5 sm:gap-2">
{/* Restaurant Info Trigger */}
{(restaurant.phone || restaurant.address) && (
<button
type="button"
onClick={() => setShowInfoModal(true)}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md"
aria-label="Restoran Bilgisi"
>
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
)}
{/* Share Trigger */}
<button
type="button"
onClick={handleShare}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md"
aria-label="Menüyü Paylaş"
>
{copiedLink ? (
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
)}
</button>
{/* Live Theme Switcher Trigger */}
{allowThemeSwitching && (
<button
type="button"
onClick={() => setShowThemePicker(!showThemePicker)}
className="px-2.5 py-1 sm:py-1.5 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center gap-1.5 border border-white/15 backdrop-blur-md text-[11px] sm:text-xs font-semibold"
aria-label="Tema Değiştir"
>
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full" style={{ backgroundColor: theme.config.primaryColor }} />
<span>Tema</span>
</button>
)}
</div>
</div>
{/* Restaurant Identity */}
<div className="flex items-center gap-3 sm:gap-4 relative z-10">
{restaurant.logo_url ? (
<img
src={restaurant.logo_url}
alt={restaurant.name}
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl object-cover border-2 shadow-md flex-shrink-0"
style={{ borderColor: theme.config.primaryColor }}
/>
) : (
<div
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center font-bold text-xl sm:text-2xl flex-shrink-0 shadow-inner border border-white/20"
style={{
background: "rgba(255, 255, 255, 0.12)",
color: theme.config.primaryColor,
}}
>
{restaurant.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<h1 className="text-xl sm:text-2xl font-bold tracking-tight leading-snug break-words">
{restaurant.name}
</h1>
<p className="text-[11px] sm:text-xs text-white/70 mt-0.5 flex flex-wrap items-center gap-1.5">
<span>{totalItemsCount} Özel Lezzet</span>
<span></span>
<span className="capitalize">{theme.name} Şablonu</span>
</p>
</div>
</div>
{/* Live Search Bar */}
<div className="mt-6 relative z-10">
<div className="relative flex items-center">
<input
type="text"
placeholder="Yemek, içecek veya tatlı ara..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 transition-all shadow-lg"
style={{
outlineColor: theme.config.primaryColor,
}}
/>
<svg
className="w-5 h-5 absolute left-3.5 text-stone-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3.5 w-5 h-5 rounded-full bg-stone-200 text-stone-600 flex items-center justify-center text-xs"
>
</button>
)}
</div>
</div>
</header>
{/* Live Theme Switcher Drawer */}
{showThemePicker && allowThemeSwitching && (
<div className="bg-stone-900 text-white px-5 py-4 border-b border-stone-800 animate-slide-up">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold uppercase tracking-wider text-stone-400">
Canlı Tema Önizleme (5 Şablon)
</p>
<button
type="button"
onClick={() => setShowThemePicker(false)}
className="text-stone-400 hover:text-white text-xs"
>
Kapat
</button>
</div>
<div className="grid grid-cols-5 gap-2">
{Object.values(THEME_PRESETS).map((preset) => {
const isSelected = selectedThemeKey === preset.key;
return (
<button
key={preset.key}
type="button"
onClick={() => setSelectedThemeKey(preset.key)}
className={`flex flex-col items-center gap-1.5 p-2 rounded-xl border text-center transition-all ${
isSelected
? "bg-white/15 border-white shadow-md scale-105"
: "bg-white/5 border-white/10 opacity-70 hover:opacity-100"
}`}
>
<span
className="w-5 h-5 rounded-full border border-white/30"
style={{ backgroundColor: preset.config.primaryColor }}
/>
<span className="text-[10px] font-medium leading-tight truncate w-full">
{preset.name.split(" ")[0]}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Sticky Category Navigation Bar */}
{categories.length > 0 && !searchQuery && (
<nav
className="sticky top-0 z-30 px-4 py-3 backdrop-blur-md border-b transition-colors"
style={{
backgroundColor: `${theme.config.background}E6`,
borderColor: theme.cardBorder,
}}
>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth py-0.5">
{categories.map((cat) => {
const isActive = activeCategoryId === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => scrollToCategory(cat.id)}
className={`px-4 py-2 rounded-full text-xs font-bold whitespace-nowrap transition-all flex items-center gap-1.5 ${
isActive
? "text-white shadow-md scale-105"
: "hover:bg-black/5 active:scale-95"
}`}
style={
isActive
? { backgroundColor: theme.config.primaryColor }
: {
backgroundColor: theme.accentBg,
color: theme.textSecondary,
}
}
>
<span>{cat.name}</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full ${
isActive ? "bg-black/20 text-white" : "bg-black/5"
}`}
>
{cat.menu_items.length}
</span>
</button>
);
})}
</div>
</nav>
)}
{/* Menu Content Area */}
<main className="flex-1 px-4 py-6 space-y-8">
{filteredCategories.length === 0 ? (
<div className="text-center py-16 px-4">
<div className="w-16 h-16 rounded-full bg-stone-100 dark:bg-stone-800 flex items-center justify-center text-3xl mx-auto mb-4">
🔍
</div>
<h3 className="text-base font-bold text-stone-800 dark:text-stone-200">
Aramanıza Uygun Lezzet Bulunamadı
</h3>
<p className="text-xs text-stone-500 mt-1 max-w-xs mx-auto">
"{searchQuery}" için sonuç yok. Lütfen farklı bir arama kelimesi deneyin.
</p>
<button
type="button"
onClick={() => setSearchQuery("")}
className="mt-4 px-4 py-2 rounded-xl text-xs font-bold text-white shadow"
style={{ backgroundColor: theme.config.primaryColor }}
>
Tüm Menüyü Göster
</button>
</div>
) : (
filteredCategories.map((category) => (
<section
key={category.id}
id={`category-${category.id}`}
className="scroll-mt-24 space-y-3.5"
>
{/* Category Heading Banner */}
<div className="flex items-center justify-between border-b pb-2" style={{ borderColor: theme.cardBorder }}>
<div>
<h2
className="text-lg font-bold tracking-tight"
style={{ color: theme.config.primaryColor }}
>
{category.name}
</h2>
{category.description && (
<p className="text-xs mt-0.5" style={{ color: theme.textSecondary }}>
{category.description}
</p>
)}
</div>
<span
className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full"
style={{ backgroundColor: theme.badgeBg, color: theme.badgeText }}
>
{category.menu_items.length} Ürün
</span>
</div>
{/* Items Container: Grid / Card / List Layout based on Theme */}
<div
className={
theme.config.productLayout === "list"
? "space-y-3"
: "grid grid-cols-1 gap-3.5"
}
>
{category.menu_items.map((item) => (
<article
key={item.id}
onClick={() => setSelectedItem(item)}
className={`group relative rounded-2xl p-4 transition-all duration-200 cursor-pointer border hover:shadow-lg active:scale-[0.99] flex gap-3.5 items-center ${
theme.config.productLayout === "list" ? "justify-between" : ""
}`}
style={{
backgroundColor: theme.cardBg,
borderColor: theme.cardBorder,
}}
>
{/* Item Details */}
<div className="flex-1 min-w-0 pr-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-bold text-sm tracking-tight leading-snug group-hover:text-amber-600 transition-colors">
{item.name}
</h3>
</div>
{item.description && (
<p
className="text-xs mt-1 leading-relaxed line-clamp-2"
style={{ color: theme.textSecondary }}
>
{item.description}
</p>
)}
<div className="mt-2.5 flex items-center justify-between">
<span
className="text-base font-extrabold tracking-tight"
style={{ color: theme.config.primaryColor }}
>
{item.price.toFixed(0)}
</span>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-md bg-black/5 dark:bg-white/10 group-hover:bg-amber-100 dark:group-hover:bg-amber-900/40 transition-colors">
İncele
</span>
</div>
</div>
{/* Item Thumbnail Image */}
{item.image_url && (
<div className="relative w-20 h-20 rounded-xl overflow-hidden flex-shrink-0 bg-stone-100 dark:bg-stone-800 shadow-sm">
<img
src={item.image_url}
alt={item.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
</div>
)}
</article>
))}
</div>
</section>
))
)}
</main>
{/* Footer */}
<footer
className="px-6 py-10 text-center border-t mt-auto text-xs space-y-4"
style={{
borderColor: theme.cardBorder,
color: theme.textSecondary,
}}
>
<div className="flex items-center justify-center gap-2 font-semibold">
<span>Powered by</span>
<span className="text-stone-900 dark:text-white font-black tracking-wider uppercase">
MENULIO
</span>
</div>
<p className="text-[11px] opacity-70">
© {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir.
</p>
</footer>
{/* Floating Quick Action Bar (Garson Çağır / Hesap İste / Başa Dön) */}
<aside aria-label="Masa Servis İşlemleri" className="fixed bottom-5 left-1/2 -translate-x-1/2 z-40 max-w-sm w-full px-4 flex items-center justify-between gap-2 pointer-events-none">
<div className="flex items-center gap-2 pointer-events-auto shadow-2xl rounded-full p-1 bg-stone-900/90 backdrop-blur-lg border border-stone-700 text-white">
<button
type="button"
onClick={() =>
triggerServiceAction(
tableNumber
? `Masa ${tableNumber} için Garson Çağrıldı! Garsonunuz en kısa sürede masanızda olacaktır.`
: "Garson çağrıldı! Garsonunuz hemen masanızda olacaktır.",
)
}
className="px-3.5 py-2 rounded-full hover:bg-white/15 active:scale-95 transition-all text-xs font-bold flex items-center gap-1.5"
>
<span>👋</span> Garson Çağır
</button>
<button
type="button"
onClick={() =>
triggerServiceAction(
tableNumber
? `Masa ${tableNumber} için Hesap İsteği iletildi!`
: "Hesap isteği iletildi!",
)
}
className="px-3.5 py-2 rounded-full hover:bg-white/15 active:scale-95 transition-all text-xs font-bold flex items-center gap-1.5"
>
<span>💳</span> Hesap İste
</button>
</div>
{showBackToTop && (
<button
type="button"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className="w-11 h-11 rounded-full bg-stone-900 text-white shadow-2xl flex items-center justify-center pointer-events-auto active:scale-90 transition-all border border-stone-700"
aria-label="Başa Dön"
>
</button>
)}
</aside>
</div>
{/* Item Detail Modal */}
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
<div
className="w-full max-w-lg rounded-t-3xl sm:rounded-3xl overflow-hidden shadow-2xl animate-slide-up flex flex-col max-h-[85vh]"
style={{ backgroundColor: theme.cardBg, color: theme.textPrimary }}
>
{/* Modal Image */}
{selectedItem.image_url ? (
<div className="relative h-56 w-full bg-stone-900">
<img
src={selectedItem.image_url}
alt={selectedItem.name}
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="absolute top-4 right-4 w-9 h-9 rounded-full bg-black/60 text-white flex items-center justify-center backdrop-blur-md text-sm hover:bg-black/80"
>
</button>
</div>
) : (
<div className="p-4 flex justify-end">
<button
type="button"
onClick={() => setSelectedItem(null)}
className="w-8 h-8 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-600 dark:text-stone-300 flex items-center justify-center text-sm"
>
</button>
</div>
)}
{/* Modal Body */}
<div className="p-6 overflow-y-auto space-y-4">
<div className="flex items-start justify-between gap-4">
<h3 className="text-xl font-bold tracking-tight leading-snug">
{selectedItem.name}
</h3>
<span
className="text-xl font-black whitespace-nowrap"
style={{ color: theme.config.primaryColor }}
>
{selectedItem.price.toFixed(0)}
</span>
</div>
{selectedItem.description && (
<p className="text-sm leading-relaxed" style={{ color: theme.textSecondary }}>
{selectedItem.description}
</p>
)}
{/* Quality Badges */}
<div className="pt-2 flex flex-wrap gap-2 text-xs">
<span className="px-3 py-1 rounded-full bg-emerald-50 text-emerald-700 font-semibold border border-emerald-200">
🌱 Taze & Günlük
</span>
<span className="px-3 py-1 rounded-full bg-amber-50 text-amber-700 font-semibold border border-amber-200">
Şefin İmzası
</span>
</div>
</div>
{/* Modal Footer */}
<div className="p-4 border-t" style={{ borderColor: theme.cardBorder }}>
<button
type="button"
onClick={() => {
setSelectedItem(null);
triggerServiceAction(`"${selectedItem.name}" sipariş tercihleriniz garsona iletildi!`);
}}
className="w-full py-3.5 rounded-2xl font-bold text-white shadow-lg active:scale-98 transition-all flex items-center justify-center gap-2 text-sm"
style={{ backgroundColor: theme.config.primaryColor }}
>
<span></span> Garsona Sipariş Olarak Bildir
</button>
</div>
</div>
</div>
)}
{/* Restaurant Info & Contact Modal */}
{showInfoModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
<div
className="w-full max-w-md rounded-t-3xl sm:rounded-3xl overflow-hidden shadow-2xl p-6 space-y-5 animate-slide-up"
style={{ backgroundColor: theme.cardBg, color: theme.textPrimary }}
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">Restoran Bilgileri</h3>
<button
type="button"
onClick={() => setShowInfoModal(false)}
className="w-8 h-8 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-600 dark:text-stone-300 flex items-center justify-center text-sm"
>
</button>
</div>
<div className="space-y-4 text-sm">
{restaurant.phone && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-stone-50 dark:bg-stone-800/50">
<span className="text-lg">📞</span>
<div className="flex-1">
<p className="text-xs text-stone-400">Telefon</p>
<a href={`tel:${restaurant.phone}`} className="font-semibold hover:underline">
{restaurant.phone}
</a>
</div>
</div>
)}
{restaurant.address && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-stone-50 dark:bg-stone-800/50">
<span className="text-lg">📍</span>
<div className="flex-1">
<p className="text-xs text-stone-400">Adres</p>
<p className="font-medium text-xs">{restaurant.address}</p>
</div>
</div>
)}
<div className="flex items-center gap-3 p-3 rounded-xl bg-stone-50 dark:bg-stone-800/50">
<span className="text-lg">🕒</span>
<div className="flex-1">
<p className="text-xs text-stone-400">Çalışma Saatleri</p>
<p className="font-semibold text-xs">Hergün 10:00 - 00:00</p>
</div>
</div>
</div>
<button
type="button"
onClick={() => setShowInfoModal(false)}
className="w-full py-3 rounded-xl font-bold bg-stone-900 text-white text-sm"
>
Kapat
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { PublicMenuClient, type MenuCategory, type RestaurantData } from "@/components/PublicMenuClient";
import { THEME_PRESETS, type ThemePreset } from "@/lib/theme";
interface TemplateGalleryClientProps {
restaurant: RestaurantData;
categories: MenuCategory[];
initialThemeKey?: string;
}
export function TemplateGalleryClient({
restaurant,
categories,
initialThemeKey = "elegant",
}: TemplateGalleryClientProps) {
const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey);
const [viewMode, setViewMode] = useState<"phone" | "fullscreen">("phone");
const currentPreset: ThemePreset = THEME_PRESETS[selectedThemeKey] || THEME_PRESETS.elegant!;
return (
<div className="min-h-screen bg-stone-950 text-stone-100 flex flex-col font-sans">
{/* Top Navbar */}
<header className="border-b border-stone-800/80 bg-stone-900/90 backdrop-blur-xl sticky top-0 z-50 px-4 sm:px-6 py-3.5">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-3">
<div className="flex items-center justify-between w-full md:w-auto">
<div className="flex items-center gap-3">
<Link href="/" className="font-black text-xl tracking-tight text-amber-400">
MENULIO
</Link>
<span className="text-stone-600 hidden sm:inline">/</span>
<span className="text-xs font-semibold text-stone-400 hidden sm:inline">Şablon Galerisi</span>
</div>
{/* View Mode Toggle (Visible on desktop/tablet) */}
<div className="flex items-center bg-stone-800/80 p-1 rounded-xl border border-stone-700 md:hidden">
<button
type="button"
onClick={() => setViewMode("phone")}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
viewMode === "phone" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400"
}`}
>
📱 Mobil
</button>
<button
type="button"
onClick={() => setViewMode("fullscreen")}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
viewMode === "fullscreen" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400"
}`}
>
🖥 Tam
</button>
</div>
</div>
{/* 5 Theme Pills - Smooth Horizontal Scroll */}
<div className="flex items-center gap-2 overflow-x-auto w-full md:w-auto pb-1 md:pb-0 no-scrollbar">
{Object.values(THEME_PRESETS).map((preset) => {
const isActive = selectedThemeKey === preset.key;
return (
<button
key={preset.key}
type="button"
onClick={() => setSelectedThemeKey(preset.key)}
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-2 whitespace-nowrap border flex-shrink-0 ${
isActive
? "bg-white text-stone-950 border-white shadow-lg scale-105"
: "bg-stone-800/90 text-stone-300 border-stone-700 hover:border-stone-500 hover:text-white"
}`}
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: preset.config.primaryColor }}
/>
<span>{preset.name}</span>
</button>
);
})}
</div>
<div className="hidden md:flex items-center gap-3">
{/* View Mode Toggle (Desktop) */}
<div className="flex items-center bg-stone-800/80 p-1 rounded-xl border border-stone-700">
<button
type="button"
onClick={() => setViewMode("phone")}
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all ${
viewMode === "phone" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400 hover:text-white"
}`}
>
📱 Telefon
</button>
<button
type="button"
onClick={() => setViewMode("fullscreen")}
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all ${
viewMode === "fullscreen" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400 hover:text-white"
}`}
>
🖥 Tam Ekran
</button>
</div>
<Link
href="/"
className="text-xs font-bold px-4 py-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-stone-950 transition-all shadow-md shadow-amber-500/20"
>
Uygulamayı İndir
</Link>
</div>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 w-full flex flex-col items-center justify-center p-0 sm:p-6 lg:p-8">
{viewMode === "fullscreen" ? (
/* Fullscreen Fluid View */
<div className="w-full flex-1 min-h-[85vh]">
<PublicMenuClient
restaurant={restaurant}
categories={categories}
initialThemeKey={selectedThemeKey}
allowThemeSwitching={true}
/>
</div>
) : (
/* Responsive Device Simulator / Side-by-Side on Desktop */
<div className="w-full max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 items-center justify-center py-4">
{/* Left Info Panel (Visible on Desktop) */}
<div className="hidden lg:flex lg:col-span-5 flex-col space-y-6 pr-4">
<div className="space-y-2">
<span className="text-amber-400 text-xs font-bold uppercase tracking-wider">
ŞABLON DETAYI
</span>
<h2 className="text-3xl font-extrabold text-white">
{currentPreset.name}
</h2>
<p className="text-sm text-stone-400 leading-relaxed">
{currentPreset.key === "elegant" && "Fine dining restoranlar, şarap evleri ve lüks steakhouse mekanlar için altın & krem tonlarında yüksek prestijli tasarım."}
{currentPreset.key === "modern" && "Kafeler, burgerciler ve dinamik mekanlar için canlı mavi safir tonları ve hızlı arama odaklı ızgara düzeni."}
{currentPreset.key === "dark" && "Gece kulüpleri, kokteyl barlar ve lounge mekanlar için obsidian siyahı ve kehribar parıltılı lüks mod."}
{currentPreset.key === "minimal" && "Butik kahveciler, fırınlar ve üçüncü nesil mekanlar için ferah ve monokromatik Nordic mizanpaj."}
{currentPreset.key === "classic" && "Geleneksel brasserie'ler, meyhaneler ve trattoria'lar için Toskana bordo ve sıcak rustik doku."}
</p>
</div>
{/* Theme Specs */}
<div className="p-5 rounded-2xl bg-stone-900 border border-stone-800 space-y-3.5 text-xs">
<div className="flex items-center justify-between">
<span className="text-stone-400">Vurgu Rengi</span>
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: currentPreset.config.primaryColor }}
/>
<span className="font-mono text-stone-200">{currentPreset.config.primaryColor}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-stone-400">Tipografi Ailesi</span>
<span className="font-semibold text-stone-200 uppercase">{currentPreset.fontFamily}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-stone-400">Ürün Mizanpajı</span>
<span className="font-semibold text-stone-200 uppercase">{currentPreset.config.productLayout}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-stone-400">Kategori Gezintisi</span>
<span className="font-semibold text-stone-200 uppercase">{currentPreset.config.categoryLayout}</span>
</div>
</div>
{/* Direct Demo Link */}
<Link
href={`/demo?theme=${selectedThemeKey}`}
target="_blank"
className="w-full py-3.5 rounded-xl font-bold text-center bg-stone-800 hover:bg-stone-700 text-stone-200 border border-stone-700 transition-all text-xs flex items-center justify-center gap-2"
>
<span></span> Yeni Sekmede Canlı Menüyü
</Link>
</div>
{/* Right Phone Mockup Container */}
<div className="lg:col-span-7 flex justify-center w-full">
<div className="w-full max-w-[420px] rounded-[36px] sm:rounded-[44px] overflow-hidden shadow-2xl border-0 sm:border-[8px] border-stone-800 bg-stone-900 relative">
{/* Dynamic Island (Desktop only) */}
<div className="hidden sm:block w-24 h-4 bg-stone-800 rounded-full mx-auto mt-2 mb-1" />
{/* Simulated Screen Body */}
<div className="overflow-y-auto max-h-[85vh] sm:max-h-[780px] rounded-none sm:rounded-[32px] no-scrollbar">
<PublicMenuClient
restaurant={restaurant}
categories={categories}
initialThemeKey={selectedThemeKey}
allowThemeSwitching={true}
/>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { MenuCategory, RestaurantData } from "@/components/PublicMenuClient";
export const DEMO_RESTAURANT: RestaurantData = {
id: "demo-restaurant-1",
name: "Gusto & Co. Brasserie",
slug: "gusto-brasserie",
logo_url: null,
phone: "+90 (212) 555 0192",
address: "Nişantaşı, Abdi İpekçi Cad. No: 42, İstanbul",
};
export const DEMO_CATEGORIES: MenuCategory[] = [
{
id: "cat-starters",
name: "Başlangıçlar & Meze",
description: "Özenle seçilmiş taze malzemeler ve paylaşımlık tabaklar",
is_active: true,
menu_items: [
{
id: "item-1",
name: "Truffle Burrata & Çeri Domates",
description: "Manda burrata, trüf yağı, fırınlanmış karamelize çeri domates ve fesleğen pesto sosu ile.",
price: 420,
image_url: "https://images.unsplash.com/photo-1592417817098-8f3d69102353?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-2",
name: "Dana Carpaccio",
description: "İnce dilimlenmiş marine bonfile, roka, parmesan talaşı, kapari ve trüflü balzamik glaze.",
price: 490,
image_url: "https://images.unsplash.com/photo-1544025162-d76694265947?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-3",
name: "Çıtır Kalamar & Aioli",
description: "Ege kalamarı, hafif mısır unlu kaplama, köz biberli ev yapımı aioli sos ve taze limon dilimleri.",
price: 380,
image_url: "https://images.unsplash.com/photo-1599488615731-7e5c2823ff28?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
],
},
{
id: "cat-mains",
name: "Ana Yemekler & Izgaralar",
description: "Kömür ateşinde dinlendirilmiş etler ve şefin özel tarifleri",
is_active: true,
menu_items: [
{
id: "item-4",
name: "Dry-Aged Ribeye Steak (300g)",
description: "28 gün kuru dinlendirilmiş antrikot, trüflü patates püresi, ızgara kuşkonmaz ve biberiye sosu.",
price: 890,
image_url: "https://images.unsplash.com/photo-1600891964599-f61ba0e24092?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-5",
name: "Trüflü Ev Yapımı Pappardelle",
description: "Taze el açması makarna, yaban mantarları kreması, taze trüf mantarı rendesi ve 24 aylık parmesan.",
price: 520,
image_url: "https://images.unsplash.com/photo-1621996346565-e3d5d6281691?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-6",
name: "Fırınlanmış Norveç Somonu",
description: "Közlenmiş rezene, fırın tatlı patates, taze otlar ve narenciye beurre blanc sosu eşliğinde.",
price: 680,
image_url: "https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-7",
name: "Gusto Signature Smash Burger",
description: "İkili 100g kuru dinlendirilmiş köfte, duble cheddar peyniri, karamelize soğan, trüf mayonez ve brioche ekmeği.",
price: 440,
image_url: "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
],
},
{
id: "cat-cocktails",
name: "İmza Kokteyller & İçecekler",
description: "Miksolojistlerimiz tarafından hazırlanan taze meyveli kokteyller",
is_active: true,
menu_items: [
{
id: "item-8",
name: "Smoked Rosemary Old Fashioned",
description: "Meşe fıçıda dinlendirilmiş burbon, Angostura bitter, tütsülenmiş taze biberiye ve portakal kabuğu.",
price: 410,
image_url: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-9",
name: "Passionfruit & Chili Margarita",
description: "Tekila reposado, çarkıfelek meyvesi püresi, taze misket limonu suyu, agave ve acı biberli tuz çemberi.",
price: 390,
image_url: "https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-10",
name: "Artisan Soğuk Demleme Kahve (Cold Brew)",
description: "18 saat soğuk demlenmiş tek kökenli Etiyopya Yirgacheffe çekirdekleri, buz ile servis edilir.",
price: 160,
image_url: "https://images.unsplash.com/photo-1517701550927-30cf4ba1dba5?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
],
},
{
id: "cat-desserts",
name: "Tatlılar",
description: "Günün tatlı sonu için taze pastacılık lezzetleri",
is_active: true,
menu_items: [
{
id: "item-11",
name: "San Sebastián Cheesecake & Belçika Çikolatası",
description: "Akışkan fırınlanmış Bask cheesecake, eritilmiş sıcak Callebaut bitter çikolatası ile.",
price: 280,
image_url: "https://images.unsplash.com/photo-1533134242443-d4fd215305ad?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
{
id: "item-12",
name: "Sıcak Çikolatalı Fondan & Vanilyalı Dondurma",
description: "Akışkan lav kek, Madagaskar vanilyalı dondurma ve çıtır fındık krokant.",
price: 310,
image_url: "https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=600&auto=format&fit=crop&q=80",
is_active: true,
},
],
},
];
+11
View File
@@ -0,0 +1,11 @@
import { createClient } from "@supabase/supabase-js";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
throw new Error("NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY missing");
}
// Anon key only — RLS enforces that only published-menu data is readable here.
export const supabase = createClient(url, anonKey);
+131
View File
@@ -0,0 +1,131 @@
import type { ThemeConfig } from "@menulio/shared";
export interface ThemePreset {
key: string;
name: string;
fontFamily: "serif" | "sans" | "heading";
config: ThemeConfig;
cardBg: string;
cardBorder: string;
textPrimary: string;
textSecondary: string;
accentBg: string;
headerGradient: string;
badgeBg: string;
badgeText: string;
}
export const THEME_PRESETS: Record<string, ThemePreset> = {
elegant: {
key: "elegant",
name: "Elegant Gold",
fontFamily: "serif",
config: {
primaryColor: "#C8A96B",
background: "#FAF8F5",
productLayout: "card",
categoryLayout: "accordion",
},
cardBg: "#FFFFFF",
cardBorder: "rgba(200, 169, 107, 0.18)",
textPrimary: "#1C1917",
textSecondary: "#78716C",
accentBg: "rgba(200, 169, 107, 0.12)",
headerGradient: "linear-gradient(180deg, #1C1917 0%, #292524 100%)",
badgeBg: "#F5F0E6",
badgeText: "#926E27",
},
modern: {
key: "modern",
name: "Modern Sapphire",
fontFamily: "sans",
config: {
primaryColor: "#2563EB",
background: "#F8FAFC",
productLayout: "card",
categoryLayout: "tabs",
},
cardBg: "#FFFFFF",
cardBorder: "rgba(226, 232, 240, 0.9)",
textPrimary: "#0F172A",
textSecondary: "#64748B",
accentBg: "rgba(37, 99, 235, 0.08)",
headerGradient: "linear-gradient(135deg, #1E293B 0%, #0F172A 100%)",
badgeBg: "#EFF6FF",
badgeText: "#1D4ED8",
},
dark: {
key: "dark",
name: "Luxury Dark",
fontFamily: "heading",
config: {
primaryColor: "#F59E0B",
background: "#0F0F12",
productLayout: "card",
categoryLayout: "accordion",
},
cardBg: "#18181D",
cardBorder: "rgba(255, 255, 255, 0.07)",
textPrimary: "#F8FAFC",
textSecondary: "#94A3B8",
accentBg: "rgba(245, 158, 11, 0.12)",
headerGradient: "linear-gradient(180deg, #09090B 0%, #18181B 100%)",
badgeBg: "#27272A",
badgeText: "#FBBF24",
},
minimal: {
key: "minimal",
name: "Nordic Minimal",
fontFamily: "sans",
config: {
primaryColor: "#18181B",
background: "#FAFAFA",
productLayout: "list",
categoryLayout: "flat",
},
cardBg: "#FFFFFF",
cardBorder: "#E4E4E7",
textPrimary: "#18181B",
textSecondary: "#71717A",
accentBg: "#F4F4F5",
headerGradient: "linear-gradient(180deg, #27272A 0%, #18181B 100%)",
badgeBg: "#F4F4F5",
badgeText: "#27272A",
},
classic: {
key: "classic",
name: "Classic Bistro",
fontFamily: "serif",
config: {
primaryColor: "#8B1E1E",
background: "#FFF9F2",
productLayout: "list",
categoryLayout: "accordion",
},
cardBg: "#FFFFFF",
cardBorder: "rgba(139, 30, 30, 0.15)",
textPrimary: "#2D1B18",
textSecondary: "#7A6966",
accentBg: "rgba(139, 30, 30, 0.08)",
headerGradient: "linear-gradient(180deg, #38120F 0%, #240B0A 100%)",
badgeBg: "#FDF2F0",
badgeText: "#8B1E1E",
},
};
const ELEGANT_PRESET: ThemePreset = THEME_PRESETS.elegant!;
export const DEFAULT_THEME: ThemeConfig = ELEGANT_PRESET.config;
export function resolveThemePreset(themeKey?: string, customConfig?: Partial<ThemeConfig>): ThemePreset {
const base = (themeKey ? THEME_PRESETS[themeKey] : undefined) ?? ELEGANT_PRESET;
if (!customConfig) return base;
return {
...base,
config: {
...base.config,
...customConfig,
},
};
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
const ROOT_DOMAIN = process.env.ROOT_DOMAIN ?? "menul.io";
// Wildcard subdomain routing: kebapci-ahmet.menul.io -> /menu/kebapci-ahmet
// Custom domains resolve here too once verified (PRD §11).
export function middleware(req: NextRequest) {
const host = req.headers.get("host") ?? "";
const hostname = host.split(":")[0] ?? host;
const isRootDomain = hostname === ROOT_DOMAIN || hostname === `www.${ROOT_DOMAIN}`;
if (isRootDomain || hostname === "localhost") {
return NextResponse.next();
}
const subdomain = hostname.endsWith(`.${ROOT_DOMAIN}`)
? hostname.replace(`.${ROOT_DOMAIN}`, "")
: hostname; // custom domain — resolved to a slug via domains table at render time
const url = req.nextUrl.clone();
url.pathname = `/menu/${subdomain}${req.nextUrl.pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
+42
View File
@@ -0,0 +1,42 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class",
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)", "system-ui", "sans-serif"],
heading: ["var(--font-heading)", "sans-serif"],
serif: ["var(--font-serif)", "Georgia", "serif"],
},
colors: {
amber: {
450: "#F5A524",
},
},
animation: {
"fade-in": "fadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards",
"slide-up": "slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0", transform: "translateY(8px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slideUp: {
"0%": { opacity: "0", transform: "translateY(16px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
},
},
plugins: [],
};
export default config;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"noEmit": true,
"allowJs": true,
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "src", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}