feat: implement Tabletop QR Stand Generator with high-res PNG and print-ready A5 PDF export

This commit is contained in:
AyrisAI
2026-08-20 18:16:49 +03:00
parent 54a27903c8
commit d396cbb221
12 changed files with 1039 additions and 5 deletions
+3 -2
View File
@@ -3,8 +3,9 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext"
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["src"]
}
+35
View File
@@ -2,7 +2,9 @@ import { useEffect, useState } from "react";
import { router } from "expo-router";
import {
ActivityIndicator,
Alert,
Image,
Linking,
Pressable,
ScrollView,
Share,
@@ -87,6 +89,14 @@ export default function QrScreen() {
);
}
function handleOpenStandGenerator() {
if (!active?.slug) return;
const standUrl = `https://menul.io/qr/${active.slug}`;
Linking.openURL(standUrl).catch(() => {
Alert.alert("Bilgi", `Stant Jeneratörü adresi: ${standUrl}`);
});
}
return (
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
<View style={{ maxWidth: 440, width: "100%", alignSelf: "center", alignItems: "center" }}>
@@ -158,6 +168,31 @@ export default function QrScreen() {
{/* Action Buttons */}
<View style={{ width: "100%", gap: 12 }}>
{/* Stand Generator PDF / PNG Button */}
<Pressable
onPress={handleOpenStandGenerator}
style={({ pressed }) => ({
backgroundColor: "#C8A96B",
borderRadius: 14,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
opacity: pressed ? 0.9 : 1,
shadowColor: "#C8A96B",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 4,
})}
>
<Ionicons name="color-palette-outline" size={18} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "800" }}>
Masa Stant Tasarımları & İndir (PDF / PNG)
</Text>
</Pressable>
{/* Share Button */}
<Pressable
onPress={handleShare}
+3
View File
@@ -13,7 +13,10 @@
"@menulio/shared": "workspace:*",
"@supabase/supabase-js": "^2.45.4",
"@swc/helpers": "^0.5.15",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
"next": "^15.2.1",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { QrStandGeneratorClient } from "@/components/QrStandGeneratorClient";
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
return {
title: `${slug} | Masa Stant QR Kod Jeneratörü | Menulio`,
description: "Restoranınız için yüksek kalitede baskıya hazır A5 masa kartı ve QR stant tasarımları oluşturun.",
};
}
export default async function RestaurantQrPage({ params }: PageProps) {
const { slug } = await params;
if (slug === "demo" || slug === "gusto-brasserie") {
return (
<QrStandGeneratorClient
restaurantName={DEMO_RESTAURANT.name}
logoUrl={DEMO_RESTAURANT.logo_url}
slug={slug}
categoriesList={DEMO_CATEGORIES.map((c) => c.name)}
/>
);
}
const { data: restaurant } = await supabase
.from("restaurants")
.select("id, name, slug, logo_url")
.eq("slug", slug)
.maybeSingle();
if (!restaurant) {
notFound();
}
const { data: menu } = await supabase
.from("menus")
.select("id, locations!inner(restaurant_id)")
.eq("locations.restaurant_id", restaurant.id)
.maybeSingle();
let categoryNames: string[] = [];
if (menu) {
const { data: categories } = await supabase
.from("menu_categories")
.select("name")
.eq("menu_id", menu.id)
.eq("is_active", true)
.order("sort_order", { ascending: true });
if (categories) {
categoryNames = categories.map((c) => c.name);
}
}
return (
<QrStandGeneratorClient
restaurantName={restaurant.name}
logoUrl={restaurant.logo_url}
slug={restaurant.slug}
categoriesList={categoryNames}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { QrStandGeneratorClient } from "@/components/QrStandGeneratorClient";
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
export const metadata: Metadata = {
title: "Masa Stant QR Kod Tasarım Jeneratörü | Menulio",
description: "Restoranınız için 5 farklı lüks tema seçeneğiyle yüksek çözünürlüklü PNG ve A5 PDF masa kartı stant çıktıları alın.",
};
export default function QrStandPage() {
return (
<QrStandGeneratorClient
restaurantName={DEMO_RESTAURANT.name}
logoUrl={DEMO_RESTAURANT.logo_url}
slug="gusto-brasserie"
categoriesList={DEMO_CATEGORIES.map((c) => c.name)}
/>
);
}
@@ -0,0 +1,302 @@
"use client";
import { useRef, useState } 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";
interface QrStandGeneratorClientProps {
restaurantName?: string;
logoUrl?: string | null;
slug?: string;
categoriesList?: string[];
}
export function QrStandGeneratorClient({
restaurantName = "THE URBAN BURGER",
logoUrl = null,
slug = "kebapci-sinan-3",
categoriesList = [],
}: QrStandGeneratorClientProps) {
const [selectedKey, setSelectedKey] = useState<string>("urban");
const [downloadingPng, setDownloadingPng] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const standRef = useRef<HTMLDivElement>(null);
const activePreset: QrStandPreset = (QR_STAND_PRESETS[selectedKey] || QR_STAND_PRESETS.urban)!;
const targetUrl = `https://menul.io/${slug}`;
const displayCategories =
categoriesList.length > 0 ? categoriesList.slice(0, 4) : activePreset.categories;
async function handleDownloadPng() {
if (!standRef.current) return;
setDownloadingPng(true);
try {
const dataUrl = await toPng(standRef.current, {
cacheBust: true,
pixelRatio: 3, // 300 DPI high resolution
});
const link = document.createElement("a");
link.download = `${slug}-masa-stant-qr.png`;
link.href = dataUrl;
link.click();
} catch (err) {
console.error("PNG indirme hatası:", err);
alert("PNG indirilemedi. Lütfen tekrar deneyin.");
} finally {
setDownloadingPng(false);
}
}
async function handleDownloadPdf() {
if (!standRef.current) return;
setDownloadingPdf(true);
try {
const dataUrl = await toPng(standRef.current, {
cacheBust: true,
pixelRatio: 3,
});
// A5 dimensions in mm: 148 x 210
const pdf = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a5",
});
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
pdf.addImage(dataUrl, "PNG", 0, 0, pdfWidth, pdfHeight);
pdf.save(`${slug}-masa-stant-a5.pdf`);
} catch (err) {
console.error("PDF indirme hatası:", err);
alert("PDF indirilemedi. Lütfen tekrar deneyin.");
} finally {
setDownloadingPdf(false);
}
}
return (
<div className="min-h-screen bg-stone-950 text-stone-100 flex flex-col font-sans select-none">
{/* Top Navbar */}
<header className="border-b border-stone-800/80 bg-stone-900/90 backdrop-blur-xl sticky top-0 z-50 px-4 sm:px-6 py-3.5">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="font-black text-xl tracking-tight text-amber-400">
MENULIO
</Link>
<span className="text-stone-600 hidden sm:inline">/</span>
<span className="text-xs font-semibold text-stone-400 hidden sm:inline">
Masa Stant QR Jeneratörü
</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>
</header>
{/* Main Content Area */}
<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">
<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>
{/* 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 }}
/>
<div>
<div className="text-xs font-bold">{preset.name}</div>
<div className="text-[10px] text-stone-400">{preset.categoryName}</div>
</div>
</div>
{isSelected && <span className="text-xs font-bold"></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">
<span className="text-xs font-bold uppercase tracking-wider text-stone-400">
2. Baskı Çıktısı Alın
</span>
{/* PNG Download Button */}
<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"
>
{downloadingPng ? (
<span>Hazırlanıyor...</span>
) : (
<>
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
</svg>
<span>PNG Olarak İndir (300 DPI Ultra HD)</span>
</>
)}
</button>
{/* PDF Download Button */}
<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"
>
{downloadingPdf ? (
<span>Hazırlanıyor...</span>
) : (
<>
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zM4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6z" />
</svg>
<span>PDF Olarak İndir (A5 Matbaa Baskı)</span>
</>
)}
</button>
</div>
</div>
{/* Right Panel: Stand Live Visual Canvas Preview */}
<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 }}
>
{/* Tabletop Plaque Container */}
<div
ref={standRef}
className="w-[360px] sm:w-[380px] min-h-[660px] rounded-[2.5rem] p-7 flex flex-col items-center text-center relative shadow-[0_25px_60px_rgba(0,0,0,0.6)] border-2"
style={{
background: activePreset.plaqueBg,
borderColor: activePreset.borderAccent,
}}
>
{/* Inner Line Accent */}
<div
className="absolute inset-2.5 rounded-[2rem] border pointer-events-none"
style={{ borderColor: activePreset.borderAccent }}
/>
{/* 01. Brand Badge */}
<header className="flex flex-col items-center text-center mt-2 z-10">
<div
className="border-2 rounded-xl px-4 py-2 flex flex-col items-center mb-4 shadow-md"
style={{
borderColor: activePreset.logoBorder,
backgroundColor: activePreset.logoBg,
}}
>
{logoUrl ? (
<img
src={logoUrl}
alt={restaurantName}
className="h-10 w-auto max-w-[140px] object-contain mb-1"
/>
) : null}
<span
className="font-black text-xl tracking-wider uppercase font-['Montserrat'] leading-tight"
style={{ color: activePreset.accentText }}
>
{restaurantName}
</span>
</div>
{/* Scan Action Text */}
<h2
className="font-['Montserrat'] font-black text-2xl sm:text-3xl tracking-tight uppercase leading-[0.95] drop-shadow-sm"
style={{ color: activePreset.headerText }}
>
DİJİTAL MENÜ<br />
<span className="tracking-wide">KAMERANIZLA TARATIN</span>
</h2>
</header>
{/* 02. Centered Custom QR Code Box */}
<section
className="w-60 h-60 sm:w-64 sm:h-64 rounded-3xl p-5 shadow-2xl flex items-center justify-center my-6 relative border border-black/15 z-10"
style={{ backgroundColor: activePreset.qrBg }}
>
<QRCodeCanvas
value={targetUrl}
size={200}
bgColor={activePreset.qrBg}
fgColor={activePreset.qrColor}
level="H"
includeMargin={false}
/>
</section>
{/* 03. Category Highlights Footer */}
<footer className="w-full relative px-4 text-center z-10 pb-2 flex-1 flex flex-col justify-end">
<p
className="text-xs tracking-wide mb-3 font-semibold"
style={{ color: activePreset.subText }}
>
Telefonunuzun kamerasını doğrultun
</p>
{/* Category Text Stack */}
<div
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) => (
<div key={idx} className="w-full">
<div className="py-0.5">{cat}</div>
{idx < displayCategories.length - 1 && (
<div
className="w-full h-[1px] my-0.5"
style={{ backgroundColor: activePreset.dividerColor }}
/>
)}
</div>
))}
</div>
</footer>
</div>
</div>
</div>
</main>
</div>
);
}