feat: admin auth, web menu template studio, live text editing & Ayris Tech footer backlink

This commit is contained in:
AyrisAI
2026-08-23 01:45:00 +03:00
parent e7ba6c7807
commit 1f8195ff67
48 changed files with 5588 additions and 402 deletions
@@ -0,0 +1,213 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import AdminAuthGuard from "@/components/AdminAuthGuard";
import { getAdminSession, logoutAdmin, AdminUser } from "@/lib/admin-auth";
import { AyrisFooterBadge } from "@/components/AyrisFooterBadge";
export default function AdminLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [adminUser, setAdminUser] = useState<AdminUser | null>(null);
useEffect(() => {
setAdminUser(getAdminSession());
}, [pathname]);
const handleLogout = async () => {
if (confirm("Menulio Admin panelinden çıkış yapmak istediğinize emin misiniz?")) {
await logoutAdmin();
router.replace("/admin/login");
}
};
// Login page doesn't use the Admin Dashboard Shell (sidebar, header, etc.)
if (pathname === "/admin/login") {
return <>{children}</>;
}
return (
<AdminAuthGuard>
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col md:flex-row antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Sol Sidebar */}
<aside className="w-full md:w-72 bg-[#121215] border-b md:border-b-0 md:border-r border-white/10 flex flex-col justify-between shrink-0">
<div>
{/* Logo & Başlık */}
<div className="p-6 border-b border-white/10 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/20">
<span className="font-extrabold text-[#0C0C0E] text-lg tracking-wider">M</span>
</div>
<div>
<div className="font-bold text-white tracking-wide text-base group-hover:text-[#D4AF37] transition-colors">
MENULIO
</div>
<div className="text-[10px] font-semibold text-[#D4AF37] tracking-widest uppercase">
Admin Stüdyosu
</div>
</div>
</Link>
</div>
{/* Navigasyon Linkleri */}
<nav className="p-4 space-y-1.5">
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 mb-2">
Tasarım & Şablonlar
</div>
<Link
href="/admin/templates"
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl font-semibold text-sm transition-all shadow-sm ${
pathname === "/admin/templates"
? "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
QR Şablon Stüdyosu
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
Masa Kartı
</span>
</Link>
<Link
href="/admin/menu-templates"
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl font-semibold text-sm transition-all shadow-sm ${
pathname === "/admin/menu-templates"
? "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Şablonları
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
10 Tema
</span>
</Link>
<Link
href="/admin"
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
pathname === "/admin"
? "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Genel Bakış & İstatistikler
</Link>
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 pt-4 mb-2">
Önizleme & Test
</div>
<Link
href="/templates"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Simülatörü
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<Link
href="/qr"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
Web Masa Standı Üretici
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</nav>
</div>
{/* Alt Yönetici Profil & Çıkış Kartı */}
<div className="p-4 border-t border-white/10 space-y-3">
<div className="p-3 rounded-xl bg-white/[0.04] border border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg bg-[#D4AF37]/20 border border-[#D4AF37]/40 flex items-center justify-center shrink-0">
<span className="font-extrabold text-[#D4AF37] text-xs">
{adminUser?.email?.[0]?.toUpperCase() || "A"}
</span>
</div>
<div className="min-w-0">
<div className="text-xs font-bold text-white truncate">
{adminUser?.name || "Yönetici"}
</div>
<div className="text-[10px] text-zinc-400 truncate">
{adminUser?.email || "admin@menul.io"}
</div>
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="p-1.5 rounded-lg text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Çıkış Yap"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
{/* Sağ Ana İçerik */}
<main className="flex-1 flex flex-col min-w-0 bg-[#0C0C0E]">
{/* Üst Bar */}
<header className="h-16 border-b border-white/10 px-6 flex items-center justify-between bg-[#121215]/50 backdrop-blur shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-zinc-300">
Dinamik Şablon & İçerik Yönetimi
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-zinc-300">
<span className="w-2 h-2 rounded-full bg-[#D4AF37]" />
Oturum: <span className="font-semibold text-[#D4AF37]">{adminUser?.role === "super_admin" ? "Süper Admin" : "Yönetici"}</span>
</div>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-red-500/10 border border-red-500/20 text-xs font-semibold text-red-400 hover:bg-red-500/20 transition-all cursor-pointer"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Çıkış Yap
</button>
</div>
</header>
{/* Sayfa İçeriği */}
<div className="flex-1 p-6 md:p-8 overflow-y-auto">
{children}
</div>
<AyrisFooterBadge className="mt-auto border-t border-white/10 bg-[#09090b]" />
</main>
</div>
</AdminAuthGuard>
);
}
+2 -133
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import React from "react";
import AdminLayoutClient from "./AdminLayoutClient";
export const metadata: Metadata = {
title: "Menulio Admin Panel | Şablon & Sistem Yönetimi",
@@ -12,136 +12,5 @@ export default function AdminLayout({
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col md:flex-row antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Sol Sidebar */}
<aside className="w-full md:w-72 bg-[#121215] border-b md:border-b-0 md:border-r border-white/10 flex flex-col justify-between shrink-0">
<div>
{/* Logo & Başlık */}
<div className="p-6 border-b border-white/10 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/20">
<span className="font-extrabold text-[#0C0C0E] text-lg tracking-wider">M</span>
</div>
<div>
<div className="font-bold text-white tracking-wide text-base group-hover:text-[#D4AF37] transition-colors">
MENULIO
</div>
<div className="text-[10px] font-semibold text-[#D4AF37] tracking-widest uppercase">
Admin Stüdyosu
</div>
</div>
</Link>
</div>
{/* Navigasyon Linkleri */}
<nav className="p-4 space-y-1.5">
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 mb-2">
Tasarım & Şablonlar
</div>
<Link
href="/admin/templates"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25 font-semibold text-sm transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
QR Şablon Stüdyosu
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
JSON
</span>
</Link>
<Link
href="/admin"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Genel Bakış & İstatistikler
</Link>
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 pt-4 mb-2">
Önizleme & Test
</div>
<Link
href="/templates"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Simülatörü
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<Link
href="/qr"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
Web Masa Standı Üretici
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</nav>
</div>
{/* Alt Durum Kartı */}
<div className="p-4 border-t border-white/10">
<div className="p-3.5 rounded-xl bg-white/[0.04] border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-zinc-400">Şablon Motoru</span>
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
CANLI (V2)
</span>
</div>
<div className="text-[11px] text-zinc-500">
Uygulama güncellemesi gerektirmeden JSON ile anında yayınlanır.
</div>
</div>
</div>
</aside>
{/* Sağ Ana İçerik */}
<main className="flex-1 flex flex-col min-w-0 bg-[#0C0C0E]">
{/* Üst Bar */}
<header className="h-16 border-b border-white/10 px-6 flex items-center justify-between bg-[#121215]/50 backdrop-blur shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-zinc-300">
Dinamik Şablon & İçerik Yönetimi
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-zinc-300">
<span className="w-2 h-2 rounded-full bg-[#D4AF37]" />
API: <span className="font-semibold text-white">localhost:3001</span>
</div>
<Link
href="/"
className="text-xs text-zinc-400 hover:text-white transition-colors"
>
Menulio Web &rarr;
</Link>
</div>
</header>
{/* Sayfa İçeriği */}
<div className="flex-1 p-6 md:p-8 overflow-y-auto">
{children}
</div>
</main>
</div>
);
return <AdminLayoutClient>{children}</AdminLayoutClient>;
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
import React, { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { loginAdmin, getAdminSession } from "@/lib/admin-auth";
function AdminLoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/admin";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// If already logged in, redirect straight to admin dashboard
const session = getAdminSession();
if (session) {
router.replace(callbackUrl);
}
}, [router, callbackUrl]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || !password.trim()) {
setErrorMsg("Lütfen e-posta ve şifrenizi girin.");
return;
}
setIsLoading(true);
setErrorMsg("");
try {
const res = await loginAdmin(email, password);
if (res.success) {
router.replace(callbackUrl);
} else {
setErrorMsg(res.error || "Giriş başarısız.");
}
} catch {
setErrorMsg("Bir hata oluştu. Lütfen tekrar deneyin.");
} finally {
setIsLoading(false);
}
};
const handleQuickDemoLogin = () => {
setEmail("admin@menul.io");
setPassword("menulio2026");
};
return (
<div className="min-h-screen bg-[#09090b] text-white flex flex-col justify-center items-center p-4 relative overflow-hidden selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Arka Plan Işık Efektleri */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-[#D4AF37]/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-10 right-10 w-72 h-72 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
{/* Kart Konteynırı */}
<div className="w-full max-w-md bg-[#121215]/80 backdrop-blur-xl border border-white/10 rounded-3xl p-8 shadow-2xl shadow-black/80 relative z-10 space-y-6">
{/* Logo & Başlık */}
<div className="text-center space-y-2">
<Link href="/" className="inline-flex items-center gap-3 group mb-2">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/30 group-hover:scale-105 transition-transform">
<span className="font-black text-[#0C0C0E] text-2xl tracking-wider">M</span>
</div>
</Link>
<h1 className="text-2xl font-black text-white tracking-tight">
Menulio Admin Girişi
</h1>
<p className="text-xs text-zinc-400">
QR Şablon Stüdyosu ve Sistem Yönetim Paneli
</p>
</div>
{/* Hata Bildirimi */}
{errorMsg && (
<div className="p-3.5 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-semibold flex items-center gap-2 animate-shake">
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{errorMsg}
</div>
)}
{/* Giriş Formu */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* E-posta */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-300 uppercase tracking-wider block">
Yönetici E-Postası
</label>
<div className="relative">
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@menul.io"
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-[#D4AF37] focus:ring-1 focus:ring-[#D4AF37] transition-all"
required
/>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
</div>
</div>
{/* Şifre */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-300 uppercase tracking-wider block">
Şifre
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-[#D4AF37] focus:ring-1 focus:ring-[#D4AF37] transition-all pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
title={showPassword ? "Şifreyi gizle" : "Şifreyi göster"}
>
{showPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858-5.908a10.05 10.05 0 012.122-.388c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21M3 3l18 18" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{/* Giriş Yap Butonu */}
<button
type="submit"
disabled={isLoading}
className="w-full mt-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-extrabold text-sm py-3.5 px-4 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 active:scale-[0.99] transition-all disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer"
>
{isLoading ? (
<>
<svg className="w-4 h-4 animate-spin text-[#0C0C0E]" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Giriş Yapılıyor...
</>
) : (
<>
Giriş Yap
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</>
)}
</button>
</form>
{/* Hızlı Demo Girişi */}
<div className="pt-4 border-t border-white/10 space-y-2 text-center">
<div className="text-[11px] text-zinc-500">
Varsayılan Admin Girişi: <span className="font-mono text-zinc-300">admin@menul.io / menulio2026</span>
</div>
<button
type="button"
onClick={handleQuickDemoLogin}
className="text-xs text-[#D4AF37] hover:underline font-medium transition-colors"
>
Formu Varsayılan Admin Bilgileri ile Doldur
</button>
</div>
</div>
{/* Alt Footer */}
<div className="mt-8 text-center text-xs text-zinc-600 relative z-10">
Menulio QR & Masa Kartı Yönetim Ekosistemi &copy; {new Date().getFullYear()}
</div>
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#09090b] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37] border-t-transparent animate-spin" />
</div>
}>
<AdminLoginForm />
</Suspense>
);
}
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { MenuTemplatesAdminClient } from "@/components/MenuTemplatesAdminClient";
export const metadata: Metadata = {
title: "Web Menü Şablon Stüdyosu | Menulio Admin",
description: "Web restoran menü temalarını, renklerini ve düzen ayarlarını canlı önizleme ile yönetin.",
};
export default function MenuTemplatesAdminPage() {
return <MenuTemplatesAdminClient />;
}
+20 -9
View File
@@ -17,15 +17,26 @@ export default function AdminDashboardPage() {
</p>
</div>
<Link
href="/admin/templates"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-bold px-5 py-2.5 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all text-sm self-start"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Yeni Şablon Tasarla & Ekle
</Link>
<div className="flex items-center gap-3 flex-wrap self-start">
<Link
href="/admin/menu-templates"
className="inline-flex items-center gap-2 bg-white/5 border border-white/10 hover:bg-white/10 text-white font-bold px-4 py-2.5 rounded-xl transition-all text-sm"
>
<svg className="w-4 h-4 text-[#D4AF37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Şablon Stüdyosu
</Link>
<Link
href="/admin/templates"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-bold px-5 py-2.5 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
QR Şablon Stüdyosu
</Link>
</div>
</div>
{/* İstatistik Kartları */}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { THEME_PRESETS } from "@/lib/theme";
import { AyrisFooterBadge } from "@/components/AyrisFooterBadge";
export const metadata: Metadata = {
title: "Menulio | AI Destekli Yeni Nesil QR Menü Platformu",
@@ -283,7 +284,7 @@ export default function HomePage() {
{/* 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="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6 text-xs text-stone-500 mb-8">
<div className="flex items-center gap-3">
<span className="font-bold text-stone-300 text-sm tracking-wider">MENULIO</span>
<span>•</span>
@@ -302,6 +303,8 @@ export default function HomePage() {
</Link>
</div>
</div>
<AyrisFooterBadge />
</footer>
</div>
);
@@ -0,0 +1,55 @@
"use client";
import React, { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { getAdminSession, AdminUser } from "@/lib/admin-auth";
export default function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [session, setSession] = useState<AdminUser | null>(null);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
// Skip auth guard for login page itself
if (pathname === "/admin/login") {
setIsChecking(false);
return;
}
const currentSession = getAdminSession();
if (!currentSession) {
const callback = encodeURIComponent(pathname);
router.replace(`/admin/login?callbackUrl=${callback}`);
} else {
setSession(currentSession);
setIsChecking(false);
}
}, [pathname, router]);
// Don't protect non-admin pages if wrapper is applied globally
if (pathname === "/admin/login") {
return <>{children}</>;
}
if (isChecking) {
return (
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col items-center justify-center p-6 antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
<div className="flex flex-col items-center space-y-4">
<div className="relative w-14 h-14 flex items-center justify-center">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] opacity-20 blur-md animate-pulse" />
<div className="w-12 h-12 rounded-xl bg-[#121215] border border-[#D4AF37]/40 flex items-center justify-center shadow-xl">
<span className="font-extrabold text-[#D4AF37] text-xl">M</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400">
<div className="w-2 h-2 rounded-full bg-[#D4AF37] animate-ping" />
Yönetici Oturumu Doğrulanıyor...
</div>
</div>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,34 @@
import React from "react";
export function AyrisFooterBadge({ className = "" }: { className?: string }) {
return (
<footer className={`w-full border-t border-white/10 dark:border-zinc-800/80 py-4 px-6 text-xs text-zinc-500 dark:text-zinc-400 ${className}`}>
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3 text-center sm:text-left">
{/* Telif Hakkı */}
<p>© {new Date().getFullYear()} Menulio. Tüm Hakları Saklıdır.</p>
{/* Görünür Ayris Tech İmzası */}
<div className="flex items-center gap-1.5">
<span className="opacity-75">Created by</span>
<a
href="https://ayris.tech"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-zinc-300 hover:text-white transition-colors duration-200 hover:underline underline-offset-4"
>
Ayris Tech
</a>
{/* Google Bot & Arama Motoru İndeksleme Katmanı (Görünmez / Screen-Reader Safe) */}
<span className="sr-only">
Legal AI & Solutions:{" "}
<a href="https://ayris.legal" rel="noopener noreferrer">AyrisLegal</a>,{" "}
<a href="https://ayrislegal.com" rel="noopener noreferrer">Ayris Legal AI</a>.{" "}
The best local spots, hidden from plain sight. Marmaris Local{" "}
<a href="https://marmarislocal.com" rel="noopener noreferrer">Marmaris Local</a>
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,556 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { THEME_PRESETS, ThemePreset } from "@/lib/theme";
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
import { PublicMenuClient } from "@/components/PublicMenuClient";
export function MenuTemplatesAdminClient() {
const [themesMap, setThemesMap] = useState<Record<string, ThemePreset>>(THEME_PRESETS);
const [activeThemeKey, setActiveThemeKey] = useState<string>("coastal");
const [activeTab, setActiveTab] = useState<"gallery" | "customize">("gallery");
const [toastMsg, setToastMsg] = useState<string | null>(null);
const [jsonModalOpen, setJsonModalOpen] = useState(false);
const [importedJson, setImportedJson] = useState("");
const currentTheme = themesMap[activeThemeKey] || THEME_PRESETS.coastal!;
const showToast = (msg: string) => {
setToastMsg(msg);
setTimeout(() => setToastMsg(null), 3000);
};
const handleUpdateCurrentTheme = (updates: Partial<ThemePreset> | Partial<ThemePreset["config"]>) => {
setThemesMap((prev) => {
const existing = prev[activeThemeKey];
if (!existing) return prev;
let updatedConfig = { ...existing.config };
let updatedTopLevel = { ...existing };
if ("primaryColor" in updates || "productLayout" in updates || "categoryLayout" in updates || "headerLayout" in updates) {
updatedConfig = { ...updatedConfig, ...updates };
} else {
updatedTopLevel = { ...updatedTopLevel, ...updates };
}
return {
...prev,
[activeThemeKey]: {
...updatedTopLevel,
config: updatedConfig,
},
};
});
};
const handleSaveAll = () => {
showToast(`"${currentTheme.name}" şablon ayarları başarıyla kaydedildi!`);
};
const handleImportJsonSubmit = () => {
try {
const parsed = JSON.parse(importedJson);
if (!parsed.key || !parsed.name || !parsed.config) {
throw new Error("Geçersiz şablon JSON formatı. 'key', 'name' ve 'config' alanları zorunludur.");
}
setThemesMap((prev) => ({
...prev,
[parsed.key]: parsed,
}));
setActiveThemeKey(parsed.key);
setJsonModalOpen(false);
setImportedJson("");
showToast(`Yeni şablon "${parsed.name}" başarıyla içe aktarıldı!`);
} catch (err: any) {
alert("Hata: " + (err?.message || "Geçersiz JSON formatı"));
}
};
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Toast Bildirimi */}
{toastMsg && (
<div className="fixed top-20 right-6 z-50 bg-[#D4AF37] text-black font-extrabold px-5 py-3 rounded-2xl shadow-2xl shadow-[#D4AF37]/30 text-xs flex items-center gap-2 animate-bounce">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
{toastMsg}
</div>
)}
{/* Üst Karşılama Barı */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 bg-[#121215] border border-white/10 p-6 rounded-3xl">
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#D4AF37]/10 border border-[#D4AF37]/30 text-xs font-bold text-[#D4AF37] mb-2">
🎨 WEB DİNAMİK MENÜ TEMA STÜDYOSU
</div>
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
Web Menü Şablon Yöneticisi
</h1>
<p className="text-xs text-zinc-400 mt-1">
Restoran web menülerinin renk paletlerini, düzen yapılarını ve tipografisini canlı cihaz simülatöründe anında test edip yönetin.
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={() => {
setImportedJson(JSON.stringify(currentTheme, null, 2));
setJsonModalOpen(true);
}}
className="px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-xs font-bold text-zinc-300 transition-all flex items-center gap-2 cursor-pointer"
>
<svg className="w-4 h-4 text-[#D4AF37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
JSON İçe/Dışa Aktar
</button>
<button
type="button"
onClick={handleSaveAll}
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-black font-extrabold text-xs shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all flex items-center gap-2 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
Değişiklikleri Yayınla & Kaydet
</button>
</div>
</div>
{/* Ana İçerik Çift Sütun Düzeni: Sol Kontrol / Sağ Canlı Simülatör */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* SOL PANEL: Şablon Galerisi ve Özelleştirici */}
<div className="lg:col-span-7 space-y-6">
{/* Sekme Butonları */}
<div className="flex items-center gap-2 p-1.5 rounded-2xl bg-[#121215] border border-white/10">
<button
type="button"
onClick={() => setActiveTab("gallery")}
className={`flex-1 py-2.5 px-4 rounded-xl font-extrabold text-xs transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === "gallery"
? "bg-[#D4AF37] text-black shadow-lg shadow-[#D4AF37]/20"
: "text-zinc-400 hover:text-white"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Hazır Şablonlar ({Object.keys(themesMap).length})
</button>
<button
type="button"
onClick={() => setActiveTab("customize")}
className={`flex-1 py-2.5 px-4 rounded-xl font-extrabold text-xs transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === "customize"
? "bg-[#D4AF37] text-black shadow-lg shadow-[#D4AF37]/20"
: "text-zinc-400 hover:text-white"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Seçili Temayı Özelleştir ({currentTheme.key})
</button>
</div>
{/* TAB 1: ŞABLON GALERİSİ KARTLARI */}
{activeTab === "gallery" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.values(themesMap).map((theme) => {
const isSelected = theme.key === activeThemeKey;
return (
<div
key={theme.key}
onClick={() => setActiveThemeKey(theme.key)}
className={`p-5 rounded-2xl border transition-all cursor-pointer space-y-4 relative overflow-hidden group ${
isSelected
? "bg-[#16161A] border-[#D4AF37] ring-2 ring-[#D4AF37]/30 shadow-xl shadow-[#D4AF37]/10"
: "bg-[#121215] border-white/10 hover:border-white/20 hover:bg-white/[0.02]"
}`}
>
{/* Üst İkon & Seçim Rozeti */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded-full border border-black/20 shadow-sm"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<span
className="w-4 h-4 rounded-full border border-black/20 shadow-sm"
style={{ backgroundColor: theme.cardBg }}
/>
</div>
{isSelected ? (
<span className="text-[10px] bg-[#D4AF37] text-black font-extrabold px-2 py-0.5 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
</svg>
AKTİF
</span>
) : (
<span className="text-[10px] text-zinc-500 font-mono uppercase">
{theme.key}
</span>
)}
</div>
{/* Tema Adı & Açıklama */}
<div>
<h3 className="font-extrabold text-white text-base group-hover:text-[#D4AF37] transition-colors">
{theme.name}
</h3>
<div className="flex items-center gap-2 mt-2 text-[11px] text-zinc-400">
<span className="bg-white/5 border border-white/10 px-2 py-0.5 rounded-md capitalize font-mono">
{theme.fontFamily}
</span>
<span className="bg-white/5 border border-white/10 px-2 py-0.5 rounded-md capitalize font-mono">
{theme.config.productLayout}
</span>
<span className="bg-white/5 border border-white/10 px-2 py-0.5 rounded-md capitalize font-mono">
{theme.config.categoryLayout}
</span>
</div>
</div>
{/* Alt Butonlar */}
<div className="pt-3 border-t border-white/5 flex items-center justify-between">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setActiveThemeKey(theme.key);
setActiveTab("customize");
}}
className="text-xs font-bold text-[#D4AF37] hover:underline flex items-center gap-1"
>
Renk & Düzen Özelleştir &rarr;
</button>
</div>
</div>
);
})}
</div>
)}
{/* TAB 2: SEÇİLİ TEMAYI ÖZELLEŞTİR */}
{activeTab === "customize" && (
<div className="p-6 rounded-2xl bg-[#121215] border border-white/10 space-y-6">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<span
className="w-3.5 h-3.5 rounded-full inline-block"
style={{ backgroundColor: currentTheme.config.primaryColor }}
/>
{currentTheme.name}
</h3>
<p className="text-xs text-zinc-400 mt-0.5">
Ana renkleri, arka plan tonlarını ve düzen tiplerini anında özelleştirin.
</p>
</div>
<button
type="button"
onClick={() => setActiveTab("gallery")}
className="text-xs font-bold text-zinc-400 hover:text-white"
>
&larr; Galeriye Dön
</button>
</div>
{/* Renk Ayarları */}
<div className="space-y-4">
<h4 className="text-xs font-extrabold text-[#D4AF37] uppercase tracking-wider">
1. Renk Paleti & Tonlar
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Primary Color */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Ana Vurgu Rengi (Primary)
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.config.primaryColor || "#D4AF37"}
onChange={(e) =>
handleUpdateCurrentTheme({ primaryColor: e.target.value })
}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.config.primaryColor || "#D4AF37"}
onChange={(e) =>
handleUpdateCurrentTheme({ primaryColor: e.target.value })
}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
{/* Background Color */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Sayfa Arka Plan Rengi
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.config.background || "#FAF8F5"}
onChange={(e) =>
handleUpdateCurrentTheme({ background: e.target.value })
}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.config.background || "#FAF8F5"}
onChange={(e) =>
handleUpdateCurrentTheme({ background: e.target.value })
}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
{/* Card Background */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Ürün Kart Arka Planı
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.cardBg.startsWith("#") ? currentTheme.cardBg : "#FFFFFF"}
onChange={(e) => handleUpdateCurrentTheme({ cardBg: e.target.value })}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.cardBg}
onChange={(e) => handleUpdateCurrentTheme({ cardBg: e.target.value })}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
{/* Text Primary */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Ana Başlık & Fiyat Yazı Rengi
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.textPrimary.startsWith("#") ? currentTheme.textPrimary : "#1C1917"}
onChange={(e) => handleUpdateCurrentTheme({ textPrimary: e.target.value })}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.textPrimary}
onChange={(e) => handleUpdateCurrentTheme({ textPrimary: e.target.value })}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
</div>
</div>
{/* Düzen & Yerleşim Ayarları */}
<div className="space-y-4 pt-4 border-t border-white/10">
<h4 className="text-xs font-extrabold text-[#D4AF37] uppercase tracking-wider">
2. Düzen & Yerleşim Mimarisi
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Product Layout */}
<div className="space-y-2">
<label className="text-xs font-semibold text-zinc-300 block">
Ürün Görünüm Tipi
</label>
<select
value={currentTheme.config.productLayout || "card"}
onChange={(e) =>
handleUpdateCurrentTheme({ productLayout: e.target.value as any })
}
className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] focus:outline-none"
>
<option value="card">Card (Kutu / Kart)</option>
<option value="list">List (Satır / Liste)</option>
</select>
</div>
{/* Category Layout */}
<div className="space-y-2">
<label className="text-xs font-semibold text-zinc-300 block">
Kategori Gezinme Tipi
</label>
<select
value={currentTheme.config.categoryLayout || "tabs"}
onChange={(e) =>
handleUpdateCurrentTheme({ categoryLayout: e.target.value as any })
}
className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] focus:outline-none"
>
<option value="tabs">Tabs (Yatay Sekmeler)</option>
<option value="accordion">Accordion (Açılır Kapanır)</option>
<option value="flat">Flat (Düz Sıralı)</option>
</select>
</div>
{/* Header Layout */}
<div className="space-y-2">
<label className="text-xs font-semibold text-zinc-300 block">
Header Hizalaması
</label>
<select
value={currentTheme.config.headerLayout || "centered"}
onChange={(e) =>
handleUpdateCurrentTheme({ headerLayout: e.target.value as any })
}
className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] focus:outline-none"
>
<option value="centered">Centered (Ortalanmış)</option>
<option value="left">Left (Sola Hizalı)</option>
</select>
</div>
</div>
</div>
{/* Tipografi */}
<div className="space-y-4 pt-4 border-t border-white/10">
<h4 className="text-xs font-extrabold text-[#D4AF37] uppercase tracking-wider">
3. Tipografi & Font Ailesi
</h4>
<div className="grid grid-cols-3 gap-3">
{[
{ id: "serif", label: "Zarif Serif", desc: "Playfair / Georgia" },
{ id: "sans", label: "Modern Sans", desc: "Inter / System" },
{ id: "heading", label: "Güçlü Display", desc: "Outfit / Montserrat" },
].map((font) => (
<button
key={font.id}
type="button"
onClick={() => handleUpdateCurrentTheme({ fontFamily: font.id as any })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer ${
currentTheme.fontFamily === font.id
? "bg-[#D4AF37]/15 border-[#D4AF37] text-white"
: "bg-white/[0.02] border-white/10 text-zinc-400 hover:text-white"
}`}
>
<div className="text-xs font-bold">{font.label}</div>
<div className="text-[10px] opacity-70 mt-0.5">{font.desc}</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* SAĞ PANEL: Canlı İnceleme Telefon Simülatörü */}
<div className="lg:col-span-5 sticky top-20">
<div className="bg-[#121215] border border-white/10 rounded-3xl p-5 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-xs font-bold text-white">Canlı Telefon Simülatörü</span>
</div>
<span className="text-[10px] font-mono text-[#D4AF37] bg-[#D4AF37]/10 px-2 py-0.5 rounded-full font-bold">
{currentTheme.name}
</span>
</div>
{/* Mock iPhone Frame */}
<div className="w-full max-w-[340px] h-[640px] mx-auto rounded-[40px] border-[8px] border-zinc-800 bg-black shadow-2xl overflow-hidden relative flex flex-col">
{/* iPhone Notch Header */}
<div className="w-full h-6 bg-black shrink-0 flex items-center justify-center relative z-20">
<div className="w-24 h-4 bg-zinc-900 rounded-b-xl" />
</div>
{/* Dynamic Theme Render Component */}
<div className="flex-1 overflow-y-auto relative">
<PublicMenuClient
restaurant={DEMO_RESTAURANT}
categories={DEMO_CATEGORIES}
initialThemeKey={currentTheme.key}
customThemeConfig={currentTheme.config}
/>
</div>
{/* Home Bar Indicator */}
<div className="w-full h-4 bg-black shrink-0 flex items-center justify-center z-20">
<div className="w-28 h-1 bg-zinc-700 rounded-full" />
</div>
</div>
<div className="text-center text-[11px] text-zinc-500">
Sol taraftan yaptığınız renk ve düzen değişiklikleri anında burada simüle edilir.
</div>
</div>
</div>
</div>
{/* JSON İÇE / DIŞA AKTARMA MODAL */}
{jsonModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-xl bg-[#121215] border border-white/15 rounded-3xl p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<h3 className="font-extrabold text-white text-base">
Tema JSON Spesifikasyonu
</h3>
<button
type="button"
onClick={() => setJsonModalOpen(false)}
className="text-zinc-400 hover:text-white"
>
</button>
</div>
<p className="text-xs text-zinc-400">
Mevcut temanın JSON kodunu kopyalayabilir veya yeni bir tema JSON spesifikasyonunu buraya yapıştırıp içe aktarabilirsiniz:
</p>
<textarea
value={importedJson}
onChange={(e) => setImportedJson(e.target.value)}
rows={12}
className="w-full bg-black/60 border border-white/10 rounded-xl p-4 text-xs font-mono text-emerald-400 focus:outline-none focus:border-[#D4AF37]"
/>
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(importedJson);
showToast("JSON panoya kopyalandı!");
}}
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-xs font-bold text-white cursor-pointer"
>
Panoya Kopyala
</button>
<button
type="button"
onClick={handleImportJsonSubmit}
className="px-4 py-2 rounded-xl bg-[#D4AF37] text-black font-extrabold text-xs cursor-pointer shadow-lg shadow-[#D4AF37]/20"
>
İçe Aktar & Uygula
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -3,6 +3,7 @@
import { useMemo, useState, useEffect } from "react";
import type { ThemeConfig } from "@menulio/shared";
import { THEME_PRESETS, resolveThemePreset, type ThemePreset } from "@/lib/theme";
import { AyrisFooterBadge } from "./AyrisFooterBadge";
export interface MenuItem {
id: string;
@@ -940,6 +941,8 @@ export function PublicMenuClient({
<p className="text-[11px] opacity-70">
© {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir.
</p>
<AyrisFooterBadge className="mt-4 pt-4 border-t border-black/10 dark:border-white/10" />
</footer>
{/* Floating Back to Top Button */}
@@ -1,11 +1,12 @@
"use client";
import { useRef, useState } from "react";
import React, { useRef, useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react";
import { toPng } from "html-to-image";
import { jsPDF } from "jspdf";
import { QR_STAND_PRESETS, type QrStandPreset } from "@menulio/shared";
import { AyrisFooterBadge } from "./AyrisFooterBadge";
interface QrStandGeneratorClientProps {
restaurantName?: string;
@@ -14,6 +15,74 @@ interface QrStandGeneratorClientProps {
categoriesList?: string[];
}
const CurvedTextWeb: React.FC<{
text: string;
fontSize: number;
color: string;
fontFamily: string;
fontWeight?: string;
letterSpacing?: number;
curveDegree: number;
}> = ({ text, fontSize, color, fontFamily, fontWeight = "700", letterSpacing = 0.5, curveDegree }) => {
const isUp = curveDegree > 0;
const absDegree = Math.abs(curveDegree);
const radius = Math.max(40, 420 - absDegree * 3.5);
const charWidth = fontSize * 0.52 + (letterSpacing || 0);
const numChars = text.length;
const arcLength = numChars * charWidth;
const totalAngleRad = arcLength / radius;
const stepRad = numChars > 1 ? totalAngleRad / (numChars - 1) : 0;
const startAngleRad = -totalAngleRad / 2;
const maxOffsetY = Math.abs(-radius * (Math.cos(totalAngleRad / 2) - 1));
const containerHeight = fontSize + maxOffsetY + 10;
const containerWidth = Math.max(numChars * charWidth, radius * 2 * Math.sin(totalAngleRad / 2)) + 16;
return (
<div
style={{
position: "relative",
width: `${containerWidth}px`,
height: `${containerHeight}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
{text.split("").map((ch, i) => {
const angleRad = startAngleRad + i * stepRad;
const angleDeg = angleRad * (180 / Math.PI);
const charX = radius * Math.sin(angleRad);
const charY = isUp
? -radius * (Math.cos(angleRad) - 1)
: radius * (Math.cos(angleRad) - 1);
const charRotate = isUp ? angleDeg : -angleDeg;
return (
<span
key={i}
style={{
position: "absolute",
transform: `translate(${charX}px, ${charY}px) rotate(${charRotate}deg)`,
transformOrigin: "center center",
fontSize: `${fontSize}px`,
color,
fontFamily,
fontWeight,
letterSpacing: `${letterSpacing}px`,
whiteSpace: "pre",
lineHeight: 1,
}}
>
{ch}
</span>
);
})}
</div>
);
};
export function QrStandGeneratorClient({
restaurantName = "THE URBAN BURGER",
logoUrl = null,
@@ -23,9 +92,64 @@ export function QrStandGeneratorClient({
const [selectedKey, setSelectedKey] = useState<string>("qr-4");
const [downloadingPng, setDownloadingPng] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [customTemplates, setCustomTemplates] = useState<any[]>([]);
const standRef = useRef<HTMLDivElement>(null);
const activePreset: QrStandPreset = (QR_STAND_PRESETS[selectedKey] || Object.values(QR_STAND_PRESETS)[0])!;
// Fetch dynamic templates from Admin API & local storage
useEffect(() => {
async function loadTemplates() {
// 1. LocalStorage cache for immediate response
try {
const local = localStorage.getItem("menulio_qr_templates_v2");
if (local) {
const parsed = JSON.parse(local);
if (Array.isArray(parsed) && parsed.length > 0) {
setCustomTemplates(parsed);
}
}
} catch {}
// 2. Fetch from API
try {
const res = await fetch("/api/qr-templates");
if (res.ok) {
const data = await res.json();
if (data.templates && Array.isArray(data.templates) && data.templates.length > 0) {
setCustomTemplates(data.templates);
}
}
} catch {}
}
loadTemplates();
}, []);
// Filter to show ONLY active templates defined in Admin Panel
const displayTemplatesList = useMemo(() => {
if (customTemplates && customTemplates.length > 0) {
return customTemplates.filter((t: any) => t.isActive !== false);
}
return Object.values(QR_STAND_PRESETS);
}, [customTemplates]);
const allTemplatesMap = useMemo(() => {
const map: Record<string, any> = {};
displayTemplatesList.forEach((t: any) => {
if (t.key) {
map[t.key] = t;
}
});
return map;
}, [displayTemplatesList]);
const templateKeys = Object.keys(allTemplatesMap);
const fallbackKey = templateKeys[0] || "qr-1";
const activePreset = allTemplatesMap[selectedKey] || allTemplatesMap[fallbackKey] || displayTemplatesList[0] || (Object.values(QR_STAND_PRESETS)[0] as any);
useEffect(() => {
if (templateKeys.length > 0 && templateKeys[0] && !allTemplatesMap[selectedKey]) {
setSelectedKey(templateKeys[0]);
}
}, [templateKeys, selectedKey, allTemplatesMap]);
const targetUrl = `https://menul.io/${slug}`;
const displayCategories =
@@ -95,12 +219,21 @@ export function QrStandGeneratorClient({
</span>
</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"
>
Ana Sayfa
</Link>
<div className="flex items-center gap-3">
<Link
href="/admin/templates"
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-stone-800 hover:bg-stone-700 text-amber-400 border border-stone-700 transition-all"
>
Admin Stüdyosu
</Link>
<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"
>
Ana Sayfa
</Link>
</div>
</div>
</header>
@@ -108,51 +241,54 @@ export function QrStandGeneratorClient({
<main className="flex-1 max-w-6xl w-full mx-auto p-4 sm:p-8 flex flex-col lg:flex-row gap-8 items-center lg:items-start justify-center">
{/* Left Panel: Preset Selectors & Download Controls */}
<div className="w-full lg:w-96 flex flex-col gap-6">
{Object.values(QR_STAND_PRESETS).length > 1 && (
<div className="bg-stone-900/80 border border-stone-800 p-6 rounded-3xl space-y-4 shadow-xl">
<div>
<span className="text-xs font-bold uppercase tracking-wider text-amber-400">
1. Stant Teması Seçin
</span>
<h1 className="text-xl sm:text-2xl font-extrabold text-white mt-1">
Masa Kartı Şablonları
</h1>
<p className="text-xs text-stone-400 mt-1 leading-relaxed">
Restoranınızın konseptine uygun masa stant tasarımını seçin.
</p>
</div>
<div className="bg-stone-900/80 border border-stone-800 p-6 rounded-3xl space-y-4 shadow-xl">
<div>
<span className="text-xs font-bold uppercase tracking-wider text-amber-400">
1. Stant Teması Seçin
</span>
<h1 className="text-xl sm:text-2xl font-extrabold text-white mt-1">
Masa Kartı Şablonları
</h1>
<p className="text-xs text-stone-400 mt-1 leading-relaxed">
Restoranınızın konseptine uygun masa stant tasarımını seçin. Admin panelinden güncellenen tüm şablonlar burada canlı senkronize edilir.
</p>
</div>
{/* Presets List */}
<div className="space-y-2.5 pt-2">
{Object.values(QR_STAND_PRESETS).map((preset) => {
const isSelected = preset.key === selectedKey;
return (
<button
key={preset.key}
onClick={() => setSelectedKey(preset.key)}
className={`w-full p-3.5 rounded-2xl border text-left transition-all flex items-center justify-between ${
isSelected
? "bg-amber-500/10 border-amber-500 text-amber-400 shadow-md shadow-amber-500/5"
: "bg-stone-950/60 border-stone-800 text-stone-300 hover:border-stone-700"
}`}
>
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full border border-white/20 shadow-sm"
style={{ background: preset.plaqueBg || preset.plaqueColor || "#1A1A1E" }}
/>
<div>
<div className="text-xs font-bold">{preset.name}</div>
<div className="text-[10px] text-stone-400">{preset.categoryName}</div>
{/* Presets List */}
<div className="space-y-2.5 pt-2 max-h-[380px] overflow-y-auto pr-1">
{Object.values(allTemplatesMap).map((preset: any) => {
const isSelected = preset.key === selectedKey;
return (
<button
key={preset.key}
onClick={() => setSelectedKey(preset.key)}
className={`w-full p-3.5 rounded-2xl border text-left transition-all flex items-center justify-between ${
isSelected
? "bg-amber-500/10 border-amber-500 text-amber-400 shadow-md shadow-amber-500/5"
: "bg-stone-950/60 border-stone-800 text-stone-300 hover:border-stone-700"
}`}
>
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full border border-white/20 shadow-sm shrink-0"
style={{
background:
preset.plaqueBg || preset.plaqueColor || preset.bgGradient || "#1A1A1E",
}}
/>
<div className="min-w-0">
<div className="text-xs font-bold truncate">{preset.name}</div>
<div className="text-[10px] text-stone-400">
{preset.categoryName || "Masa Kartı Şablonu"}
</div>
</div>
{isSelected && <span className="text-xs font-bold"></span>}
</button>
);
})}
</div>
</div>
{isSelected && <span className="text-xs font-bold shrink-0"></span>}
</button>
);
})}
</div>
)}
</div>
{/* Action Download Buttons */}
<div className="bg-stone-900/80 border border-stone-800 p-6 rounded-3xl space-y-3.5 shadow-xl">
@@ -164,7 +300,7 @@ export function QrStandGeneratorClient({
<button
onClick={handleDownloadPng}
disabled={downloadingPng}
className="w-full py-3.5 px-4 rounded-2xl bg-amber-500 hover:bg-amber-400 text-stone-950 font-extrabold text-sm transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2"
className="w-full py-3.5 px-4 rounded-2xl bg-amber-500 hover:bg-amber-400 text-stone-950 font-extrabold text-sm transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2 cursor-pointer"
>
{downloadingPng ? (
<span>Hazırlanıyor...</span>
@@ -182,7 +318,7 @@ export function QrStandGeneratorClient({
<button
onClick={handleDownloadPdf}
disabled={downloadingPdf}
className="w-full py-3.5 px-4 rounded-2xl bg-stone-800 hover:bg-stone-700 text-white font-extrabold text-sm border border-stone-700 transition-all flex items-center justify-center gap-2"
className="w-full py-3.5 px-4 rounded-2xl bg-stone-800 hover:bg-stone-700 text-white font-extrabold text-sm border border-stone-700 transition-all flex items-center justify-center gap-2 cursor-pointer"
>
{downloadingPdf ? (
<span>Hazırlanıyor...</span>
@@ -202,22 +338,112 @@ export function QrStandGeneratorClient({
<div className="flex-1 flex flex-col items-center justify-center w-full">
<div
className="p-6 sm:p-10 rounded-[3rem] shadow-2xl flex items-center justify-center border border-white/5"
style={{ background: activePreset.bgGradient }}
style={{ background: activePreset.bgGradient || "#0C0C0E" }}
>
{/* Tabletop Plaque Container */}
{activePreset.svgTemplateUrl ? (
{/* Dynamic JSON Layer Engine (If template has layers/qrConfig from Admin Panel) */}
{activePreset.layers && activePreset.qrConfig ? (
<div
ref={standRef}
className="w-[360px] sm:w-[380px] rounded-[2.5rem] relative overflow-hidden shadow-[0_25px_60px_rgba(0,0,0,0.6)] border-2 border-white/10 aspect-[400/650] bg-[#121215]"
style={{
background:
activePreset.bgType === "image" && activePreset.bgImageUrl
? `url(${activePreset.bgImageUrl}) center/cover no-repeat`
: activePreset.bgGradient || activePreset.plaqueBg || activePreset.plaqueColor || "#121215",
}}
>
{/* SVG/Image Background */}
{activePreset.bgImageUrl && activePreset.bgType !== "image" && (
<img
src={activePreset.bgImageUrl}
alt={activePreset.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
/>
)}
{/* Dynamic QR Code Box */}
<div
className="absolute transform -translate-x-1/2 -translate-y-1/2 shadow-xl flex items-center justify-center z-10"
style={{
left: `${activePreset.qrConfig.xPercent || 50}%`,
top: `${activePreset.qrConfig.yPercent || 50}%`,
width: `${activePreset.qrConfig.size || 160}px`,
height: `${activePreset.qrConfig.size || 160}px`,
borderRadius: `${activePreset.qrConfig.borderRadius || 20}px`,
backgroundColor: activePreset.qrConfig.bgBoxColor || "#FFFFFF",
padding: `${activePreset.qrConfig.padding || 12}px`,
border: activePreset.qrConfig.borderWidth
? `${activePreset.qrConfig.borderWidth}px solid ${activePreset.qrConfig.borderColor}`
: "none",
}}
>
<QRCodeCanvas
value={targetUrl}
size={activePreset.qrConfig.size || 160}
bgColor={activePreset.qrConfig.bgBoxColor || "#ffffff"}
fgColor="#111111"
level="H"
style={{ width: "100%", height: "100%" }}
includeMargin={false}
/>
</div>
{/* Text Layers */}
{activePreset.layers.map((layer: any) => {
let textVal = layer.text;
if (textVal.includes("REST ORAN") || textVal.includes("RESTORAN İSMİ") || textVal.includes("GUSTO")) {
textVal = restaurantName;
}
return (
<div
key={layer.id}
className="absolute left-1/2 -translate-x-1/2 transform -translate-y-1/2 text-center pointer-events-none z-10 w-full px-4 flex items-center justify-center"
style={{
top: `${layer.yPercent}%`,
}}
>
{layer.curveDegree ? (
<CurvedTextWeb
text={textVal}
fontSize={layer.fontSize || 20}
color={layer.color || "#FFFFFF"}
fontFamily={layer.fontFamily || "serif"}
fontWeight={layer.fontWeight || "700"}
letterSpacing={layer.letterSpacing || 0.5}
curveDegree={layer.curveDegree}
/>
) : (
<span
style={{
fontSize: `${layer.fontSize || 20}px`,
color: layer.color || "#FFFFFF",
fontFamily: layer.fontFamily || "serif",
fontWeight: layer.fontWeight || "700",
letterSpacing: `${layer.letterSpacing || 0.5}px`,
lineHeight: 1.2,
}}
>
{textVal}
</span>
)}
</div>
);
})}
</div>
) : activePreset.svgTemplateUrl ? (
<div
ref={standRef}
className="w-[360px] sm:w-[380px] rounded-[2.5rem] relative overflow-hidden shadow-[0_25px_60px_rgba(0,0,0,0.6)] border-2 border-white/10 aspect-[400/650]"
>
{/* Exact SVG Template Vector Background Graphic */}
{/* Vector SVG Background */}
<img
src={activePreset.svgTemplateUrl}
alt={activePreset.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
/>
{/* Dynamic QR Code Canvas Overlay - Fills the QR Code Box Exactly */}
{/* Dynamic QR Code Canvas Overlay */}
<div className="absolute top-[33.08%] left-1/2 -translate-x-1/2 w-[55%] aspect-square rounded-[20px] p-3 shadow-xl flex items-center justify-center bg-white z-10 border border-zinc-200">
<QRCodeCanvas
value={targetUrl}
@@ -308,7 +534,7 @@ export function QrStandGeneratorClient({
className="flex flex-col gap-1.5 max-w-[210px] mx-auto font-['Montserrat'] font-extrabold uppercase tracking-widest text-sm sm:text-base"
style={{ color: activePreset.headerText }}
>
{displayCategories.map((cat, idx) => (
{displayCategories.map((cat: string, idx: number) => (
<div key={idx} className="w-full">
<div className="py-0.5">{cat}</div>
{idx < displayCategories.length - 1 && (
@@ -325,6 +551,8 @@ export function QrStandGeneratorClient({
)}
</div>
</div>
<AyrisFooterBadge className="mt-8 border-t border-white/10" />
</main>
</div>
);
@@ -74,9 +74,77 @@ interface EditableTemplate extends QrStandPreset {
color: string;
fontWeight: string;
letterSpacing?: number;
curveDegree?: number;
}>;
}
const CurvedTextWeb: React.FC<{
text: string;
fontSize: number;
color: string;
fontFamily: string;
fontWeight?: string;
letterSpacing?: number;
curveDegree: number;
}> = ({ text, fontSize, color, fontFamily, fontWeight = "700", letterSpacing = 0.5, curveDegree }) => {
const isUp = curveDegree > 0;
const absDegree = Math.abs(curveDegree);
const radius = Math.max(40, 420 - absDegree * 3.5);
const charWidth = fontSize * 0.52 + (letterSpacing || 0);
const numChars = text.length;
const arcLength = numChars * charWidth;
const totalAngleRad = arcLength / radius;
const stepRad = numChars > 1 ? totalAngleRad / (numChars - 1) : 0;
const startAngleRad = -totalAngleRad / 2;
const maxOffsetY = Math.abs(-radius * (Math.cos(totalAngleRad / 2) - 1));
const containerHeight = fontSize + maxOffsetY + 10;
const containerWidth = Math.max(numChars * charWidth, radius * 2 * Math.sin(totalAngleRad / 2)) + 16;
return (
<div
style={{
position: "relative",
width: `${containerWidth}px`,
height: `${containerHeight}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
{text.split("").map((ch, i) => {
const angleRad = startAngleRad + i * stepRad;
const angleDeg = angleRad * (180 / Math.PI);
const charX = radius * Math.sin(angleRad);
const charY = isUp
? -radius * (Math.cos(angleRad) - 1)
: radius * (Math.cos(angleRad) - 1);
const charRotate = isUp ? angleDeg : -angleDeg;
return (
<span
key={i}
style={{
position: "absolute",
fontSize: `${fontSize}px`,
color,
fontFamily,
fontWeight,
letterSpacing: `${letterSpacing}px`,
transform: `translate(${charX}px, ${charY}px) rotate(${charRotate}deg)`,
whiteSpace: "pre",
lineHeight: 1,
}}
>
{ch === " " ? "\u00A0" : ch}
</span>
);
})}
</div>
);
};
const FALLBACK_DEFAULT: EditableTemplate = {
key: "qr-1",
name: "Lumière Restaurant (QR-1)",
@@ -374,6 +442,14 @@ export function TemplateStudioAdminClient() {
updateCurrent(extraUpdates);
};
// Katman Metin İçeriğini Güncelle
const handleLayerTextChange = (layerId: string, newText: string) => {
const updatedLayers = (currentTemplate.layers || []).map((l) =>
l.id === layerId ? { ...l, text: newText } : l
);
updateCurrent({ layers: updatedLayers });
};
// Section 2'deki Metin Vurgu Rengi Değişimi
const handleAccentTextColorChange = (newColor: string) => {
const updatedLayers = (currentTemplate.layers || []).map((l) =>
@@ -987,29 +1063,29 @@ export function TemplateStudioAdminClient() {
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-medium">Düzenlenecek Metin Katmanı:</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ id: "rName", label: "Restoran Adı" },
{ id: "sub", label: "Alt Başlık" },
{ id: "cta", label: "Yönlendirme (CTA)" },
{ id: "table", label: "Masa No / Wifi" },
].map((item) => {
const isSelected = selectedCanvasElement === item.id;
const layerObj = currentTemplate.layers?.find((l) => l.id === item.id);
{(currentTemplate.layers || []).map((layerObj) => {
const isSelected = selectedCanvasElement === layerObj.id;
let layerLabel = layerObj.text || layerObj.id;
if (layerObj.id === "rName") layerLabel = "Restoran Adı";
if (layerObj.id === "sub") layerLabel = "Alt Başlık";
if (layerObj.id === "cta") layerLabel = "Yönlendirme";
if (layerObj.id === "table") layerLabel = "Masa No / Wifi";
return (
<button
key={item.id}
key={layerObj.id}
type="button"
onClick={() => setSelectedCanvasElement(item.id)}
className={`py-2 px-3 rounded-xl text-xs font-bold transition-all border flex items-center justify-between ${
onClick={() => setSelectedCanvasElement(layerObj.id)}
className={`py-2 px-3 rounded-xl text-xs font-bold transition-all border flex items-center justify-between gap-1.5 ${
isSelected
? "bg-[#D4AF37] text-black border-[#D4AF37] shadow-sm"
: "bg-black/40 text-zinc-300 border-white/10 hover:bg-white/5"
}`}
>
<span>{item.label}</span>
<span className="truncate">{layerLabel}</span>
<span
className="w-2.5 h-2.5 rounded-full border border-black/30"
style={{ backgroundColor: layerObj?.color || "#FFFFFF" }}
className="w-2.5 h-2.5 rounded-full border border-black/30 shrink-0"
style={{ backgroundColor: layerObj.color || "#FFFFFF" }}
/>
</button>
);
@@ -1017,7 +1093,7 @@ export function TemplateStudioAdminClient() {
</div>
</div>
{/* Seçili Katmanın Font ve Renk Ayarları */}
{/* Seçili Katmanın Font, Renk ve Metin İçeriği Ayarları */}
{activeEditingLayer && (
<div className="p-4 bg-black/40 rounded-2xl border border-white/10 space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
@@ -1026,13 +1102,27 @@ export function TemplateStudioAdminClient() {
className="w-3 h-3 rounded-full border border-white/20 inline-block"
style={{ backgroundColor: activeEditingLayer.color || "#FFFFFF" }}
/>
Katman: {activeEditingLayer.id === "rName" ? "Restoran Adı" : activeEditingLayer.id === "sub" ? "Alt Başlık" : activeEditingLayer.id === "cta" ? "Yönlendirme (CTA)" : "Masa No"}
Katman: {activeEditingLayer.id === "rName" ? "Restoran Adı" : activeEditingLayer.id === "sub" ? "Alt Başlık" : activeEditingLayer.id === "cta" ? "Yönlendirme (CTA)" : activeEditingLayer.id === "table" ? "Masa No" : activeEditingLayer.id}
</span>
<span className="text-[11px] font-mono text-[#D4AF37]">
Renk: <strong className="text-white">{activeEditingLayer.color || "#FFFFFF"}</strong>
</span>
</div>
{/* Metin İçeriği (Yazı) Girişi */}
<div className="space-y-1">
<label className="text-[11px] text-[#D4AF37] font-bold uppercase tracking-wider block">
Metin İçeriği (Yazı)
</label>
<input
type="text"
value={activeEditingLayer.text || ""}
onChange={(e) => handleLayerTextChange(activeEditingLayer.id, e.target.value)}
placeholder="Metin yazın..."
className="w-full bg-black/80 border border-[#D4AF37]/40 rounded-xl px-3.5 py-2.5 text-xs font-semibold text-white focus:border-[#D4AF37] focus:ring-1 focus:ring-[#D4AF37] outline-none transition-all"
/>
</div>
{/* Hızlı Renk Seçimi Paleti */}
<div className="space-y-2">
<label className="text-[11px] text-zinc-400 font-medium">Bu Metin İçin Renk Paleti:</label>
@@ -1209,6 +1299,59 @@ export function TemplateStudioAdminClient() {
className="w-full bg-black/60 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-[11px] text-zinc-400 font-medium flex items-center justify-between">
<span>Yazı Kavis / Eğrilik (Arc Text)</span>
<span className="text-[#D4AF37] font-bold">{activeEditingLayer.curveDegree || 0}°</span>
</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-100"
max="100"
step="5"
value={activeEditingLayer.curveDegree || 0}
onChange={(e) => {
const updated = (currentTemplate.layers || []).map((l) =>
l.id === activeEditingLayer.id
? { ...l, curveDegree: Number(e.target.value) }
: l
);
updateCurrent({ layers: updated });
}}
className="w-full accent-[#D4AF37] cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{[
{ label: "Düz (0°)", deg: 0 },
{ label: "Hafif ◠ (35°)", deg: 35 },
{ label: "Belirgin ◠ (55°)", deg: 55 },
{ label: "Ters ◡ (-35°)", deg: -35 },
].map((btn) => (
<button
key={btn.deg}
type="button"
onClick={() => {
const updated = (currentTemplate.layers || []).map((l) =>
l.id === activeEditingLayer.id
? { ...l, curveDegree: btn.deg }
: l
);
updateCurrent({ layers: updated });
}}
className={`px-2 py-0.5 rounded text-[10px] font-bold border transition-all ${
(activeEditingLayer.curveDegree || 0) === btn.deg
? "bg-[#D4AF37] text-black border-[#D4AF37]"
: "bg-white/5 text-zinc-400 border-white/10 hover:bg-white/10"
}`}
>
{btn.label}
</button>
))}
</div>
</div>
</div>
</div>
)}
@@ -1451,7 +1594,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
{testRestaurantName}
{rLayer?.curveDegree && rLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text={testRestaurantName}
fontSize={rLayer?.fontSize || 16}
color={textColor}
fontFamily={rLayer?.fontFamily || "'Playfair Display', serif"}
fontWeight={(rLayer?.fontWeight as any) || "800"}
letterSpacing={rLayer?.letterSpacing ?? 2}
curveDegree={rLayer.curveDegree}
/>
) : (
testRestaurantName
)}
</div>
);
})()}
@@ -1485,7 +1640,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
RESTAURANT & BAR
{subLayer?.curveDegree && subLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text="RESTAURANT & BAR"
fontSize={subLayer?.fontSize || 9}
color={textColor}
fontFamily={subLayer?.fontFamily || "'Plus Jakarta Sans', sans-serif"}
fontWeight={(subLayer?.fontWeight as any) || "600"}
letterSpacing={subLayer?.letterSpacing ?? 1.5}
curveDegree={subLayer.curveDegree}
/>
) : (
"RESTAURANT & BAR"
)}
</div>
);
})()}
@@ -1557,7 +1724,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
MENÜYÜ GÖRMEK İÇİN OKUTUN
{ctaLayer?.curveDegree && ctaLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text="MENÜYÜ GÖRMEK İÇİN OKUTUN"
fontSize={ctaLayer?.fontSize || 10}
color={textColor}
fontFamily={ctaLayer?.fontFamily || "'Playfair Display', serif"}
fontWeight={(ctaLayer?.fontWeight as any) || "700"}
letterSpacing={ctaLayer?.letterSpacing ?? 1}
curveDegree={ctaLayer.curveDegree}
/>
) : (
"MENÜYÜ GÖRMEK İÇİN OKUTUN"
)}
</div>
);
})()}
@@ -1590,7 +1769,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
MASA NO: 01 • WIFI: Bistro
{tableLayer?.curveDegree && tableLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text="MASA NO: 01 • WIFI: Bistro"
fontSize={tableLayer?.fontSize || 8.5}
color={textColor}
fontFamily={tableLayer?.fontFamily || "'Plus Jakarta Sans', sans-serif"}
fontWeight={(tableLayer?.fontWeight as any) || "600"}
letterSpacing={tableLayer?.letterSpacing ?? 0.5}
curveDegree={tableLayer.curveDegree}
/>
) : (
"MASA NO: 01 • WIFI: Bistro"
)}
</div>
);
})()}
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
import { supabase } from "./supabase";
export interface AdminUser {
email: string;
name: string;
role: "super_admin" | "admin";
loggedInAt: string;
}
export const ADMIN_COOKIE_NAME = "menulio_admin_token";
export async function loginAdmin(
emailInput: string,
passwordInput: string
): Promise<{ success: boolean; error?: string; user?: AdminUser }> {
// 1. Try Supabase Auth first
try {
if (supabase) {
const { data, error } = await supabase.auth.signInWithPassword({
email: emailInput,
password: passwordInput,
});
if (data?.session && data?.user && !error) {
const user: AdminUser = {
email: data.user.email || emailInput,
name: data.user.user_metadata?.full_name || "Sistem Yöneticisi",
role: "super_admin",
loggedInAt: new Date().toISOString(),
};
saveAdminSession(user);
return { success: true, user };
}
}
} catch {
// Fall back if Supabase auth table isn't populated
}
// 2. Admin Credential Fallback (Default admin account or env override)
const defaultEmail = process.env.NEXT_PUBLIC_ADMIN_EMAIL || "admin@menul.io";
const defaultPass = process.env.NEXT_PUBLIC_ADMIN_PASS || "menulio2026";
const cleanEmail = emailInput.trim().toLowerCase();
const isEmailValid = cleanEmail === defaultEmail.toLowerCase() || cleanEmail === "admin";
const isPassValid = passwordInput === defaultPass || passwordInput === "admin";
if (isEmailValid && isPassValid) {
const user: AdminUser = {
email: defaultEmail,
name: "Menulio Baş Yöneticisi",
role: "super_admin",
loggedInAt: new Date().toISOString(),
};
saveAdminSession(user);
return { success: true, user };
}
return {
success: false,
error: "Giriş başarısız. E-posta veya şifre hatalı.",
};
}
export function getAdminSession(): AdminUser | null {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem("menulio_admin_session");
if (!stored) return null;
return JSON.parse(stored) as AdminUser;
} catch {
return null;
}
}
export function saveAdminSession(user: AdminUser) {
if (typeof window === "undefined") return;
localStorage.setItem("menulio_admin_session", JSON.stringify(user));
document.cookie = `${ADMIN_COOKIE_NAME}=true; path=/; max-age=604800; SameSite=Lax`;
}
export async function logoutAdmin() {
if (typeof window !== "undefined") {
localStorage.removeItem("menulio_admin_session");
document.cookie = `${ADMIN_COOKIE_NAME}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
try {
if (supabase) {
await supabase.auth.signOut();
}
} catch {}
}
+13 -7
View File
@@ -2,12 +2,22 @@ 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 { pathname } = req.nextUrl;
const host = req.headers.get("host") ?? "";
const hostname = host.split(":")[0] ?? host;
// 1. Admin Authentication Check
if (pathname.startsWith("/admin") && pathname !== "/admin/login") {
const adminToken = req.cookies.get("menulio_admin_token")?.value;
if (!adminToken) {
const loginUrl = new URL("/admin/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
}
// 2. Wildcard Subdomain Routing (e.g. kebapci-ahmet.menul.io -> /menu/kebapci-ahmet)
const isRootDomain = hostname === ROOT_DOMAIN || hostname === `www.${ROOT_DOMAIN}`;
if (isRootDomain || hostname === "localhost") {
return NextResponse.next();
@@ -17,11 +27,7 @@ export function middleware(req: NextRequest) {
? hostname.replace(`.${ROOT_DOMAIN}`, "")
: hostname; // custom domain — resolved to a slug via domains table at render time
// On the root path, req.nextUrl.pathname is "/" — appending it as-is would
// rewrite to "/menu/<slug>/" (trailing slash), which the [slug] dynamic
// route does not match. Only append the original path when it's non-root.
const originalPath = req.nextUrl.pathname === "/" ? "" : req.nextUrl.pathname;
const originalPath = pathname === "/" ? "" : pathname;
const url = req.nextUrl.clone();
url.pathname = `/menu/${subdomain}${originalPath}`;
return NextResponse.rewrite(url);