Files
menulio/apps/mobile/src/app/menu/qr.tsx
T

637 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { router } from "expo-router";
import {
ActivityIndicator,
Alert,
Image,
Pressable,
ScrollView,
Share,
Text,
View,
} 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";
interface QrResponse {
id: string;
redirectUrl: string;
targetUrl: string;
pngBase64: string;
}
export default function QrScreen() {
const [active, setActive] = useState<ActiveRestaurant | null>(null);
const [restaurantName, setRestaurantName] = useState<string>("THE URBAN BURGER");
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 () => {
const activeRest = await getActiveRestaurant();
if (!activeRest) return;
setActive(activeRest);
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 {
setLoading(false);
}
})();
}, []);
const displayCategories = categories.length > 0 ? categories.slice(0, 4) : activePreset.categories;
async function handleCopy() {
if (!qr?.targetUrl) return;
await Clipboard.setStringAsync(qr.targetUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
async function handleShareQuick() {
if (!qr?.targetUrl) return;
await Share.share({
title: "Dijital QR Menü",
message: `${qr.targetUrl}\nMenümüzü incelemek için QR kodu taratın veya linke tıklayın.`,
url: qr.targetUrl,
});
}
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: 5px 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: #1c3e2b;
display: flex; align-items: center; justify-content: center;
-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);
}
.inner-border {
position: absolute; top: 10px; left: 10px; right: 10px; bottom: 10px;
border: 1px solid ${activePreset.borderAccent};
border-radius: 24px; pointer-events: none;
}
.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>
</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 ve Şablonlar Hazırlanıyor...</Text>
</View>
);
}
if (error || !qr) {
return (
<View style={{ flex: 1, backgroundColor: "#FAF8F5", alignItems: "center", justifyContent: "center", padding: 24 }}>
<Ionicons name="alert-circle-outline" size={48} color="#DC2626" style={{ marginBottom: 12 }} />
<Text style={{ color: "#DC2626", fontSize: 16, fontWeight: "600", textAlign: "center" }}>
{error ?? "QR kod yüklenemedi"}
</Text>
<Pressable
onPress={() => router.back()}
style={{ marginTop: 20, backgroundColor: "#1C1917", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 }}
>
<Text style={{ color: "#FFF", fontWeight: "600" }}>Geri Dön</Text>
</Pressable>
</View>
);
}
return (
<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: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
}}
>
<Ionicons name="qr-code-outline" size={15} color="#926E27" />
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
MASA QR STANT JENERATÖRÜ
</Text>
</View>
<Text style={{ fontSize: 24, fontWeight: "800", color: "#1C1917", marginBottom: 4, textAlign: "center" }}>
Masa Stant Tasarımları
</Text>
<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>
{/* Horizontal Theme Selector (rendered only if multiple themes exist) */}
{Object.values(QR_STAND_PRESETS).length > 1 && (
<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.plaqueColor,
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={{
alignItems: "center",
justifyContent: "center",
padding: 18,
borderRadius: 28,
backgroundColor: "#1c3e2b", // Ambient forest green tabletop environment backdrop
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
}}
>
<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)",
position: "relative",
shadowColor: "#000",
shadowOffset: { width: 0, height: 15 },
shadowOpacity: 0.4,
shadowRadius: 30,
}}
>
{/* Inner Border Accent Line */}
<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,
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,
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 5,
borderWidth: 1,
borderColor: "rgba(0,0,0,0.1)",
}}
>
<Image
source={{ uri: `data:image/png;base64,${qr.pngBase64}` }}
style={{ width: 175, height: 175, borderRadius: 10 }}
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>
{/* Output Action Buttons */}
<View style={{ width: "100%", gap: 10 }}>
{/* PDF Download Button */}
<Pressable
onPress={handleExportPdf}
disabled={exportingPdf}
style={({ pressed }) => ({
backgroundColor: "#C8A96B",
borderRadius: 14,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: pressed || exportingPdf ? 0.85 : 1,
shadowColor: "#C8A96B",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 4,
})}
>
{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>
{/* PNG Export Button */}
<Pressable
onPress={handleExportPng}
disabled={exportingPng}
style={({ pressed }) => ({
backgroundColor: "#1C1917",
borderRadius: 14,
padding: 15,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: pressed || exportingPng ? 0.85 : 1,
})}
>
{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>
{/* 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 }}
>
<Ionicons name="arrow-back" size={16} color="#78716C" />
<Text style={{ color: "#78716C", fontSize: 14, fontWeight: "600" }}>
Menü Editörüne Dön
</Text>
</Pressable>
</View>
</View>
</ScrollView>
);
}