feat(mobile): render exact SVG vector templates in mobile QR stand screen

This commit is contained in:
AyrisAI
2026-08-20 23:43:26 +03:00
parent f768b92873
commit 01c9f5f195
5 changed files with 230 additions and 390 deletions
+128 -302
View File
@@ -15,9 +15,11 @@ import * as Clipboard from "expo-clipboard";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as Print from "expo-print";
import { SvgXml } from "react-native-svg";
import { QR_STAND_PRESETS, type QrStandPreset } from "@menulio/shared";
import { api } from "@/lib/api";
import { getActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant";
import { MOBILE_SVG_TEMPLATES } from "@/lib/svg-templates";
interface QrResponse {
id: string;
@@ -28,13 +30,12 @@ interface QrResponse {
export default function QrScreen() {
const [active, setActive] = useState<ActiveRestaurant | null>(null);
const [restaurantName, setRestaurantName] = useState<string>("THE URBAN BURGER");
const [restaurantName, setRestaurantName] = useState<string>("Gourmet Bistro");
const [qr, setQr] = useState<QrResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [selectedKey, setSelectedKey] = useState<string>("qr-4");
const [categories, setCategories] = useState<string[]>([]);
const [exportingPng, setExportingPng] = useState(false);
const [exportingPdf, setExportingPdf] = useState(false);
@@ -42,6 +43,7 @@ export default function QrScreen() {
const viewShotRef = useRef<any>(null);
const activePreset: QrStandPreset = (QR_STAND_PRESETS[selectedKey] || Object.values(QR_STAND_PRESETS)[0])!;
const currentSvgRaw = MOBILE_SVG_TEMPLATES[selectedKey] || MOBILE_SVG_TEMPLATES["qr-4"] || Object.values(MOBILE_SVG_TEMPLATES)[0] || "";
useEffect(() => {
(async () => {
@@ -62,18 +64,6 @@ export default function QrScreen() {
} catch {
// fallback
}
// Fetch categories for preview
if (activeRest.menuId) {
try {
const menuRes = await api.get<{ categories: { name: string }[] }>(`/menus/${activeRest.menuId}`);
if (menuRes?.categories?.length) {
setCategories(menuRes.categories.map((c) => c.name));
}
} catch {
// ignore fallback
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "QR oluşturulamadı");
} finally {
@@ -82,9 +72,6 @@ export default function QrScreen() {
})();
}, []);
const displayCategories =
categories.length > 0 ? categories.slice(0, 4) : (activePreset.categories || []);
async function handleCopy() {
if (!qr?.targetUrl) return;
await Clipboard.setStringAsync(qr.targetUrl);
@@ -120,21 +107,19 @@ export default function QrScreen() {
}
async function handleExportPdf() {
if (!qr || !active) return;
if (!qr || !active || !currentSvgRaw) return;
setExportingPdf(true);
try {
const restTitle = restaurantName.toUpperCase();
const qrImageUri = `data:image/png;base64,${qr.pngBase64}`;
const catsHtml = displayCategories
.map(
(c, i) =>
`<div style="padding: 5px 0;">${c}</div>${
i < displayCategories.length - 1
? '<div style="height: 1px; background: ' + activePreset.dividerColor + '; margin: 2px 0;"></div>'
: ""
}`,
)
.join("");
// Inject QR code directly into the SVG for print-ready A5 PDF
const svgWithQr = currentSvgRaw.replace(
/<g id="qr-placeholder"[^>]*>[\s\S]*?<\/g>/,
`<g id="qr-placeholder" transform="translate(90, 215)">
<rect width="220" height="220" rx="20" fill="#FFFFFF" stroke="#E4E4E7" stroke-width="2"/>
<image href="${qrImageUri}" x="10" y="10" width="200" height="200" preserveAspectRatio="xMidYMid meet" />
</g>`
);
const htmlContent = `
<!DOCTYPE html>
@@ -143,69 +128,30 @@ export default function QrScreen() {
<meta charset="utf-8"/>
<style>
@page { size: A5 portrait; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 0; width: 148mm; height: 210mm;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: #1c3e2b;
display: flex; align-items: center; justify-content: center;
background: #000000;
-webkit-print-color-adjust: exact;
}
.plaque {
width: 125mm; height: 185mm;
background: ${activePreset.plaqueColor};
border-radius: 32px;
padding: 24px; box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; text-align: center;
color: ${activePreset.headerText};
position: relative;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
.svg-wrap {
width: 140mm;
height: 200mm;
display: flex;
align-items: center;
justify-content: center;
}
.inner-border {
position: absolute; top: 10px; left: 10px; right: 10px; bottom: 10px;
border: 1px solid ${activePreset.borderAccent};
border-radius: 24px; pointer-events: none;
svg {
width: 100%;
height: 100%;
object-fit: contain;
}
.badge {
border: 2px solid ${activePreset.logoBorder};
background: ${activePreset.logoBg};
color: ${activePreset.accentText};
padding: 6px 18px; border-radius: 12px;
font-weight: 900; font-size: 16px; letter-spacing: 2px;
margin-bottom: 14px; display: inline-flex; align-items: center; gap: 6px;
}
.title {
font-size: 22px; font-weight: 900; letter-spacing: 1px;
line-height: 1.1; margin-bottom: 18px; color: #ffffff;
}
.qr-box {
background: ${activePreset.qrBg};
padding: 16px; border-radius: 24px;
display: inline-block; margin-bottom: 14px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
}
.qr-img { width: 160px; height: 160px; display: block; border-radius: 8px; }
.sub { font-size: 12px; opacity: 0.9; margin-bottom: 16px; color: ${activePreset.subText}; font-weight: 600; }
.footer-wrap { width: 100%; position: relative; }
.icon-left { position: absolute; left: 10px; bottom: 15px; color: ${activePreset.accentText}; font-size: 28px; }
.icon-right { position: absolute; right: 10px; bottom: 15px; color: ${activePreset.accentText}; font-size: 28px; }
.cats { font-size: 14px; font-weight: 900; letter-spacing: 2px; width: 75%; margin: 0 auto; color: #ffffff; }
</style>
</head>
<body>
<div class="plaque">
<div class="inner-border"></div>
<div class="badge">🔥 ${restTitle}</div>
<div class="title">DIGITAL MENU<br><span style="letter-spacing:2px;">SCAN TO ORDER</span></div>
<div class="qr-box">
<img class="qr-img" src="${qrImageUri}" />
</div>
<div class="sub">Use your phone's camera</div>
<div class="footer-wrap">
<div class="icon-left">🍔</div>
<div class="icon-right">🥤</div>
<div class="cats">${catsHtml}</div>
</div>
<div class="svg-wrap">
${svgWithQr}
</div>
</body>
</html>
@@ -255,13 +201,13 @@ export default function QrScreen() {
}
return (
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", paddingVertical: 20 }}>
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#0C0C0E", paddingVertical: 20 }}>
<View style={{ width: "100%", maxWidth: 440, alignSelf: "center", paddingHorizontal: 20 }}>
{/* Header Badge */}
<View
style={{
alignSelf: "center",
backgroundColor: "#F3EDE2",
backgroundColor: "rgba(212, 175, 55, 0.15)",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 99,
@@ -269,22 +215,24 @@ export default function QrScreen() {
flexDirection: "row",
alignItems: "center",
gap: 6,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.3)",
}}
>
<Ionicons name="qr-code-outline" size={15} color="#926E27" />
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
<Ionicons name="qr-code-outline" size={15} color="#D4AF37" />
<Text style={{ color: "#D4AF37", fontSize: 12, fontWeight: "700" }}>
MASA QR STANT JENERATÖRÜ
</Text>
</View>
<Text style={{ fontSize: 24, fontWeight: "800", color: "#1C1917", marginBottom: 4, textAlign: "center" }}>
<Text style={{ fontSize: 24, fontWeight: "800", color: "#FFFFFF", marginBottom: 4, textAlign: "center" }}>
Masa Stant Tasarımları
</Text>
<Text style={{ fontSize: 13, color: "#78716C", textAlign: "center", marginBottom: 20, lineHeight: 18 }}>
<Text style={{ fontSize: 13, color: "#A1A1AA", textAlign: "center", marginBottom: 20, lineHeight: 18 }}>
Şablon seçin; QR kodunuz otomatik yerleşsin. Baskıya hazır A5 PDF veya PNG olarak indirin.
</Text>
{/* Horizontal Theme Selector (rendered only if multiple themes exist) */}
{/* Horizontal Theme Selector */}
{Object.values(QR_STAND_PRESETS).length > 1 && (
<ScrollView
horizontal
@@ -298,9 +246,9 @@ export default function QrScreen() {
key={preset.key}
onPress={() => setSelectedKey(preset.key)}
style={{
backgroundColor: isSelected ? "#1C1917" : "#FFFFFF",
backgroundColor: isSelected ? "#D4AF37" : "#1A1A1E",
borderWidth: 1.5,
borderColor: isSelected ? "#1C1917" : "#E7E5E4",
borderColor: isSelected ? "#D4AF37" : "#2B2B32",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 10,
@@ -314,7 +262,7 @@ export default function QrScreen() {
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: preset.plaqueColor,
backgroundColor: preset.plaqueColor || "#1A1A1E",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.4)",
}}
@@ -323,7 +271,7 @@ export default function QrScreen() {
style={{
fontSize: 13,
fontWeight: "700",
color: isSelected ? "#FFFFFF" : "#1C1917",
color: isSelected ? "#0C0C0E" : "#FFFFFF",
}}
>
{preset.name}
@@ -339,173 +287,61 @@ export default function QrScreen() {
style={{
alignItems: "center",
justifyContent: "center",
padding: 18,
padding: 16,
borderRadius: 28,
backgroundColor: "#1c3e2b", // Ambient forest green tabletop environment backdrop
backgroundColor: "#18181B",
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.35,
shadowOpacity: 0.5,
shadowRadius: 20,
elevation: 8,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
}}
>
<ViewShot ref={viewShotRef} options={{ format: "png", quality: 1.0 }}>
<View
style={{
width: 300,
minHeight: 520,
borderRadius: 36,
padding: 22,
alignItems: "center",
backgroundColor: activePreset.plaqueColor,
borderWidth: 2,
borderColor: "rgba(0,0,0,0.25)",
height: 487.5,
borderRadius: 24,
overflow: "hidden",
position: "relative",
shadowColor: "#000",
shadowOffset: { width: 0, height: 15 },
shadowOpacity: 0.4,
shadowRadius: 30,
backgroundColor: "#0C0C0E",
}}
>
{/* Inner Border Accent Line */}
{/* Exact Vector SVG Artwork */}
{currentSvgRaw ? (
<SvgXml xml={currentSvgRaw} width={300} height={487.5} />
) : null}
{/* Centered QR Code Box Overlay */}
<View
style={{
position: "absolute",
top: 10,
left: 10,
right: 10,
bottom: 10,
borderRadius: 28,
borderWidth: 1,
borderColor: activePreset.borderAccent,
pointerEvents: "none",
}}
/>
{/* Top Restaurant Badge with Flame Icon */}
<View
style={{
borderWidth: 2,
borderColor: activePreset.logoBorder,
backgroundColor: activePreset.logoBg,
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 12,
marginBottom: 16,
top: 161.25, // (215 / 650) * 487.5
left: 67.5, // (90 / 400) * 300
width: 165, // (220 / 400) * 300
height: 165,
borderRadius: 15,
backgroundColor: "#FFFFFF",
padding: 8,
alignItems: "center",
flexDirection: "row",
gap: 6,
}}
>
<Text
style={{
color: activePreset.accentText,
fontSize: 16,
fontWeight: "900",
letterSpacing: 1.5,
textAlign: "center",
}}
>
{restaurantName.toUpperCase()}
</Text>
<Ionicons name="flame" size={16} color={activePreset.accentText} />
</View>
{/* Title Callout */}
<Text
style={{
color: activePreset.headerText,
fontSize: 22,
fontWeight: "900",
textAlign: "center",
lineHeight: 25,
marginBottom: 18,
letterSpacing: 0.5,
}}
>
DIGITAL MENU{"\n"}
<Text style={{ letterSpacing: 1.5 }}>SCAN TO ORDER</Text>
</Text>
{/* Auto Placed QR Code Container */}
<View
style={{
backgroundColor: activePreset.qrBg,
padding: 14,
borderRadius: 24,
marginBottom: 14,
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 5,
borderWidth: 1,
borderColor: "rgba(0,0,0,0.1)",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
}}
>
<Image
source={{ uri: `data:image/png;base64,${qr.pngBase64}` }}
style={{ width: 175, height: 175, borderRadius: 10 }}
style={{ width: "100%", height: "100%", borderRadius: 8 }}
resizeMode="contain"
/>
</View>
{/* Instructional Subtext */}
<Text
style={{
color: activePreset.subText,
fontSize: 12,
fontWeight: "600",
marginBottom: 16,
textAlign: "center",
}}
>
Use your phone's camera
</Text>
{/* Category Quick Preview List Flanked by Icons */}
<View style={{ width: "100%", position: "relative", alignItems: "center" }}>
{/* Floating Left Line Icon (Burger) */}
<View style={{ position: "absolute", left: 0, bottom: 8, opacity: 0.9 }}>
<Ionicons name="fast-food-outline" size={26} color={activePreset.accentText} />
</View>
{/* Floating Right Line Icon (Drink) */}
<View style={{ position: "absolute", right: 0, bottom: 8, opacity: 0.9 }}>
<Ionicons name="wine-outline" size={26} color={activePreset.accentText} />
</View>
{/* Center Category Stack */}
<View style={{ width: "68%", alignItems: "center", gap: 2 }}>
{displayCategories.map((cat, idx) => (
<View key={idx} style={{ width: "100%", alignItems: "center" }}>
<Text
style={{
color: activePreset.headerText,
fontSize: 13,
fontWeight: "900",
letterSpacing: 2,
textTransform: "uppercase",
paddingVertical: 2,
}}
>
{cat}
</Text>
{idx < displayCategories.length - 1 && (
<View
style={{
width: "100%",
height: 1,
backgroundColor: activePreset.dividerColor,
marginVertical: 2,
}}
/>
)}
</View>
))}
</View>
</View>
</View>
</ViewShot>
</View>
@@ -517,7 +353,7 @@ export default function QrScreen() {
onPress={handleExportPdf}
disabled={exportingPdf}
style={({ pressed }) => ({
backgroundColor: "#C8A96B",
backgroundColor: "#D4AF37",
borderRadius: 14,
padding: 16,
flexDirection: "row",
@@ -525,33 +361,35 @@ export default function QrScreen() {
justifyContent: "center",
gap: 8,
opacity: pressed || exportingPdf ? 0.85 : 1,
shadowColor: "#C8A96B",
shadowColor: "#D4AF37",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 4,
shadowRadius: 8,
elevation: 3,
})}
>
{exportingPdf ? (
<ActivityIndicator color="#FFFFFF" />
<ActivityIndicator size="small" color="#0C0C0E" />
) : (
<>
<Ionicons name="document-text-outline" size={18} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "800" }}>
A5 Baskı PDF Dosyası İndir / Paylaş
<Ionicons name="document-text-outline" size={20} color="#0C0C0E" />
<Text style={{ color: "#0C0C0E", fontSize: 15, fontWeight: "800" }}>
PDF Olarak İndir (A5 Matbaa Baskı)
</Text>
</>
)}
</Pressable>
{/* PNG Export Button */}
{/* PNG Download Button */}
<Pressable
onPress={handleExportPng}
disabled={exportingPng}
style={({ pressed }) => ({
backgroundColor: "#1C1917",
backgroundColor: "#1A1A1E",
borderWidth: 1.5,
borderColor: "#2B2B32",
borderRadius: 14,
padding: 15,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -560,76 +398,64 @@ export default function QrScreen() {
})}
>
{exportingPng ? (
<ActivityIndicator color="#FFFFFF" />
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="image-outline" size={18} color="#FFFFFF" />
<Ionicons name="image-outline" size={20} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
PNG Görsel Olarak Kaydet / Paylaş
PNG Olarak Kaydet / Paylaş
</Text>
</>
)}
</Pressable>
{/* Secondary Quick Share & Copy */}
<View style={{ flexDirection: "row", gap: 10, marginTop: 4 }}>
<Pressable
onPress={handleShareQuick}
style={({ pressed }) => ({
flex: 1,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#E7E5E4",
borderRadius: 12,
padding: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="share-social-outline" size={16} color="#1C1917" />
<Text style={{ color: "#1C1917", fontSize: 13, fontWeight: "600" }}>Linki Paylaş</Text>
</Pressable>
<Pressable
onPress={handleCopy}
style={({ pressed }) => ({
flex: 1,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#E7E5E4",
borderRadius: 12,
padding: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons
name={copied ? "checkmark-circle-outline" : "copy-outline"}
size={16}
color={copied ? "#10B981" : "#1C1917"}
/>
<Text style={{ color: copied ? "#10B981" : "#1C1917", fontSize: 13, fontWeight: "600" }}>
{copied ? "Kopyalandı!" : "Linki Kopyala"}
</Text>
</Pressable>
</View>
{/* Back button */}
{/* Direct Link Share Button */}
<Pressable
onPress={() => router.back()}
style={{ padding: 12, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 6 }}
onPress={handleShareQuick}
style={({ pressed }) => ({
backgroundColor: "#1A1A1E",
borderWidth: 1,
borderColor: "#2B2B32",
borderRadius: 14,
padding: 14,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="arrow-back" size={16} color="#78716C" />
<Text style={{ color: "#78716C", fontSize: 14, fontWeight: "600" }}>
Menü Editörüne Dön
<Ionicons name="share-outline" size={18} color="#D4AF37" />
<Text style={{ color: "#D4AF37", fontSize: 14, fontWeight: "700" }}>
Menü Linkini Paylaş
</Text>
</Pressable>
{/* Quick Copy Link Box */}
<Pressable
onPress={handleCopy}
style={{
backgroundColor: "#141417",
borderWidth: 1,
borderColor: "#242429",
borderRadius: 14,
padding: 14,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 4,
}}
>
<Text style={{ color: "#A1A1AA", fontSize: 12, flex: 1 }} numberOfLines={1}>
{qr.targetUrl}
</Text>
<Ionicons
name={copied ? "checkmark-circle" : "copy-outline"}
size={18}
color={copied ? "#10B981" : "#D4AF37"}
style={{ marginLeft: 8 }}
/>
</Pressable>
</View>
</View>
</ScrollView>