feat(mobile): build native in-app QR Stand Generator with theme switcher & A5 PDF / PNG exporter

This commit is contained in:
AyrisAI
2026-08-20 18:25:20 +03:00
parent bf44bf3e9d
commit 3a15dd15b9
3 changed files with 590 additions and 107 deletions
+4 -1
View File
@@ -25,7 +25,9 @@
"expo-haptics": "^14.0.1",
"expo-image-picker": "~16.0.6",
"expo-linking": "~7.0.5",
"expo-print": "^57.0.1",
"expo-router": "~4.0.0",
"expo-sharing": "^57.0.13",
"expo-splash-screen": "~0.29.24",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
@@ -33,7 +35,8 @@
"react-native-purchases": "^8.2.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-url-polyfill": "^2.0.0"
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^5.1.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",
+420 -100
View File
@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { router } from "expo-router";
import {
ActivityIndicator,
Alert,
Image,
Linking,
Pressable,
ScrollView,
Share,
@@ -13,6 +12,10 @@ import {
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
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 { QR_STAND_PRESETS, type QrStandPreset } from "@menulio/shared";
import { api } from "@/lib/api";
import { getActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant";
@@ -25,10 +28,20 @@ interface QrResponse {
export default function QrScreen() {
const [active, setActive] = useState<ActiveRestaurant | null>(null);
const [restaurantName, setRestaurantName] = useState<string>("RESTORAN");
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>("urban");
const [categories, setCategories] = useState<string[]>([]);
const [exportingPng, setExportingPng] = useState(false);
const [exportingPdf, setExportingPdf] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewShotRef = useRef<any>(null);
const activePreset: QrStandPreset = (QR_STAND_PRESETS[selectedKey] || QR_STAND_PRESETS.urban)!;
useEffect(() => {
(async () => {
@@ -39,6 +52,28 @@ export default function QrScreen() {
try {
const data = await api.post<QrResponse>(`/restaurants/${activeRest.restaurantId}/qr`);
setQr(data);
// Fetch detailed restaurant name
try {
const restDetail = await api.get<{ name?: string }>(`/restaurants/${activeRest.restaurantId}`);
if (restDetail?.name) {
setRestaurantName(restDetail.name);
}
} 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 {
@@ -47,6 +82,8 @@ 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);
@@ -54,7 +91,7 @@ export default function QrScreen() {
setTimeout(() => setCopied(false), 2000);
}
async function handleShare() {
async function handleShareQuick() {
if (!qr?.targetUrl) return;
await Share.share({
title: "Dijital QR Menü",
@@ -63,11 +100,124 @@ export default function QrScreen() {
});
}
async function handleExportPng() {
if (!viewShotRef.current) return;
setExportingPng(true);
try {
const uri = await viewShotRef.current.capture?.();
if (uri) {
await Sharing.shareAsync(uri, {
mimeType: "image/png",
dialogTitle: "Masa Stant QR Görselini Paylaş / Kaydet",
});
}
} catch {
Alert.alert("Hata", "Görsel oluşturulamadı.");
} finally {
setExportingPng(false);
}
}
async function handleExportPdf() {
if (!qr || !active) 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: 4px 0;">${c}</div>${
i < displayCategories.length - 1
? '<div style="height: 1px; background: ' + activePreset.dividerColor + '; margin: 2px 0;"></div>'
: ""
}`,
)
.join("");
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<style>
@page { size: A5 portrait; margin: 0; }
body {
margin: 0; padding: 0; width: 148mm; height: 210mm;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: ${activePreset.bgGradient};
display: flex; align-items: center; justify-content: center;
-webkit-print-color-adjust: exact;
}
.plaque {
width: 120mm; height: 180mm;
background: ${activePreset.plaqueBg};
border: 3px solid ${activePreset.borderAccent};
border-radius: 24px;
padding: 20px; box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; text-align: center;
color: ${activePreset.headerText};
position: relative;
}
.badge {
border: 2px solid ${activePreset.logoBorder};
background: ${activePreset.logoBg};
color: ${activePreset.accentText};
padding: 6px 16px; border-radius: 12px;
font-weight: 900; font-size: 16px; letter-spacing: 2px;
margin-bottom: 12px;
}
.title {
font-size: 20px; font-weight: 900; letter-spacing: 1px;
line-height: 1.1; margin-bottom: 16px;
}
.qr-box {
background: ${activePreset.qrBg};
padding: 14px; border-radius: 20px;
display: inline-block; margin-bottom: 16px;
}
.qr-img { width: 150px; height: 150px; display: block; }
.sub { font-size: 11px; opacity: 0.8; margin-bottom: 12px; color: ${activePreset.subText}; }
.cats { font-size: 13px; font-weight: 800; letter-spacing: 2px; width: 80%; margin: 0 auto; }
</style>
</head>
<body>
<div class="plaque">
<div class="badge">${restTitle}</div>
<div class="title">DİJİTAL MENÜ<br><span style="letter-spacing:2px;">KAMERANIZLA TARATIN</span></div>
<div class="qr-box">
<img class="qr-img" src="${qrImageUri}" />
</div>
<div class="sub">Telefonunuzun kamerasını doğrultun</div>
<div class="cats">${catsHtml}</div>
</div>
</body>
</html>
`;
const { uri } = await Print.printToFileAsync({
html: htmlContent,
width: 420,
height: 595,
});
await Sharing.shareAsync(uri, {
mimeType: "application/pdf",
dialogTitle: "A5 Baskıya Hazır PDF İndir / Paylaş",
});
} catch {
Alert.alert("Hata", "PDF oluşturulamadı.");
} finally {
setExportingPdf(false);
}
}
if (loading) {
return (
<View style={{ flex: 1, backgroundColor: "#FAF8F5", alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator size="large" color="#C8A96B" />
<Text style={{ marginTop: 12, color: "#78716C", fontSize: 14 }}>QR Kod Hazırlanıyor...</Text>
<Text style={{ marginTop: 12, color: "#78716C", fontSize: 14 }}>QR Kod ve Şablonlar Hazırlanıyor...</Text>
</View>
);
}
@@ -89,25 +239,18 @@ 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" }}>
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", paddingVertical: 20 }}>
<View style={{ width: "100%", maxWidth: 440, alignSelf: "center", paddingHorizontal: 20 }}>
{/* Header Badge */}
<View
style={{
alignSelf: "center",
backgroundColor: "#F3EDE2",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 99,
marginBottom: 16,
marginBottom: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
@@ -115,62 +258,208 @@ export default function QrScreen() {
>
<Ionicons name="qr-code-outline" size={15} color="#926E27" />
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
DİJİTAL RESTORAN QR KODU
MASA QR STANT JENERATÖRÜ
</Text>
</View>
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", marginBottom: 6, textAlign: "center" }}>
Masanıza Özel QR Kod
<Text style={{ fontSize: 24, fontWeight: "800", color: "#1C1917", marginBottom: 4, textAlign: "center" }}>
Masa Stant Tasarımları
</Text>
<Text style={{ fontSize: 14, color: "#78716C", textAlign: "center", marginBottom: 28, lineHeight: 20 }}>
Müşterileriniz bu QR kodu telefon kameralarıyla taratarak saniyeler içinde menünüzü inceleyebilir.
<Text style={{ fontSize: 13, color: "#78716C", 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>
{/* QR Code Container Card */}
{/* Horizontal Theme Selector */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 10, paddingHorizontal: 4, marginBottom: 20 }}
>
{Object.values(QR_STAND_PRESETS).map((preset) => {
const isSelected = preset.key === selectedKey;
return (
<Pressable
key={preset.key}
onPress={() => setSelectedKey(preset.key)}
style={{
backgroundColor: isSelected ? "#1C1917" : "#FFFFFF",
borderWidth: 1.5,
borderColor: isSelected ? "#1C1917" : "#E7E5E4",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<View
style={{
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: preset.qrColor,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.4)",
}}
/>
<Text
style={{
fontSize: 13,
fontWeight: "700",
color: isSelected ? "#FFFFFF" : "#1C1917",
}}
>
{preset.name}
</Text>
</Pressable>
);
})}
</ScrollView>
{/* Live Stand Preview Wrapped in ViewShot */}
<View
style={{
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 24,
alignItems: "center",
justifyContent: "center",
padding: 16,
borderRadius: 24,
backgroundColor: "#18181B",
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 8,
borderWidth: 1,
borderColor: "rgba(200, 169, 107, 0.25)",
marginBottom: 24,
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 16,
elevation: 6,
}}
>
<Image
source={{ uri: `data:image/png;base64,${qr.pngBase64}` }}
style={{ width: 220, height: 220, borderRadius: 12 }}
resizeMode="contain"
/>
<ViewShot ref={viewShotRef} options={{ format: "png", quality: 1.0 }}>
<View
style={{
width: 300,
minHeight: 480,
borderRadius: 28,
padding: 24,
alignItems: "center",
backgroundColor: activePreset.qrColor === "#ffffff" ? "#18181b" : activePreset.qrColor,
borderWidth: 2,
borderColor: activePreset.borderAccent,
position: "relative",
}}
>
{/* Top Restaurant Badge */}
<View
style={{
borderWidth: 1.5,
borderColor: activePreset.logoBorder,
backgroundColor: activePreset.logoBg,
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 10,
marginBottom: 14,
alignItems: "center",
}}
>
<Text
style={{
color: activePreset.accentText,
fontSize: 15,
fontWeight: "900",
letterSpacing: 1,
textAlign: "center",
}}
>
{restaurantName.toUpperCase()}
</Text>
</View>
<View
style={{
marginTop: 16,
paddingVertical: 6,
paddingHorizontal: 14,
backgroundColor: "#FAF8F5",
borderRadius: 8,
borderWidth: 1,
borderColor: "#E7E5E4",
}}
>
<Text style={{ fontSize: 13, fontWeight: "600", color: "#1C1917" }}>
{qr.targetUrl}
</Text>
</View>
{/* Title Callout */}
<Text
style={{
color: activePreset.headerText,
fontSize: 18,
fontWeight: "900",
textAlign: "center",
lineHeight: 22,
marginBottom: 16,
}}
>
DİJİTAL MENÜ{"\n"}
<Text style={{ letterSpacing: 1 }}>KAMERANIZLA TARATIN</Text>
</Text>
{/* Auto Placed QR Code Container */}
<View
style={{
backgroundColor: activePreset.qrBg,
padding: 12,
borderRadius: 20,
marginBottom: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
}}
>
<Image
source={{ uri: `data:image/png;base64,${qr.pngBase64}` }}
style={{ width: 170, height: 170, borderRadius: 8 }}
resizeMode="contain"
/>
</View>
{/* Instructional Subtext */}
<Text
style={{
color: activePreset.subText,
fontSize: 11,
fontWeight: "600",
marginBottom: 14,
textAlign: "center",
}}
>
Telefonunuzun kamerasını doğrultun
</Text>
{/* Category Quick Preview List */}
<View style={{ width: "85%", alignItems: "center", gap: 3 }}>
{displayCategories.map((cat, idx) => (
<View key={idx} style={{ width: "100%", alignItems: "center" }}>
<Text
style={{
color: activePreset.headerText,
fontSize: 12,
fontWeight: "800",
letterSpacing: 1.5,
textTransform: "uppercase",
}}
>
{cat}
</Text>
{idx < displayCategories.length - 1 && (
<View
style={{
width: "100%",
height: 1,
backgroundColor: activePreset.dividerColor,
marginVertical: 2,
}}
/>
)}
</View>
))}
</View>
</View>
</ViewShot>
</View>
{/* Action Buttons */}
<View style={{ width: "100%", gap: 12 }}>
{/* Stand Generator PDF / PNG Button */}
{/* Output Action Buttons */}
<View style={{ width: "100%", gap: 10 }}>
{/* PDF Download Button */}
<Pressable
onPress={handleOpenStandGenerator}
onPress={handleExportPdf}
disabled={exportingPdf}
style={({ pressed }) => ({
backgroundColor: "#C8A96B",
borderRadius: 14,
@@ -178,8 +467,8 @@ export default function QrScreen() {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
opacity: pressed ? 0.9 : 1,
gap: 8,
opacity: pressed || exportingPdf ? 0.85 : 1,
shadowColor: "#C8A96B",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
@@ -187,64 +476,95 @@ export default function QrScreen() {
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>
{exportingPdf ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<>
<Ionicons name="document-text-outline" size={18} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "800" }}>
A5 Baskı PDF Dosyası İndir / Paylaş
</Text>
</>
)}
</Pressable>
{/* Share Button */}
{/* PNG Export Button */}
<Pressable
onPress={handleShare}
onPress={handleExportPng}
disabled={exportingPng}
style={({ pressed }) => ({
backgroundColor: "#1C1917",
borderRadius: 14,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
opacity: pressed ? 0.9 : 1,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 10,
elevation: 4,
})}
>
<Ionicons name="share-social-outline" size={18} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
QR Kodu ve Linki Paylaş
</Text>
</Pressable>
{/* Copy Link Button */}
<Pressable
onPress={handleCopy}
style={({ pressed }) => ({
backgroundColor: "#FFFFFF",
borderWidth: 1.5,
borderColor: "#E7E5E4",
borderRadius: 14,
padding: 15,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: pressed ? 0.85 : 1,
opacity: pressed || exportingPng ? 0.85 : 1,
})}
>
<Ionicons
name={copied ? "checkmark-circle-outline" : "copy-outline"}
size={18}
color={copied ? "#10B981" : "#1C1917"}
/>
<Text style={{ color: copied ? "#10B981" : "#1C1917", fontSize: 14, fontWeight: "600" }}>
{copied ? "Link Kopyalandı!" : "Menü Linkini Kopyala"}
</Text>
{exportingPng ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<>
<Ionicons name="image-outline" size={18} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
PNG Görsel Olarak Kaydet / Paylaş
</Text>
</>
)}
</Pressable>
{/* Back to Menu Editor */}
{/* 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 */}
<Pressable
onPress={() => router.back()}
style={{ padding: 12, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 6 }}