feat: implement Tabletop QR Stand Generator with high-res PNG and print-ready A5 PDF export
This commit is contained in:
@@ -3,8 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user