feat(qr): complete QR studio, dynamic google fonts, mouse canvas drag, and template sync

This commit is contained in:
AyrisAI
2026-08-22 16:00:30 +03:00
parent c1569c0879
commit e7ba6c7807
34 changed files with 5689 additions and 1251 deletions
+147
View File
@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import React from "react";
export const metadata: Metadata = {
title: "Menulio Admin Panel | Şablon & Sistem Yönetimi",
description: "Menulio QR Şablon Stüdyosu, Masa Standı Tasarım Motoru ve Sistem Yönetimi",
};
export default function AdminLayout({
children,
}: {
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>
);
}
+158
View File
@@ -0,0 +1,158 @@
import Link from "next/link";
import { QR_STAND_PRESETS } from "@menulio/shared";
export default function AdminDashboardPage() {
const presetCount = Object.keys(QR_STAND_PRESETS).length;
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Karşılama Başlığı */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
Menulio Sistem & Şablon Yönetimi
</h1>
<p className="text-zinc-400 text-sm mt-1">
Masa standı şablonlarını, JSON şablon motorunu ve restoran görünüm ayarlarını yönetin.
</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>
{/* İstatistik Kartları */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Aktif Şablonlar
</div>
<div className="text-3xl font-black text-white">{presetCount} Adet</div>
<div className="text-xs text-emerald-400 font-medium flex items-center gap-1">
<span></span> Mobil & Web Canlıda
</div>
</div>
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Şablon Motoru Tipi
</div>
<div className="text-2xl font-black text-[#D4AF37]">JSON Driven</div>
<div className="text-xs text-zinc-400 font-medium">
0 App Store güncellemesi
</div>
</div>
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Baskı Çıktı Formatı
</div>
<div className="text-3xl font-black text-white">A5 & 300DPI</div>
<div className="text-xs text-zinc-400 font-medium">
PDF + Vektörel PNG
</div>
</div>
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
API Durumu
</div>
<div className="text-2xl font-black text-emerald-400">Aktif & Senkron</div>
<div className="text-xs text-zinc-400 font-medium">
REST API v1
</div>
</div>
</div>
{/* Nasıl Çalışır / Mimari Rehber Kutusu */}
<div className="p-6 rounded-2xl bg-gradient-to-br from-[#18181B] to-[#121215] border border-[#D4AF37]/20 relative overflow-hidden">
<div className="relative z-10 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#D4AF37]/15 border border-[#D4AF37]/30 text-xs font-bold text-[#D4AF37]">
💡 DİNAMİK ŞABLON MİMARİSİ
</div>
<h2 className="text-xl font-bold text-white">
Mobil Uygulamayı Güncellemeden Yeni Şablon Yayınlama
</h2>
<p className="text-sm text-zinc-300 leading-relaxed max-w-3xl">
Menulio, QR masa kartı şablonlarını doğrudan kod içine gömmek yerine <strong>JSON şablon spesifikasyonu</strong> ile çalışır.
Admin panelinden yeni bir SVG/Görsel arka plan ve varsayılan QR/metin koordinatları tanımladığınızda,
tüm mobil kullanıcılar uygulamayı güncellemek zorunda kalmadan yeni şablonu anında görürler.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="p-4 rounded-xl bg-black/40 border border-white/5 space-y-1.5">
<div className="text-xs font-bold text-[#D4AF37]">1. Arka Planı Belirle</div>
<div className="text-xs text-zinc-400">
Figma veya Illustrator'dan SVG/PNG arka planı oluşturup yükleyin veya renk seçin.
</div>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-white/5 space-y-1.5">
<div className="text-xs font-bold text-[#D4AF37]">2. QR & Katmanları Ayarla</div>
<div className="text-xs text-zinc-400">
QR kodun yerini, boyutunu, restoran ismi ve slogan metinlerinin varsayılan yerlerini belirleyin.
</div>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-white/5 space-y-1.5">
<div className="text-xs font-bold text-[#D4AF37]">3. Tek Tıkla Yayınla</div>
<div className="text-xs text-zinc-400">
Kaydet butonuna bastığınız anda API üzerinden tüm restoranların mobil uygulamasına dağıtılır.
</div>
</div>
</div>
</div>
</div>
{/* Hızlı Şablonlar Listesi */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-white">Mevcut Hazır Şablonlar</h3>
<Link
href="/admin/templates"
className="text-xs font-semibold text-[#D4AF37] hover:underline"
>
Tümünü Yönet & Düzenle &rarr;
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.values(QR_STAND_PRESETS).slice(0, 4).map((preset) => (
<div
key={preset.key}
className="p-4 rounded-xl bg-[#121215] border border-white/10 space-y-3 flex flex-col justify-between"
>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-mono text-zinc-500">{preset.key}</span>
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-full font-bold">
Aktif
</span>
</div>
<div className="font-bold text-white text-sm line-clamp-1">{preset.name}</div>
<div className="text-xs text-zinc-400 mt-0.5">{preset.categoryName || "Masa Kartı"}</div>
</div>
<div className="pt-3 border-t border-white/5 flex items-center justify-between text-xs text-zinc-400">
<span>Oran: {preset.aspectRatio || "1/1"}</span>
<Link
href={`/admin/templates?key=${preset.key}`}
className="font-bold text-[#D4AF37] hover:underline"
>
Düzenle
</Link>
</div>
</div>
))}
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { TemplateStudioAdminClient } from "@/components/TemplateStudioAdminClient";
export const metadata: Metadata = {
title: "QR Şablon Stüdyosu | Menulio Admin",
description: "Masa standı şablonlarını görsel editör veya JSON ile tasarlayın, canlı önizleyin ve anında yayınlayın.",
};
export default function AdminTemplatesPage() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 border-b border-white/10 pb-5">
<div>
<h1 className="text-2xl font-extrabold text-white tracking-tight">
QR Masa Standı Şablon Stüdyosu
</h1>
<p className="text-zinc-400 text-xs mt-1">
Uygulama güncellemesi gerektirmeden yeni şablonlar ekleyin, renkleri ve QR koordinatlarını düzenleyin.
</p>
</div>
</div>
<TemplateStudioAdminClient />
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { supabase } from "@/lib/supabase";
const DATA_FILE = path.join(process.cwd(), "src/data/qr-templates.json");
function mapFromDb(row: any) {
return {
key: row.key,
name: row.name,
categoryName: row.category_name,
aspectRatio: row.aspect_ratio || "1/1",
bgType: row.bg_type || "gradient",
bgImageUrl: row.bg_image_url || undefined,
bgGradient: row.bg_gradient || undefined,
plaqueColor: row.plaque_color || undefined,
borderAccent: row.border_accent || undefined,
accentText: row.accent_text || undefined,
headerText: row.header_text || undefined,
subText: row.sub_text || undefined,
dividerColor: row.divider_color || undefined,
qrConfig: row.qr_config,
layers: row.layers,
isActive: row.is_active ?? true,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapToDb(item: any) {
return {
key: item.key,
name: item.name,
category_name: item.categoryName || null,
aspect_ratio: item.aspectRatio || "1/1",
bg_type: item.bgType || "gradient",
bg_image_url: item.bgImageUrl || null,
bg_gradient: item.bgGradient || null,
plaque_color: item.plaqueColor || null,
border_accent: item.borderAccent || null,
accent_text: item.accentText || null,
header_text: item.headerText || null,
sub_text: item.subText || null,
divider_color: item.dividerColor || null,
qr_config: item.qrConfig || item.defaultQrConfig || null,
layers: item.layers || item.defaultLayers || null,
is_active: item.isActive !== undefined ? item.isActive : true,
updated_at: new Date().toISOString(),
};
}
async function readFromDisk(): Promise<any[]> {
try {
const raw = await fs.readFile(DATA_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return [];
}
}
async function writeToDisk(list: any[]): Promise<void> {
try {
await fs.writeFile(DATA_FILE, JSON.stringify(list, null, 2), "utf-8");
} catch {}
}
export async function GET() {
try {
const { data, error } = await supabase
.from("qr_stand_templates")
.select("*")
.order("created_at", { ascending: false });
if (!error && data && data.length > 0) {
const mapped = data.map(mapFromDb);
await writeToDisk(mapped);
return NextResponse.json({ templates: mapped, source: "supabase_db" });
}
} catch {}
const diskTemplates = await readFromDisk();
return NextResponse.json({ templates: diskTemplates, source: "disk_cache" });
}
export async function POST(req: Request) {
try {
const body = await req.json();
if (!body || !body.key) {
return NextResponse.json({ error: "Şablon key zorunludur" }, { status: 400 });
}
// 1. Supabase'e Upsert et
try {
const dbRow = mapToDb(body);
await supabase.from("qr_stand_templates").upsert(dbRow, { onConflict: "key" });
} catch (dbErr) {
console.warn("Supabase upsert warning:", dbErr);
}
// 2. Diske de yaz (offline / backup)
const diskList = await readFromDisk();
const existingIdx = diskList.findIndex((t: any) => t.key === body.key);
if (existingIdx >= 0) {
diskList[existingIdx] = { ...diskList[existingIdx], ...body };
} else {
diskList.unshift(body);
}
await writeToDisk(diskList);
return NextResponse.json({ success: true, template: body });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const { searchParams } = new URL(req.url);
const key = searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "Şablon key zorunludur" }, { status: 400 });
}
// 1. Supabase'den Sil
try {
await supabase.from("qr_stand_templates").delete().eq("key", key);
} catch (dbErr) {
console.warn("Supabase delete warning:", dbErr);
}
// 2. Diskten de Sil
const diskList = await readFromDisk();
const filtered = diskList.filter((t: any) => t.key !== key);
await writeToDisk(filtered);
return NextResponse.json({ success: true, deletedKey: key });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long