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

487 lines
16 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,
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 { 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;
redirectUrl: string;
targetUrl: string;
pngBase64: string;
}
export default function QrScreen() {
const [active, setActive] = useState<ActiveRestaurant | null>(null);
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 [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] || Object.values(QR_STAND_PRESETS)[0])!;
const currentSvgRaw = MOBILE_SVG_TEMPLATES[selectedKey] || MOBILE_SVG_TEMPLATES["qr-4"] || Object.values(MOBILE_SVG_TEMPLATES)[0] || "";
// Inject the live QR code image directly into the SVG's #qr-placeholder group
// Yorum satırlarını temizle + iç içe <g> taglarını doğru saymak için depth tracking kullan
function buildSvgWithQr(svgRaw: string, pngBase64: string): string {
// react-native-svg XML parser <!-- yorum satırlarını --> desteklemez
// Ayrıca text node'lardaki &amp; &lt; gibi entity'leri decode et
const clean = svgRaw
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"');
const startMarker = '<g id="qr-placeholder"';
const startIdx = clean.indexOf(startMarker);
if (startIdx === -1) return clean;
// İç içe <g> taglarını sayarak doğru kapanış </g>'yi bul
let depth = 0;
let i = startIdx;
let endIdx = -1;
while (i < clean.length - 3) {
if (clean[i] === "<") {
if (clean[i + 1] === "g" && (clean[i + 2] === " " || clean[i + 2] === ">")) {
depth++;
} else if (clean[i + 1] === "/" && clean[i + 2] === "g" && clean[i + 3] === ">") {
depth--;
if (depth === 0) {
endIdx = i + 4; // </g> uzunluğu = 4
break;
}
}
}
i++;
}
if (endIdx === -1) return clean;
const qrGroup = `<g id="qr-placeholder" transform="translate(90, 215)">
<rect width="220" height="220" rx="20" fill="#FFFFFF"/>
<image href="data:image/png;base64,${pngBase64}" x="10" y="10" width="200" height="200" preserveAspectRatio="xMidYMid meet"/>
</g>`;
return clean.substring(0, startIdx) + qrGroup + clean.substring(endIdx);
}
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
}
} catch (err) {
setError(err instanceof Error ? err.message : "QR oluşturulamadı");
} finally {
setLoading(false);
}
})();
}, []);
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 || !currentSvgRaw) return;
setExportingPdf(true);
try {
const qrImageUri = `data:image/png;base64,${qr.pngBase64}`;
// 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>
<html>
<head>
<meta charset="utf-8"/>
<style>
@page { size: A5 portrait; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 0; width: 148mm; height: 210mm;
display: flex; align-items: center; justify-content: center;
background: #000000;
-webkit-print-color-adjust: exact;
}
.svg-wrap {
width: 140mm;
height: 200mm;
display: flex;
align-items: center;
justify-content: center;
}
svg {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
</head>
<body>
<div class="svg-wrap">
${svgWithQr}
</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: "#0C0C0E", paddingVertical: 20 }}>
<View style={{ width: "100%", maxWidth: 440, alignSelf: "center", paddingHorizontal: 20 }}>
{/* Header Badge */}
<View
style={{
alignSelf: "center",
backgroundColor: "rgba(212, 175, 55, 0.15)",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 99,
marginBottom: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.3)",
}}
>
<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: "#FFFFFF", marginBottom: 4, textAlign: "center" }}>
Masa Stant Tasarımları
</Text>
<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 */}
{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 ? "#D4AF37" : "#1A1A1E",
borderWidth: 1.5,
borderColor: isSelected ? "#D4AF37" : "#2B2B32",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<View
style={{
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: preset.plaqueColor || "#1A1A1E",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.4)",
}}
/>
<Text
style={{
fontSize: 13,
fontWeight: "700",
color: isSelected ? "#0C0C0E" : "#FFFFFF",
}}
>
{preset.name}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
{/* Live Stand Preview Wrapped in ViewShot */}
<View
style={{
alignItems: "center",
justifyContent: "center",
padding: 16,
borderRadius: 28,
backgroundColor: "#18181B",
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
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 }}>
{/* SVG with QR code injected directly — no overlay, pixel-perfect */}
<View
style={{
width: 300,
height: 487.5,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#0C0C0E",
}}
>
{currentSvgRaw ? (
<SvgXml
xml={buildSvgWithQr(currentSvgRaw, qr.pngBase64)}
width={300}
height={487.5}
/>
) : null}
</View>
</ViewShot>
</View>
{/* Output Action Buttons */}
<View style={{ width: "100%", gap: 10 }}>
{/* PDF Download Button */}
<Pressable
onPress={handleExportPdf}
disabled={exportingPdf}
style={({ pressed }) => ({
backgroundColor: "#D4AF37",
borderRadius: 14,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: pressed || exportingPdf ? 0.85 : 1,
shadowColor: "#D4AF37",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
})}
>
{exportingPdf ? (
<ActivityIndicator size="small" color="#0C0C0E" />
) : (
<>
<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 Download Button */}
<Pressable
onPress={handleExportPng}
disabled={exportingPng}
style={({ pressed }) => ({
backgroundColor: "#1A1A1E",
borderWidth: 1.5,
borderColor: "#2B2B32",
borderRadius: 14,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: pressed || exportingPng ? 0.85 : 1,
})}
>
{exportingPng ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="image-outline" size={20} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
PNG Olarak Kaydet / Paylaş
</Text>
</>
)}
</Pressable>
{/* Direct Link Share Button */}
<Pressable
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="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>
);
}