feat(qr): complete QR studio, dynamic google fonts, mouse canvas drag, and template sync

This commit is contained in:
AyrisAI
2026-08-22 16:00:30 +03:00
parent c1569c0879
commit e7ba6c7807
34 changed files with 5689 additions and 1251 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,
"newArchEnabled": true,
"platforms": ["ios", "android"],
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
+7
View File
@@ -15,6 +15,13 @@
"distribution": "internal",
"ios": {
"image": "latest"
},
"env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://fwahdopsyvryvjaqande.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "sb_publishable_9YsK7YU7xfzxJQsz3x5iMA_LHCqUN3b",
"EXPO_PUBLIC_API_URL": "https://api.menul.io",
"EXPO_PUBLIC_WEB_URL": "https://menul.io",
"EXPO_PUBLIC_REVENUECAT_API_KEY": "appl_uQIvGUuvEthCNheZtcRfVixVhFy"
}
},
"production": {
+4 -4
View File
@@ -25,9 +25,9 @@
"expo-haptics": "^14.0.1",
"expo-image-picker": "~16.0.6",
"expo-linking": "~7.0.5",
"expo-print": "^57.0.1",
"expo-print": "~14.0.3",
"expo-router": "~4.0.0",
"expo-sharing": "^57.0.13",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.24",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
@@ -35,9 +35,9 @@
"react-native-purchases": "^8.2.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.15.5",
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^5.1.1"
"react-native-view-shot": "~4.0.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
+1 -1
View File
@@ -4,7 +4,7 @@ import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</>
);
+589 -160
View File
@@ -3,22 +3,22 @@ import { router } from "expo-router";
import {
ActivityIndicator,
Alert,
Modal,
Image,
Pressable,
RefreshControl,
ScrollView,
Share,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
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 { supabase } from "@/lib/supabase";
import { api } from "@/lib/api";
import { getActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant";
import {
@@ -29,7 +29,9 @@ import {
import {
QrCanvasStudio,
getDefaultLayersForTemplate,
getDefaultQrConfigForTemplate,
type CanvasTextLayer,
type CanvasQrConfig,
} from "@/components/qr-canvas";
const HIDE_ALL_TEMPLATE_FIELDS: QrCustomFields = {
@@ -48,95 +50,290 @@ interface QrResponse {
pngBase64: string;
}
function getPresetAspectRatio(preset?: QrStandPreset | null): number {
if (!preset) return 1;
if (preset.aspectRatio) {
const parts = preset.aspectRatio.split(/[/:]/).map(Number);
if (parts[0] && parts[1]) {
return parts[0] / parts[1];
}
}
if (TEMPLATE_ASPECT_RATIOS[preset.key]) {
return TEMPLATE_ASPECT_RATIOS[preset.key];
}
return 1;
}
export default function QrScreen() {
const insets = useSafeAreaInsets();
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 availablePresets = Object.values(QR_STAND_PRESETS).filter(
(p) => !!COMPONENT_TEMPLATES[p.key]
const [scrollEnabled, setScrollEnabled] = useState(true);
// Dinamik Şablon Listesi (API'den ve yerelden)
const [presets, setPresets] = useState<QrStandPreset[]>(() =>
Object.values(QR_STAND_PRESETS)
);
const [selectedKey, setSelectedKey] = useState<string>(
availablePresets[0]?.key || "qr-1"
presets[0]?.key || "qr-1"
);
const [exportingPng, setExportingPng] = useState(false);
const [exportingPdf, setExportingPdf] = useState(false);
// Instagram Story / Canva Tarzı Katmanlar
// Aktif Şablon ve Oran Hesaplama (Şablondaki Dinamik Aspect Ratio Öncelikli)
const currentPreset = presets.find((p) => p.key === selectedKey) || presets[0];
const aspectRatio = getPresetAspectRatio(currentPreset);
const displayWidth = 290;
const displayHeight = Math.round(displayWidth / aspectRatio);
// Instagram Story / Canva Tarzı Katmanlar & QR Konfigürasyonu
const [layers, setLayers] = useState<CanvasTextLayer[]>(() =>
getDefaultLayersForTemplate("qr-1", "Gourmet Bistro")
);
const [qrConfig, setQrConfig] = useState<CanvasQrConfig>(() =>
getDefaultQrConfigForTemplate("qr-1", 290, 290)
);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [isQrSelected, setIsQrSelected] = useState<boolean>(false);
// Şablonlar Arasında Düzenlemeleri Koruyan Önbellek
const templateCacheRef = useRef<
Record<string, { layers: CanvasTextLayer[]; qrConfig: CanvasQrConfig }>
>({});
const handleLayersChange = (updater: React.SetStateAction<CanvasTextLayer[]>) => {
setLayers((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater;
templateCacheRef.current[selectedKey] = {
layers: next,
qrConfig: templateCacheRef.current[selectedKey]?.qrConfig || qrConfig,
};
return next;
});
};
const handleQrConfigChange = (updater: React.SetStateAction<CanvasQrConfig>) => {
setQrConfig((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater;
templateCacheRef.current[selectedKey] = {
layers: templateCacheRef.current[selectedKey]?.layers || layers,
qrConfig: next,
};
return next;
});
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewShotRef = useRef<any>(null);
const activePreset: QrStandPreset =
QR_STAND_PRESETS[selectedKey] || availablePresets[0] || Object.values(QR_STAND_PRESETS)[0]!;
const [refreshing, setRefreshing] = useState(false);
const [syncing, setSyncing] = useState(false);
// Component-based template sistemi
const TemplateComponent =
COMPONENT_TEMPLATES[selectedKey] ?? Object.values(COMPONENT_TEMPLATES)[0];
const aspectRatio = TEMPLATE_ASPECT_RATIOS[selectedKey] ?? 1;
const displayWidth = 300;
const displayHeight = Math.round(displayWidth / aspectRatio);
useEffect(() => {
(async () => {
const activeRest = await getActiveRestaurant();
if (!activeRest) return;
setActive(activeRest);
// Şablon ve QR Verilerini Çek / Senkronize Et
const fetchTemplatesAndQr = async (isManualRefresh = false) => {
if (isManualRefresh) {
setRefreshing(true);
setSyncing(true);
try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } catch {}
}
try {
// 1. Supabase ve API'den Dinamik Şablonları Çek
let fetchedPresets: QrStandPreset[] = [];
try {
const data = await api.post<QrResponse>(`/restaurants/${activeRest.restaurantId}/qr`);
setQr(data);
const { data: dbTemplates, error: dbErr } = await supabase
.from("qr_stand_templates")
.select("*")
.eq("is_active", true)
.order("created_at", { ascending: false });
// Fetch detailed restaurant name
if (!dbErr && dbTemplates && dbTemplates.length > 0) {
fetchedPresets = dbTemplates.map((row: any) => ({
key: row.key,
name: row.name,
categoryName: row.category_name,
svgTemplateUrl: row.svg_template_url || `/${row.key}.svg`,
aspectRatio: row.aspect_ratio || "1/1",
bgType: row.bg_type || "gradient",
bgImageUrl: row.bg_image_url,
bgGradient: row.bg_gradient,
plaqueColor: row.plaque_color,
borderAccent: row.border_accent,
accentText: row.accent_text,
headerText: row.header_text,
subText: row.sub_text,
dividerColor: row.divider_color,
qrConfig: row.qr_config,
layers: row.layers,
}));
} else {
const tplData = await api.get<{ templates: QrStandPreset[] }>("/qr-templates");
if (tplData?.templates && tplData.templates.length > 0) {
fetchedPresets = tplData.templates;
}
}
} catch {
// fallback
}
let currentRestName = restaurantName;
// 2. Aktif Restoran ve QR Verilerini Çek
const activeRest = await getActiveRestaurant();
if (activeRest) {
setActive(activeRest);
try {
const qrData = await api.post<QrResponse>(`/restaurants/${activeRest.restaurantId}/qr`);
setQr(qrData);
const restDetail = await api.get<{ name?: string }>(`/restaurants/${activeRest.restaurantId}`);
if (restDetail?.name) {
currentRestName = restDetail.name;
setRestaurantName(restDetail.name);
setLayers((prev) =>
prev.map((l) =>
l.id === "rName" ? { ...l, text: restDetail.name!.toUpperCase() } : l
)
);
}
} catch {
// fallback
}
} catch (err) {
setError(err instanceof Error ? err.message : "QR oluşturulamadı");
} finally {
setLoading(false);
} catch {}
}
})();
if (fetchedPresets.length > 0) {
setPresets(fetchedPresets);
// Eğer mevcut seçili şablon silinmişse ilk geçerli şablona geç
let effectiveKey = selectedKey;
const exists = fetchedPresets.some((p) => p.key === selectedKey);
if (!exists) {
effectiveKey = fetchedPresets[0].key;
setSelectedKey(effectiveKey);
}
// Önbellekten silinen şablonları temizle
const validKeys = new Set(fetchedPresets.map((p) => p.key));
Object.keys(templateCacheRef.current).forEach((k) => {
if (!validKeys.has(k)) {
delete templateCacheRef.current[k];
}
});
// Aktif seçili şablonu yeni verilerle güncelle
const activeTpl = fetchedPresets.find((p) => p.key === effectiveKey) || fetchedPresets[0];
if (activeTpl) {
const pRatio = getPresetAspectRatio(activeTpl);
const calcHeight = Math.round(displayWidth / (pRatio || 1));
let newLayers: CanvasTextLayer[];
if ((activeTpl as any).layers && (activeTpl as any).layers.length > 0) {
newLayers = (activeTpl as any).layers.map((l: any) => ({
id: l.id || `layer-${Math.random()}`,
text: l.text === "{{RESTAURANT_NAME}}" ? currentRestName.toUpperCase() : l.text,
x: l.x ?? (displayWidth - 200) / 2,
y: l.y ?? Math.round((calcHeight * (l.yPercent || 15)) / 100),
rotation: l.rotation || 0,
fontSize: l.fontSize || 16,
fontId: l.fontFamily ? l.fontFamily.split(",")[0].replace(/['"]/g, "").trim() : "serif",
color: l.color || activeTpl.accentText || "#D4AF37",
textAlign: l.textAlign || "center",
bgStyle: l.bgStyle || "none",
letterSpacing: l.letterSpacing || 1,
}));
} else {
newLayers = getDefaultLayersForTemplate(activeTpl.key, currentRestName);
}
let newQr: CanvasQrConfig;
if ((activeTpl as any).qrConfig) {
const c = (activeTpl as any).qrConfig;
newQr = {
x: Math.round(((displayWidth - (c.size || 115)) * (c.xPercent || 50)) / 100),
y: Math.round(((calcHeight - (c.size || 115)) * (c.yPercent || 38)) / 100),
size: c.size || 115,
borderRadius: c.borderRadius || 14,
borderWidth: c.borderWidth || 2,
borderColor: c.borderColor || activeTpl.borderAccent || "#D4AF37",
padding: c.padding || 8,
bgBoxColor: c.bgBoxColor || "#FFFFFF",
};
} else {
newQr = getDefaultQrConfigForTemplate(activeTpl.key, displayWidth, calcHeight);
}
setLayers(newLayers);
setQrConfig(newQr);
// Önbelleği de taze veriyle güncelle
templateCacheRef.current[activeTpl.key] = {
layers: newLayers,
qrConfig: newQr,
};
}
}
if (isManualRefresh) {
try { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch {}
}
} catch (err) {
setError(err instanceof Error ? err.message : "QR yüklenemedi");
} finally {
setLoading(false);
setRefreshing(false);
setSyncing(false);
}
};
useEffect(() => {
fetchTemplatesAndQr(false);
// Supabase Realtime Değişiklik Aboneliği (Admin kaydettiğinde anında güncelle)
const channel = supabase
.channel("public:qr_stand_templates")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "qr_stand_templates" },
() => {
fetchTemplatesAndQr(false);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
async function handleCopy() {
if (!qr?.targetUrl) return;
await Clipboard.setStringAsync(qr.targetUrl);
try {
await Clipboard.setStringAsync(qr.targetUrl);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch {}
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,
});
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
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,
});
} catch {}
}
async function handleExportPng() {
if (!viewShotRef.current) return;
setSelectedLayerId(null);
setIsQrSelected(false);
setExportingPng(true);
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
await new Promise((r) => setTimeout(r, 120));
const uri = await viewShotRef.current.capture?.();
if (uri) {
await Sharing.shareAsync(uri, {
@@ -152,10 +349,13 @@ export default function QrScreen() {
}
async function handleExportPdf() {
if (!qr || !active) return;
if (!qr || !active || !viewShotRef.current) return;
setSelectedLayerId(null);
setIsQrSelected(false);
setExportingPdf(true);
try {
// ViewShot ile önce PNG al, sonra A5 HTML içine göm
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
await new Promise((r) => setTimeout(r, 120));
const pngUri = await viewShotRef.current?.capture();
if (!pngUri) throw new Error("Görsel alınamadı");
@@ -212,26 +412,30 @@ export default function QrScreen() {
}
}
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 style={{ flex: 1, backgroundColor: "#0C0C0E", alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator size="large" color="#D4AF37" />
<Text style={{ marginTop: 14, color: "#A1A1AA", fontSize: 14, fontWeight: "600" }}>
QR Stant Stüdyosu 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" }}>
<View style={{ flex: 1, backgroundColor: "#0C0C0E", alignItems: "center", justifyContent: "center", padding: 24 }}>
<Ionicons name="alert-circle-outline" size={48} color="#EF4444" style={{ marginBottom: 12 }} />
<Text style={{ color: "#EF4444", 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 }}
accessible={true}
accessibilityRole="button"
accessibilityLabel="Geri Dön"
style={{ marginTop: 20, backgroundColor: "#242429", paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, minHeight: 44, justifyContent: "center" }}
>
<Text style={{ color: "#FFF", fontWeight: "600" }}>Geri Dön</Text>
</Pressable>
@@ -240,79 +444,221 @@ export default function QrScreen() {
}
return (
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#0C0C0E", paddingVertical: 20 }}>
<View style={{ width: "100%", maxWidth: 440, alignSelf: "center", paddingHorizontal: 20 }}>
{/* Header Badge */}
<ScrollView
scrollEnabled={scrollEnabled}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => fetchTemplatesAndQr(true)}
tintColor="#D4AF37"
colors={["#D4AF37"]}
/>
}
contentContainerStyle={{
flexGrow: 1,
backgroundColor: "#0C0C0E",
paddingTop: insets.top + 8,
paddingBottom: insets.bottom + 40,
}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
>
<View style={{ width: "100%", maxWidth: 440, alignSelf: "center", paddingHorizontal: 16 }}>
{/* Üst Navigasyon & Başlık Çubuğu */}
<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)",
justifyContent: "space-between",
marginBottom: 12,
}}
>
<Ionicons name="qr-code-outline" size={15} color="#D4AF37" />
<Text style={{ color: "#D4AF37", fontSize: 12, fontWeight: "700" }}>
MASA QR STANT JENERATÖRÜ
</Text>
{/* Geri Dön Butonu */}
<Pressable
onPress={() => {
try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } catch {}
router.back();
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessible={true}
accessibilityRole="button"
accessibilityLabel="Geri Dön"
style={({ pressed }) => ({
width: 40,
height: 40,
borderRadius: 13,
backgroundColor: "#18181B",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
opacity: pressed ? 0.75 : 1,
})}
>
<Ionicons name="chevron-back" size={20} color="#FFFFFF" />
</Pressable>
{/* Stüdyo Rozeti */}
<View
style={{
backgroundColor: "rgba(212, 175, 55, 0.12)",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 99,
flexDirection: "row",
alignItems: "center",
gap: 6,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.25)",
}}
>
<Ionicons name="sparkles" size={13} color="#D4AF37" />
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "800", letterSpacing: 0.5 }}>
INSTAGRAM QR STÜDYOSU
</Text>
</View>
{/* Canlı Yenile / Senkronize Et Butonu */}
<Pressable
onPress={() => fetchTemplatesAndQr(true)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessible={true}
accessibilityRole="button"
accessibilityLabel="Şablonları Canlı Yenile"
style={({ pressed }) => ({
width: 40,
height: 40,
borderRadius: 13,
backgroundColor: "#18181B",
borderWidth: 1,
borderColor: syncing ? "#D4AF37" : "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
opacity: pressed ? 0.75 : 1,
})}
>
<Ionicons
name="sync"
size={18}
color={syncing ? "#D4AF37" : "#A1A1AA"}
/>
</Pressable>
</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 */}
{availablePresets.length > 1 && (
{/* Yatay Şablon Seçici Bar */}
{presets.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 10, paddingHorizontal: 4, marginBottom: 20 }}
style={{ flexGrow: 0, marginBottom: 12 }}
contentContainerStyle={{ gap: 8, paddingHorizontal: 2, alignItems: "center" }}
>
{availablePresets.map((preset) => {
{presets.map((preset) => {
const isSelected = preset.key === selectedKey;
const pRatio = getPresetAspectRatio(preset);
const calcHeight = Math.round(displayWidth / (pRatio || 1));
return (
<Pressable
key={preset.key}
onPress={() => {
setSelectedKey(preset.key);
setLayers(getDefaultLayersForTemplate(preset.key, restaurantName));
try { Haptics.selectionAsync(); } catch {}
// 1. Mevcut şablonun son durumunu önbelleğe kaydet
templateCacheRef.current[selectedKey] = {
layers,
qrConfig,
};
const nextKey = preset.key;
setSelectedKey(nextKey);
// 2. Daha önce düzenlenmişse önbellekten geri yükle
if (templateCacheRef.current[nextKey]) {
setLayers(templateCacheRef.current[nextKey].layers);
setQrConfig(templateCacheRef.current[nextKey].qrConfig);
} else {
// İlk defa açılıyorsa başlat
let initialLayers: CanvasTextLayer[];
if ((preset as any).layers && (preset as any).layers.length > 0) {
initialLayers = (preset as any).layers.map((l: any) => ({
id: l.id || `layer-${Math.random()}`,
text: l.text === "{{RESTAURANT_NAME}}" ? restaurantName.toUpperCase() : l.text,
x: l.x ?? (displayWidth - 200) / 2,
y: l.y ?? Math.round((calcHeight * (l.yPercent || 15)) / 100),
rotation: l.rotation || 0,
fontSize: l.fontSize || 16,
fontId: l.fontId || "serif",
color: l.color || "#D4AF37",
textAlign: l.textAlign || "center",
bgStyle: l.bgStyle || "none",
letterSpacing: l.letterSpacing || 1,
}));
} else {
initialLayers = getDefaultLayersForTemplate(nextKey, restaurantName);
}
let initialQr: CanvasQrConfig;
if ((preset as any).qrConfig) {
const c = (preset as any).qrConfig;
initialQr = {
x: Math.round(((displayWidth - (c.size || 115)) * (c.xPercent || 50)) / 100),
y: Math.round(((calcHeight - (c.size || 115)) * (c.yPercent || 38)) / 100),
size: c.size || 115,
borderRadius: c.borderRadius || 14,
borderWidth: c.borderWidth || 2,
borderColor: c.borderColor || preset.borderAccent || "#D4AF37",
padding: c.padding || 8,
bgBoxColor: c.bgBoxColor || "#FFFFFF",
};
} else {
initialQr = getDefaultQrConfigForTemplate(nextKey, displayWidth, calcHeight);
}
setLayers(initialLayers);
setQrConfig(initialQr);
templateCacheRef.current[nextKey] = {
layers: initialLayers,
qrConfig: initialQr,
};
}
setSelectedLayerId(null);
setIsQrSelected(false);
}}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 14,
backgroundColor: isSelected ? "#D4AF37" : "#1A1A1E",
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
accessible={true}
accessibilityRole="button"
accessibilityLabel={`${preset.name} şablonunu seç`}
accessibilityState={{ selected: isSelected }}
style={({ pressed }) => ({
paddingHorizontal: 12,
paddingVertical: 8,
minHeight: 36,
borderRadius: 12,
backgroundColor: isSelected ? "#D4AF37" : "#18181B",
borderWidth: 1.5,
borderColor: isSelected ? "#D4AF37" : "#2B2B32",
borderColor: isSelected ? "#D4AF37" : "rgba(255,255,255,0.12)",
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<View
style={{
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: preset.plaqueColor || "#1A1A1E",
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: preset.plaqueColor || "#18181B",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.4)",
borderColor: isSelected ? "#0C0C0E" : "rgba(255,255,255,0.5)",
}}
/>
<Text
style={{
fontSize: 13,
fontSize: 12,
fontWeight: "700",
color: isSelected ? "#0C0C0E" : "#FFFFFF",
}}
@@ -325,53 +671,123 @@ export default function QrScreen() {
</ScrollView>
)}
{/* İnteraktif Instagram / Canva Tarzı QR Stant Stüdyosu */}
<View
style={{
alignItems: "center",
justifyContent: "center",
width: "100%",
marginBottom: 20,
}}
>
<ViewShot ref={viewShotRef} options={{ format: "png", quality: 1.0 }}>
<QrCanvasStudio
templateKey={selectedKey}
restaurantName={restaurantName}
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
renderBackground={() =>
TemplateComponent ? (
<TemplateComponent
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
fields={HIDE_ALL_TEMPLATE_FIELDS}
{/* İnteraktif Instagram QR & Canva Stüdyosu */}
<QrCanvasStudio
templateKey={selectedKey}
restaurantName={restaurantName}
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
renderBackground={() => {
const cur = presets.find((p) => p.key === selectedKey);
const bgImg = (cur as any)?.bgImageUrl;
// 1. Eğer şablona özel bir Arka Plan Görseli / SVG yüklenmişse bunu render et
if (bgImg) {
return (
<View
style={{
width: displayWidth,
height: displayHeight,
backgroundColor: cur?.plaqueColor || "#132034",
borderRadius: 24,
overflow: "hidden",
borderWidth: 2,
borderColor: cur?.borderAccent || "#D4AF37",
}}
>
<Image
source={{ uri: bgImg }}
style={{ width: "100%", height: "100%", position: "absolute" }}
resizeMode="cover"
/>
) : null
}
layers={layers}
onLayersChange={setLayers}
/>
</ViewShot>
</View>
{/* İç Dekoratif Çerçeve */}
<View
style={{
position: "absolute",
top: 10,
left: 10,
right: 10,
bottom: 10,
borderWidth: 1,
borderColor: cur?.dividerColor || "rgba(212, 175, 55, 0.3)",
borderRadius: 16,
}}
/>
</View>
);
}
// 2. Özel görsel yoksa ve Yerel Hardcoded Component varsa onu kullan
if (COMPONENT_TEMPLATES[selectedKey]) {
const Comp = COMPONENT_TEMPLATES[selectedKey];
return (
<Comp
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
fields={HIDE_ALL_TEMPLATE_FIELDS}
hideQr={true}
/>
);
}
// 3. Fallback dinamik stant
if (!cur) return null;
return (
<View
style={{
width: displayWidth,
height: displayHeight,
backgroundColor: cur.plaqueColor || "#132034",
borderRadius: 24,
overflow: "hidden",
borderWidth: 2,
borderColor: cur.borderAccent || "#D4AF37",
}}
>
<View
style={{
position: "absolute",
top: 10,
left: 10,
right: 10,
bottom: 10,
borderWidth: 1,
borderColor: cur.dividerColor || "rgba(212, 175, 55, 0.3)",
borderRadius: 16,
}}
/>
</View>
);
}}
layers={layers}
onLayersChange={handleLayersChange}
qrConfig={qrConfig}
onQrConfigChange={handleQrConfigChange}
viewShotRef={viewShotRef}
selectedLayerId={selectedLayerId}
onSelectLayer={setSelectedLayerId}
isQrSelected={isQrSelected}
onSelectQr={setIsQrSelected}
onDragStateChange={(dragging) => setScrollEnabled(!dragging)}
/>
{/* Output Action Buttons */}
<View style={{ width: "100%", gap: 10 }}>
{/* PDF Download Button */}
{/* Aksiyon Butonları Grubu */}
<View style={{ width: "100%", gap: 10, marginTop: 4 }}>
{/* PDF İndirme Butonu */}
<Pressable
onPress={handleExportPdf}
disabled={exportingPdf}
accessible={true}
accessibilityRole="button"
accessibilityLabel="PDF Olarak İndir (A5 Matbaa Baskı)"
style={({ pressed }) => ({
backgroundColor: "#D4AF37",
borderRadius: 14,
padding: 16,
minHeight: 52,
padding: 15,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -379,9 +795,9 @@ export default function QrScreen() {
opacity: pressed || exportingPdf ? 0.85 : 1,
shadowColor: "#D4AF37",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 3,
elevation: 4,
})}
>
{exportingPdf ? (
@@ -396,16 +812,20 @@ export default function QrScreen() {
)}
</Pressable>
{/* PNG Download Button */}
{/* PNG İndirme Butonu */}
<Pressable
onPress={handleExportPng}
disabled={exportingPng}
accessible={true}
accessibilityRole="button"
accessibilityLabel="PNG Olarak Kaydet veya Paylaş"
style={({ pressed }) => ({
backgroundColor: "#1A1A1E",
backgroundColor: "#18181B",
borderWidth: 1.5,
borderColor: "#2B2B32",
borderColor: "rgba(255,255,255,0.12)",
borderRadius: 14,
padding: 16,
minHeight: 48,
padding: 14,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -417,23 +837,27 @@ export default function QrScreen() {
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="image-outline" size={20} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
<Ionicons name="image-outline" size={19} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 14, fontWeight: "700" }}>
PNG Olarak Kaydet / Paylaş
</Text>
</>
)}
</Pressable>
{/* Direct Link Share Button */}
{/* Menü Linki Paylaş */}
<Pressable
onPress={handleShareQuick}
accessible={true}
accessibilityRole="button"
accessibilityLabel="Menü Linkini Paylaş"
style={({ pressed }) => ({
backgroundColor: "#1A1A1E",
backgroundColor: "#18181B",
borderWidth: 1,
borderColor: "#2B2B32",
borderColor: "rgba(255,255,255,0.08)",
borderRadius: 14,
padding: 14,
minHeight: 44,
padding: 13,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -441,28 +865,33 @@ export default function QrScreen() {
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="share-outline" size={18} color="#D4AF37" />
<Text style={{ color: "#D4AF37", fontSize: 14, fontWeight: "700" }}>
<Ionicons name="share-outline" size={17} color="#D4AF37" />
<Text style={{ color: "#D4AF37", fontSize: 13.5, fontWeight: "700" }}>
Menü Linkini Paylaş
</Text>
</Pressable>
{/* Quick Copy Link Box */}
{/* Hızlı Kopyalama Kutusu */}
<Pressable
onPress={handleCopy}
style={{
backgroundColor: "#141417",
accessible={true}
accessibilityRole="button"
accessibilityLabel={`Menü linkini kopyala: ${qr.targetUrl}`}
style={({ pressed }) => ({
backgroundColor: "#121215",
borderWidth: 1,
borderColor: "#242429",
borderRadius: 14,
padding: 14,
borderColor: "rgba(255,255,255,0.08)",
borderRadius: 12,
padding: 12,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 4,
}}
marginTop: 2,
opacity: pressed ? 0.85 : 1,
})}
>
<Text style={{ color: "#A1A1AA", fontSize: 12, flex: 1 }} numberOfLines={1}>
<Text style={{ color: "#A1A1AA", fontSize: 11.5, flex: 1 }} numberOfLines={1}>
{qr.targetUrl}
</Text>
<Ionicons
@@ -6,14 +6,19 @@ import {
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import { type CanvasTextLayer, getCanvasFontFamily } from "./types";
interface CanvasLayerItemProps {
layer: CanvasTextLayer;
isSelected: boolean;
onSelect: (id: string) => void;
onEdit: (layer: CanvasTextLayer) => void;
onUpdatePosition: (id: string, x: number, y: number) => void;
onDelete: (id: string) => void;
onDragStart?: () => void;
onDragMove?: (rawX: number, rawY: number, itemWidth: number, itemHeight: number) => { snappedX: number; snappedY: number };
onDragEnd?: (finalX: number, finalY: number, itemWidth: number, itemHeight: number) => void;
canvasWidth: number;
canvasHeight: number;
}
@@ -22,95 +27,213 @@ export const CanvasLayerItem: React.FC<CanvasLayerItemProps> = ({
layer,
isSelected,
onSelect,
onEdit,
onUpdatePosition,
onDelete,
onDragStart,
onDragMove,
onDragEnd,
canvasWidth,
canvasHeight,
}) => {
const layerRef = useRef(layer);
layerRef.current = layer;
const canvasWidthRef = useRef(canvasWidth);
canvasWidthRef.current = canvasWidth;
const canvasHeightRef = useRef(canvasHeight);
canvasHeightRef.current = canvasHeight;
const panStartRef = useRef({ x: layer.x, y: layer.y });
const layoutSizeRef = useRef({ width: 70, height: 26 });
const currentPosRef = useRef({ x: layer.x, y: layer.y });
const lastTapRef = useRef<number>(0);
const isDraggingRef = useRef(false);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_evt, gestureState) =>
Math.abs(gestureState.dx) > 1 || Math.abs(gestureState.dy) > 1,
onMoveShouldSetPanResponderCapture: (_evt, gestureState) =>
Math.abs(gestureState.dx) > 1 || Math.abs(gestureState.dy) > 1,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
onSelect(layer.id);
panStartRef.current = { x: layer.x, y: layer.y };
const now = Date.now();
const DOUBLE_TAP_DELAY = 300;
const curLayer = layerRef.current;
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch {}
onEdit(curLayer);
} else {
onSelect(curLayer.id);
}
lastTapRef.current = now;
panStartRef.current = { x: curLayer.x, y: curLayer.y };
currentPosRef.current = { x: curLayer.x, y: curLayer.y };
isDraggingRef.current = false;
onDragStart?.();
},
onPanResponderMove: (_evt, gestureState) => {
const newX = Math.max(0, Math.min(canvasWidth - 40, panStartRef.current.x + gestureState.dx));
const newY = Math.max(0, Math.min(canvasHeight - 30, panStartRef.current.y + gestureState.dy));
onUpdatePosition(layer.id, Math.round(newX), Math.round(newY));
if (!isDraggingRef.current && (Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2)) {
isDraggingRef.current = true;
}
const curLayer = layerRef.current;
const itemW = layoutSizeRef.current.width;
const itemH = layoutSizeRef.current.height;
const cWidth = canvasWidthRef.current;
const cHeight = canvasHeightRef.current;
const maxX = Math.max(0, cWidth - itemW);
const maxY = Math.max(0, cHeight - itemH);
const rawX = Math.max(0, Math.min(maxX, panStartRef.current.x + gestureState.dx));
const rawY = Math.max(0, Math.min(maxY, panStartRef.current.y + gestureState.dy));
if (onDragMove) {
const { snappedX, snappedY } = onDragMove(rawX, rawY, itemW, itemH);
currentPosRef.current = { x: snappedX, y: snappedY };
onUpdatePosition(curLayer.id, snappedX, snappedY);
} else {
const sx = Math.round(rawX);
const sy = Math.round(rawY);
currentPosRef.current = { x: sx, y: sy };
onUpdatePosition(curLayer.id, sx, sy);
}
},
onPanResponderRelease: () => {
const itemW = layoutSizeRef.current.width;
const itemH = layoutSizeRef.current.height;
onDragEnd?.(currentPosRef.current.x, currentPosRef.current.y, itemW, itemH);
},
onPanResponderTerminate: () => {
const itemW = layoutSizeRef.current.width;
const itemH = layoutSizeRef.current.height;
onDragEnd?.(currentPosRef.current.x, currentPosRef.current.y, itemW, itemH);
},
})
).current;
const fontFamily = getCanvasFontFamily(layer.fontId);
// Instagram Badge arka plan stili
const renderBackgroundStyle = () => {
switch (layer.bgStyle) {
case "solid":
return {
backgroundColor: layer.bgBoxColor || "rgba(18, 18, 21, 0.92)",
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 8,
};
case "soft":
return {
backgroundColor: "rgba(255, 255, 255, 0.18)",
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 8,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.25)",
};
case "outline":
return {
backgroundColor: "transparent",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
borderWidth: 1.5,
borderColor: layer.color || "#D4AF37",
};
case "none":
default:
return {
backgroundColor: "transparent",
paddingHorizontal: 4,
paddingVertical: 2,
};
}
};
return (
<View
{...panResponder.panHandlers}
accessible={true}
accessibilityRole="button"
accessibilityLabel={`Metin: ${layer.text}`}
accessibilityHint="Metni düzenlemek için çift dokunun, taşımak için sürükleyin"
accessibilityState={{ selected: isSelected }}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
layoutSizeRef.current = { width, height };
}}
style={{
position: "absolute",
left: layer.x,
top: layer.y,
transform: [{ rotate: `${layer.rotation || 0}deg` }],
zIndex: isSelected ? 100 : 10,
padding: 4,
zIndex: isSelected ? 90 : 20,
}}
>
<View
style={{
borderWidth: isSelected ? 1.5 : 0,
borderColor: "#D4AF37",
borderStyle: "dashed",
borderRadius: 8,
paddingHorizontal: layer.bgBoxColor ? 10 : 4,
paddingVertical: layer.bgBoxColor ? 5 : 2,
backgroundColor: layer.bgBoxColor || "transparent",
alignItems: "center",
justifyContent: "center",
borderColor: isSelected ? "#D4AF37" : "transparent",
borderStyle: isSelected ? "dashed" : "solid",
borderRadius: 10,
padding: 2,
}}
>
<Text
style={{
fontFamily,
fontSize: layer.fontSize,
color: layer.color,
fontWeight: layer.fontWeight || "bold",
letterSpacing: layer.letterSpacing || 0.5,
textAlign: "center",
textShadowColor: layer.hasShadow ? "rgba(0,0,0,0.85)" : "transparent",
textShadowOffset: layer.hasShadow ? { width: 1, height: 1 } : { width: 0, height: 0 },
textShadowRadius: layer.hasShadow ? 4 : 0,
}}
>
{layer.text || "Metin"}
</Text>
<View style={renderBackgroundStyle()}>
<Text
style={{
fontSize: layer.fontSize,
color: layer.color,
fontFamily: getCanvasFontFamily(layer.fontId),
fontWeight: layer.fontWeight || "bold",
textAlign: layer.textAlign || "center",
letterSpacing: layer.letterSpacing ?? 0.5,
textShadowColor: layer.hasShadow ? "rgba(0,0,0,0.8)" : "transparent",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
}}
>
{layer.text}
</Text>
</View>
{/* Seçili İken Hızlı Silme Rozeti */}
{/* Seçili İken Üst Köşedeki Küçük Silme Rozeti */}
{isSelected && (
<Pressable
onPress={() => onDelete(layer.id)}
onPress={(e) => {
e.stopPropagation?.();
try {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
} catch {}
onDelete(layer.id);
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={{
position: "absolute",
top: -12,
right: -12,
top: -8,
right: -8,
backgroundColor: "#EF4444",
width: 22,
height: 22,
borderRadius: 11,
width: 18,
height: 18,
borderRadius: 9,
alignItems: "center",
justifyContent: "center",
borderWidth: 1.5,
borderColor: "#FFFFFF",
shadowColor: "#000",
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 4,
borderColor: "#0C0C0E",
}}
>
<Ionicons name="close" size={12} color="#FFFFFF" />
<Ionicons name="close" size={11} color="#FFFFFF" />
</Pressable>
)}
</View>
@@ -0,0 +1,176 @@
import React, { useRef } from "react";
import {
Image,
PanResponder,
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { type CanvasQrConfig } from "./types";
interface CanvasQrItemProps {
qrConfig: CanvasQrConfig;
qrBase64?: string;
isSelected: boolean;
onSelect: () => void;
onUpdatePosition: (x: number, y: number) => void;
onDragStart?: () => void;
onDragMove?: (rawX: number, rawY: number, itemWidth: number, itemHeight: number) => { snappedX: number; snappedY: number };
onDragEnd?: (finalX: number, finalY: number, itemWidth: number, itemHeight: number) => void;
canvasWidth: number;
canvasHeight: number;
}
export const CanvasQrItem: React.FC<CanvasQrItemProps> = ({
qrConfig,
qrBase64,
isSelected,
onSelect,
onUpdatePosition,
onDragStart,
onDragMove,
onDragEnd,
canvasWidth,
canvasHeight,
}) => {
// Stale closure önlemi için ref'ler
const qrConfigRef = useRef(qrConfig);
qrConfigRef.current = qrConfig;
const canvasWidthRef = useRef(canvasWidth);
canvasWidthRef.current = canvasWidth;
const canvasHeightRef = useRef(canvasHeight);
canvasHeightRef.current = canvasHeight;
const panStartRef = useRef({ x: qrConfig.x, y: qrConfig.y });
const currentPosRef = useRef({ x: qrConfig.x, y: qrConfig.y });
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_evt, gestureState) =>
Math.abs(gestureState.dx) > 1 || Math.abs(gestureState.dy) > 1,
onMoveShouldSetPanResponderCapture: (_evt, gestureState) =>
Math.abs(gestureState.dx) > 1 || Math.abs(gestureState.dy) > 1,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
onSelect();
const cur = qrConfigRef.current;
panStartRef.current = { x: cur.x, y: cur.y };
currentPosRef.current = { x: cur.x, y: cur.y };
onDragStart?.();
},
onPanResponderMove: (_evt, gestureState) => {
const cur = qrConfigRef.current;
const cWidth = canvasWidthRef.current;
const cHeight = canvasHeightRef.current;
const maxX = Math.max(0, cWidth - cur.size);
const maxY = Math.max(0, cHeight - cur.size);
const rawX = Math.max(0, Math.min(maxX, panStartRef.current.x + gestureState.dx));
const rawY = Math.max(0, Math.min(maxY, panStartRef.current.y + gestureState.dy));
if (onDragMove) {
const { snappedX, snappedY } = onDragMove(rawX, rawY, cur.size, cur.size);
currentPosRef.current = { x: snappedX, y: snappedY };
onUpdatePosition(snappedX, snappedY);
} else {
const sx = Math.round(rawX);
const sy = Math.round(rawY);
currentPosRef.current = { x: sx, y: sy };
onUpdatePosition(sx, sy);
}
},
onPanResponderRelease: () => {
const cur = qrConfigRef.current;
onDragEnd?.(currentPosRef.current.x, currentPosRef.current.y, cur.size, cur.size);
},
onPanResponderTerminate: () => {
const cur = qrConfigRef.current;
onDragEnd?.(currentPosRef.current.x, currentPosRef.current.y, cur.size, cur.size);
},
})
).current;
return (
<View
{...panResponder.panHandlers}
accessible={true}
accessibilityRole="button"
accessibilityLabel="QR Kod Alanı"
accessibilityHint="QR kodunu seçmek, boyutunu ayarlamak veya stant üzerinde taşımak için dokunun"
accessibilityState={{ selected: isSelected }}
style={{
position: "absolute",
left: qrConfig.x,
top: qrConfig.y,
width: qrConfig.size,
height: qrConfig.size,
zIndex: isSelected ? 80 : 15,
}}
>
<View
style={{
width: "100%",
height: "100%",
backgroundColor: qrConfig.bgBoxColor || "#FFFFFF",
borderRadius: qrConfig.borderRadius,
borderWidth: isSelected ? 2 : qrConfig.borderWidth,
borderColor: isSelected ? "#D4AF37" : qrConfig.borderColor,
borderStyle: isSelected ? "dashed" : "solid",
padding: qrConfig.padding,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 6,
elevation: 4,
}}
>
{qrBase64 ? (
<Image
source={{ uri: `data:image/png;base64,${qrBase64}` }}
style={{
width: "100%",
height: "100%",
borderRadius: Math.max(0, qrConfig.borderRadius - 4),
}}
resizeMode="contain"
/>
) : (
<View style={{ alignItems: "center", justifyContent: "center", width: "100%", height: "100%" }}>
<Ionicons name="qr-code" size={Math.round(qrConfig.size * 0.7)} color="#0C0C0E" />
</View>
)}
{/* Seçili İken Taşıma Rozeti */}
{isSelected && (
<View
style={{
position: "absolute",
top: -8,
right: -8,
backgroundColor: "#D4AF37",
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
borderWidth: 1.5,
borderColor: "#0C0C0E",
}}
>
<Ionicons name="move" size={12} color="#0C0C0E" />
</View>
)}
</View>
</View>
);
};
@@ -0,0 +1,610 @@
import React, { useEffect, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import {
type CanvasTextLayer,
CANVAS_FONT_OPTIONS,
CANVAS_COLOR_PALETTE,
getCanvasFontFamily,
} from "./types";
interface InstagramTextEditorModalProps {
visible: boolean;
layer: CanvasTextLayer | null;
onSave: (updated: Partial<CanvasTextLayer>) => void;
onClose: () => void;
onDelete?: (id: string) => void;
}
export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> = ({
visible,
layer,
onSave,
onClose,
onDelete,
}) => {
const insets = useSafeAreaInsets();
const inputRef = useRef<TextInput>(null);
const [text, setText] = useState("");
const [fontId, setFontId] = useState("sans");
const [color, setColor] = useState("#FFFFFF");
const [fontSize, setFontSize] = useState(16);
const [textAlign, setTextAlign] = useState<"left" | "center" | "right">("center");
const [bgStyle, setBgStyle] = useState<"none" | "solid" | "soft" | "outline">("none");
const [bgBoxColor, setBgBoxColor] = useState<string | undefined>(undefined);
// Modal açıldığında layer verilerini yükle
useEffect(() => {
if (layer && visible) {
setText(layer.text || "");
setFontId(layer.fontId || "sans");
setColor(layer.color || "#FFFFFF");
setFontSize(layer.fontSize || 16);
setTextAlign(layer.textAlign || "center");
if (layer.bgBoxColor) {
if (layer.bgBoxColor.includes("0.2") || layer.bgBoxColor.includes("0.3")) {
setBgStyle("soft");
} else {
setBgStyle("solid");
}
setBgBoxColor(layer.bgBoxColor);
} else {
setBgStyle("none");
setBgBoxColor(undefined);
}
// Auto focus text input
setTimeout(() => {
inputRef.current?.focus();
}, 150);
}
}, [layer, visible]);
// Arka plan pill stilini sırayla değiştir (Instagram "A" butonu mantığı)
const handleToggleBgStyle = () => {
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} catch {}
if (bgStyle === "none") {
setBgStyle("solid");
// Solid: seçili rengi kutu yap, metin rengini zıt yap
if (color === "#FFFFFF" || color === "#F6EFE3") {
setBgBoxColor("rgba(18, 18, 21, 0.92)");
} else {
setBgBoxColor("rgba(212, 175, 55, 0.92)");
}
} else if (bgStyle === "solid") {
setBgStyle("soft");
setBgBoxColor("rgba(255, 255, 255, 0.22)");
} else if (bgStyle === "soft") {
setBgStyle("outline");
setBgBoxColor(undefined);
} else {
setBgStyle("none");
setBgBoxColor(undefined);
}
};
// Metin Hizalama Değiştir (Sol -> Orta -> Sağ)
const handleToggleAlign = () => {
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} catch {}
setTextAlign((prev) => {
if (prev === "center") return "left";
if (prev === "left") return "right";
return "center";
});
};
// Renk Değiştir
const handleSelectColor = (selectedColor: string) => {
try {
Haptics.selectionAsync();
} catch {}
setColor(selectedColor);
if (bgStyle === "solid") {
if (selectedColor === "#FFFFFF" || selectedColor === "#F6EFE3") {
setBgBoxColor("rgba(18, 18, 21, 0.92)");
} else if (selectedColor === "#121215") {
setBgBoxColor("rgba(255, 255, 255, 0.92)");
} else {
setBgBoxColor(selectedColor);
}
}
};
// Font Değiştir
const handleSelectFont = (fId: string) => {
try {
Haptics.selectionAsync();
} catch {}
setFontId(fId);
};
// Bitti / Kaydet
const handleDone = () => {
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch {}
let finalBg = bgBoxColor;
if (bgStyle === "none" || bgStyle === "outline") {
finalBg = undefined;
}
onSave({
text: text.trim() ? text : "Metin",
fontId,
color,
fontSize,
textAlign,
bgBoxColor: finalBg,
bgStyle,
hasShadow: bgStyle === "outline" || (!finalBg && color !== "#121215"),
});
onClose();
};
const fontFamily = getCanvasFontFamily(fontId);
if (!visible || !layer) return null;
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={onClose}
>
<View style={styles.overlayBackdrop}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={[styles.container, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 12 }]}
>
{/* ÜST BAR - INSTAGRAM STORY KONTROLLERİ */}
<View style={styles.topBar}>
{/* Sol: İptal / Kapat */}
<Pressable
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={styles.iconButton}
>
<Ionicons name="close" size={24} color="#FFFFFF" />
</Pressable>
{/* Orta Araçlar: Hizalama & Arka Plan Rozeti */}
<View style={styles.centerTools}>
{/* Hizalama Butonu */}
<Pressable
onPress={handleToggleAlign}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.toolPill}
>
<Ionicons
name={
textAlign === "left"
? "reorder-three-outline"
: textAlign === "right"
? "reorder-three-outline"
: "reorder-two-outline"
}
size={20}
color="#FFFFFF"
/>
</Pressable>
{/* Instagram "A" Arka Plan Butonu */}
<Pressable
onPress={handleToggleBgStyle}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={[
styles.toolPill,
bgStyle !== "none" && { backgroundColor: "#FFFFFF" },
]}
>
<Text
style={[
styles.bgPillText,
bgStyle !== "none" && { color: "#0C0C0E", fontWeight: "900" },
]}
>
A
</Text>
{bgStyle === "soft" && (
<View style={styles.softIndicator} />
)}
{bgStyle === "outline" && (
<View style={styles.outlineIndicator} />
)}
</Pressable>
{/* Katmanı Sil (Varsa) */}
{onDelete && (
<Pressable
onPress={() => {
try {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
} catch {}
onDelete(layer.id);
onClose();
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={[styles.toolPill, { backgroundColor: "rgba(239, 68, 68, 0.2)" }]}
>
<Ionicons name="trash-outline" size={17} color="#EF4444" />
</Pressable>
)}
</View>
{/* Sağ: BİTTİ (Done) Butonu */}
<Pressable
onPress={handleDone}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.doneButton}
>
<Text style={styles.doneButtonText}>Bitti</Text>
</Pressable>
</View>
{/* ORTA BÖLÜM: SOL BOYUT SLIDER & CANLI METİN ALANI */}
<View style={styles.editorBody}>
{/* Sol: Dikey Boyut Stepper / Gösterge */}
<View style={styles.sizeControlCol}>
<Pressable
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
setFontSize((s) => Math.min(48, s + 2));
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.sizeStepBtn}
>
<Ionicons name="add" size={16} color="#FFFFFF" />
</Pressable>
<View style={styles.sizeTrack}>
<View
style={[
styles.sizeTrackFill,
{ height: `${Math.min(100, Math.max(10, ((fontSize - 8) / 40) * 100))}%` },
]}
/>
</View>
<Pressable
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
setFontSize((s) => Math.max(8, s - 2));
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.sizeStepBtn}
>
<Ionicons name="remove" size={16} color="#FFFFFF" />
</Pressable>
<Text style={styles.sizeLabel}>{fontSize}pt</Text>
</View>
{/* Orta: Canlı İnstagram Yazı Alanı */}
<View style={styles.textInputArea}>
<View
style={[
styles.textPillWrapper,
bgStyle === "solid" && {
backgroundColor: bgBoxColor || "#D4AF37",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 8,
},
bgStyle === "soft" && {
backgroundColor: "rgba(255, 255, 255, 0.22)",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 8,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.3)",
},
bgStyle === "outline" && {
borderWidth: 1.5,
borderColor: color,
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 6,
},
]}
>
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
multiline
placeholder="Yazmaya başla..."
placeholderTextColor="rgba(255,255,255,0.4)"
textAlign={textAlign}
autoCapitalize="sentences"
style={[
styles.textInput,
{
fontFamily,
fontSize,
color:
bgStyle === "solid" && (bgBoxColor === "#FFFFFF" || bgBoxColor === "#F6EFE3")
? "#0C0C0E"
: color,
textAlign,
letterSpacing: fontId === "serif" ? 1.5 : 0.5,
textShadowColor: bgStyle === "outline" ? color : "rgba(0,0,0,0.7)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: bgStyle === "outline" ? 8 : 4,
},
]}
/>
</View>
</View>
</View>
{/* ALT BÖLÜM 1: RENK SEÇİCİ BALONCUKLAR */}
<View style={styles.colorPickerBar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.colorScrollContent}
>
{CANVAS_COLOR_PALETTE.map((c) => {
const isSelected = color === c.color;
return (
<Pressable
key={c.id}
onPress={() => handleSelectColor(c.color)}
style={[
styles.colorCircleWrapper,
isSelected && { borderColor: "#FFFFFF", transform: [{ scale: 1.15 }] },
]}
>
<View
style={[
styles.colorCircle,
{ backgroundColor: c.color },
c.color === "#121215" && { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" },
]}
/>
</Pressable>
);
})}
</ScrollView>
</View>
{/* ALT BÖLÜM 2: INSTAGRAM FONT STİLLERİ CAROUSEL */}
<View style={styles.fontPickerBar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.fontScrollContent}
>
{CANVAS_FONT_OPTIONS.map((f) => {
const isSelected = fontId === f.id;
return (
<Pressable
key={f.id}
onPress={() => handleSelectFont(f.id)}
style={[
styles.fontPill,
isSelected && styles.fontPillActive,
]}
>
<Text
style={[
styles.fontPillText,
{ fontFamily: f.fontFamily },
isSelected && styles.fontPillTextActive,
]}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlayBackdrop: {
flex: 1,
backgroundColor: "rgba(10, 10, 14, 0.94)",
},
container: {
flex: 1,
justifyContent: "space-between",
},
topBar: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
height: 52,
zIndex: 10,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.12)",
alignItems: "center",
justifyContent: "center",
},
centerTools: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
toolPill: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: "rgba(255, 255, 255, 0.12)",
alignItems: "center",
justifyContent: "center",
position: "relative",
},
bgPillText: {
color: "#FFFFFF",
fontSize: 16,
fontWeight: "700",
},
softIndicator: {
position: "absolute",
bottom: 4,
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: "#60A5FA",
},
outlineIndicator: {
position: "absolute",
bottom: 4,
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: "#F59E0B",
},
doneButton: {
backgroundColor: "#FFFFFF",
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
doneButtonText: {
color: "#0C0C0E",
fontSize: 14,
fontWeight: "800",
},
editorBody: {
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
},
sizeControlCol: {
width: 36,
alignItems: "center",
gap: 8,
},
sizeStepBtn: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: "rgba(255, 255, 255, 0.15)",
alignItems: "center",
justifyContent: "center",
},
sizeTrack: {
width: 4,
height: 120,
backgroundColor: "rgba(255, 255, 255, 0.18)",
borderRadius: 2,
justifyContent: "flex-end",
overflow: "hidden",
},
sizeTrackFill: {
width: "100%",
backgroundColor: "#D4AF37",
borderRadius: 2,
},
sizeLabel: {
color: "rgba(255, 255, 255, 0.6)",
fontSize: 10,
fontWeight: "700",
},
textInputArea: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
textPillWrapper: {
maxWidth: "100%",
alignItems: "center",
justifyContent: "center",
},
textInput: {
minWidth: 140,
maxWidth: "100%",
padding: 0,
margin: 0,
fontWeight: "800",
},
colorPickerBar: {
paddingVertical: 10,
},
colorScrollContent: {
paddingHorizontal: 20,
gap: 12,
alignItems: "center",
},
colorCircleWrapper: {
width: 32,
height: 32,
borderRadius: 16,
borderWidth: 2,
borderColor: "transparent",
alignItems: "center",
justifyContent: "center",
},
colorCircle: {
width: 24,
height: 24,
borderRadius: 12,
},
fontPickerBar: {
paddingVertical: 8,
},
fontScrollContent: {
paddingHorizontal: 16,
gap: 8,
alignItems: "center",
},
fontPill: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
},
fontPillActive: {
backgroundColor: "#FFFFFF",
borderColor: "#FFFFFF",
},
fontPillText: {
color: "rgba(255, 255, 255, 0.8)",
fontSize: 12,
fontWeight: "700",
letterSpacing: 0.5,
},
fontPillTextActive: {
color: "#0C0C0E",
fontWeight: "900",
},
});
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
export * from "./types";
export * from "./CanvasLayerItem";
export * from "./CanvasQrItem";
export * from "./InstagramTextEditorModal";
export * from "./QrCanvasStudio";
+104 -12
View File
@@ -7,38 +7,61 @@ export interface CanvasTextLayer {
fontSize: number;
fontId: string;
color: string;
bgBoxColor?: string; // Instagram badge kutusu (örn: rgba(0,0,0,0.8), #D4AF37 vb.)
bgBoxColor?: string; // Instagram badge kutusu
bgStyle?: "none" | "solid" | "soft" | "outline";
textAlign?: "left" | "center" | "right";
hasShadow?: boolean;
letterSpacing?: number;
fontWeight?: "normal" | "600" | "700" | "800" | "900";
}
export interface CanvasQrConfig {
x: number;
y: number;
size: number;
borderRadius: number;
borderWidth: number;
borderColor: string;
padding: number;
bgBoxColor: string;
}
export const CANVAS_FONT_OPTIONS = [
{
id: "sans",
label: "Modern Sans",
label: "MODERN",
fontFamily: "System, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif",
},
{
id: "serif",
label: "Zarif Serif",
label: "ZARİF",
fontFamily: "Georgia, 'Times New Roman', serif",
},
{
id: "times",
label: "Klasik Times",
label: "KLASİK",
fontFamily: "'Times New Roman', Times, Georgia, serif",
},
{
id: "typewriter",
label: "DAKTİLO",
fontFamily: "Courier, 'Courier New', monospace",
},
{
id: "palatino",
label: "Lüks Palatino",
label: "LÜKS",
fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
},
{
id: "trebuchet",
label: "Dinamik",
label: "DİNAMİK",
fontFamily: "'Trebuchet MS', 'Segoe UI', Arial, sans-serif",
},
{
id: "heavy",
label: "GÜÇLÜ",
fontFamily: "Impact, 'Arial Black', sans-serif",
},
];
export const CANVAS_COLOR_PALETTE = [
@@ -46,16 +69,41 @@ export const CANVAS_COLOR_PALETTE = [
{ id: "white", color: "#FFFFFF", label: "Beyaz" },
{ id: "black", color: "#121215", label: "Siyah" },
{ id: "cream", color: "#F6EFE3", label: "Krem" },
{ id: "emerald", color: "#0C4443", label: "Zümrüt" },
{ id: "crimson", color: "#A64423", label: "Kızıl" },
{ id: "blue", color: "#003554", label: "Lacivert" },
{ id: "emerald", color: "#10B981", label: "Yeşil" },
{ id: "crimson", color: "#EF4444", label: "Kırmızı" },
{ id: "amber", color: "#F59E0B", label: "Amber" },
{ id: "rose", color: "#EC4899", label: "Pembe" },
{ id: "indigo", color: "#6366F1", label: "Lila" },
{ id: "cyan", color: "#06B6D4", label: "Turkuaz" },
];
export const INSTAGRAM_BADGE_STYLES = [
{ id: "none", label: "Düz", bgBoxColor: undefined },
{ id: "dark", label: "Siyah Kutu", bgBoxColor: "rgba(18, 18, 21, 0.88)" },
{ id: "gold", label: "Altın Kutu", bgBoxColor: "rgba(212, 175, 55, 0.92)" },
{ id: "white", label: "Beyaz Kutu", bgBoxColor: "rgba(255, 255, 255, 0.92)" },
{ id: "dark", label: "Koyu", bgBoxColor: "rgba(18, 18, 21, 0.88)" },
{ id: "gold", label: "Altın", bgBoxColor: "rgba(212, 175, 55, 0.92)" },
{ id: "white", label: "Beyaz", bgBoxColor: "rgba(255, 255, 255, 0.92)" },
{ id: "glass", label: "Cam", bgBoxColor: "rgba(255, 255, 255, 0.2)" },
];
export const QR_SIZE_PRESETS = [
{ label: "Kompakt", size: 85 },
{ label: "Standart", size: 115 },
{ label: "Büyük", size: 145 },
{ label: "Geniş", size: 175 },
];
export const QR_RADIUS_PRESETS = [
{ label: "Düz", radius: 0 },
{ label: "Hafif", radius: 10 },
{ label: "Yuvarlak", radius: 18 },
{ label: "Oval", radius: 28 },
];
export const QR_BORDER_COLORS = [
{ id: "gold", color: "#D4AF37", label: "Altın" },
{ id: "black", color: "#121215", label: "Siyah" },
{ id: "white", color: "#FFFFFF", label: "Beyaz" },
{ id: "none", color: "transparent", label: "Yok" },
];
export function getCanvasFontFamily(fontId?: string): string {
@@ -63,6 +111,39 @@ export function getCanvasFontFamily(fontId?: string): string {
return found ? found.fontFamily : "System, -apple-system, Roboto, sans-serif";
}
export function getDefaultQrConfigForTemplate(
templateKey: string,
canvasWidth: number,
canvasHeight: number
): CanvasQrConfig {
if (templateKey === "qr-1" || templateKey === "qr-8" || templateKey === "qr-9") {
const size = 110;
return {
x: Math.round((canvasWidth - size) / 2),
y: 102,
size,
borderRadius: 14,
borderWidth: 1.5,
borderColor: "#D4AF37",
padding: 6,
bgBoxColor: "#FFFFFF",
};
}
// Standart Dikey Şablonlar (qr-2, qr-3, qr-4, qr-5, qr-6)
const size = 125;
return {
x: Math.round((canvasWidth - size) / 2),
y: Math.round(canvasHeight * 0.36),
size,
borderRadius: 14,
borderWidth: 1.5,
borderColor: "#D4AF37",
padding: 6,
bgBoxColor: "#FFFFFF",
};
}
// Her şablon için başlangıç varsayılan katmanları (1:1 veya standart orana göre)
export function getDefaultLayersForTemplate(templateKey: string, restaurantName = "Gourmet Bistro"): CanvasTextLayer[] {
if (templateKey === "qr-1") {
@@ -78,6 +159,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
color: "#D4AF37",
fontWeight: "800",
letterSpacing: 2,
textAlign: "center",
},
{
id: "sub",
@@ -90,6 +172,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
color: "#E5C378",
fontWeight: "600",
letterSpacing: 1.5,
textAlign: "center",
},
{
id: "cta",
@@ -102,6 +185,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
color: "#D4AF37",
fontWeight: "700",
letterSpacing: 1,
textAlign: "center",
},
{
id: "table",
@@ -113,6 +197,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "sans",
color: "#C5A059",
fontWeight: "600",
textAlign: "center",
},
];
}
@@ -130,6 +215,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
color: "#FFFFFF",
fontWeight: "900",
letterSpacing: 1,
textAlign: "center",
},
{
id: "sub",
@@ -141,6 +227,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "sans",
color: "#D1A752",
fontWeight: "800",
textAlign: "center",
},
{
id: "cta",
@@ -152,6 +239,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "sans",
color: "#FFFFFF",
fontWeight: "800",
textAlign: "center",
},
{
id: "wifi",
@@ -163,6 +251,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "sans",
color: "#D1A752",
fontWeight: "800",
textAlign: "center",
},
];
}
@@ -179,6 +268,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "serif",
color: "#D4AF37",
fontWeight: "800",
textAlign: "center",
},
{
id: "sub",
@@ -190,6 +280,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "sans",
color: "#FFFFFF",
fontWeight: "700",
textAlign: "center",
},
{
id: "cta",
@@ -201,6 +292,7 @@ export function getDefaultLayersForTemplate(templateKey: string, restaurantName
fontId: "serif",
color: "#D4AF37",
fontWeight: "800",
textAlign: "center",
},
];
}
@@ -8,11 +8,12 @@ import Svg, {
Text,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
const QrTemplate2 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -159,48 +160,50 @@ const QrTemplate2 = ({
<Path stroke="#8c6239" strokeWidth={0.8} d="M145 608h30m50 0h30" />
{/* QR Placeholder / Gerçek QR */}
<G transform="translate(90 215)">
<Rect width={220} height={220} fill="#fbf8f2" stroke="#d8c7b0" strokeWidth={1.5} rx={20} />
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={200}
height={200}
/>
) : (
<>
<Rect
width={200}
height={200}
{!hideQr && (
<G transform="translate(90 215)">
<Rect width={220} height={220} fill="#fbf8f2" stroke="#d8c7b0" strokeWidth={1.5} rx={20} />
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
fill="#f5efe6"
stroke="#d8c7b0"
strokeDasharray="4 3"
rx={14}
width={200}
height={200}
/>
<Circle cx={110} cy={98} r={18} fill="#fbf8f2" stroke="#8c6239" />
<G fill="#3a2312" transform="matrix(.65 0 0 .65 110 98)">
<Ellipse cx={-5} cy={2} rx={7} ry={5} transform="rotate(-30)" />
<Ellipse cx={5} rx={7} ry={5} transform="rotate(40)" />
</G>
<Text
x={110}
y={132}
fill="#8c6239"
fontFamily="System, sans-serif"
fontSize={11}
fontWeight={700}
letterSpacing={1}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Rect
width={200}
height={200}
x={10}
y={10}
fill="#f5efe6"
stroke="#d8c7b0"
strokeDasharray="4 3"
rx={14}
/>
<Circle cx={110} cy={98} r={18} fill="#fbf8f2" stroke="#8c6239" />
<G fill="#3a2312" transform="matrix(.65 0 0 .65 110 98)">
<Ellipse cx={-5} cy={2} rx={7} ry={5} transform="rotate(-30)" />
<Ellipse cx={5} rx={7} ry={5} transform="rotate(40)" />
</G>
<Text
x={110}
y={132}
fill="#8c6239"
fontFamily="System, sans-serif"
fontSize={11}
fontWeight={700}
letterSpacing={1}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Metinler (Çok satırlı destekli) */}
<G textAnchor="middle">
@@ -12,11 +12,12 @@ import Svg, {
Text,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
const QrTemplate3 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -213,42 +214,44 @@ const QrTemplate3 = ({
</G>
{/* QR Placeholder / Gerçek QR */}
<G id="qr-placeholder" transform="translate(90 215)">
<Rect width={220} height={220} fill="#fff" stroke="#e4e4e7" strokeWidth={2} rx={20} />
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={200}
height={200}
/>
) : (
<>
<Rect
width={196}
height={196}
x={12}
y={12}
fill="#fafafa"
stroke="#e4e4e7"
strokeDasharray="4 3"
rx={14}
{!hideQr && (
<G id="qr-placeholder" transform="translate(90 215)">
<Rect width={220} height={220} fill="#fff" stroke="#e4e4e7" strokeWidth={2} rx={20} />
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={200}
height={200}
/>
<Text
x={110}
y={115}
fill="#71717a"
fontFamily="System, sans-serif"
fontSize={12}
fontWeight={700}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Rect
width={196}
height={196}
x={12}
y={12}
fill="#fafafa"
stroke="#e4e4e7"
strokeDasharray="4 3"
rx={14}
/>
<Text
x={110}
y={115}
fill="#71717a"
fontFamily="System, sans-serif"
fontSize={12}
fontWeight={700}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Metinler (Çok satırlı destekli) */}
<G id="layer-texts">
@@ -9,11 +9,12 @@ import Svg, {
Text,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
const QrTemplate4 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -136,52 +137,54 @@ const QrTemplate4 = ({
d="M114 278v-58h54m118 58v-58h-54M114 342v58h54m118-58v58h-54"
/>
{/* QR Placeholder / Live QR */}
<G transform="translate(125 230)">
<Rect
width={150}
height={150}
fill="#fff"
stroke="#eae5d9"
strokeWidth={1.5}
rx={12}
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={8}
y={8}
width={134}
height={134}
{/* QR Placeholder / Gerçek QR */}
{!hideQr && (
<G transform="translate(125 230)">
<Rect
width={150}
height={150}
fill="#fff"
stroke="#eae5d9"
strokeWidth={1.5}
rx={12}
/>
) : (
<>
<Rect
width={134}
height={134}
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={8}
y={8}
fill="#fafaf8"
stroke="#c59b4b"
strokeDasharray="4 3"
strokeOpacity={0.35}
rx={8}
width={134}
height={134}
/>
<Text
x={75}
y={79}
fill="#c59b4b"
fontFamily="System, sans-serif"
fontSize={10}
fontWeight={700}
letterSpacing={1}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Rect
width={134}
height={134}
x={8}
y={8}
fill="#fafaf8"
stroke="#c59b4b"
strokeDasharray="4 3"
strokeOpacity={0.35}
rx={8}
/>
<Text
x={75}
y={79}
fill="#c59b4b"
fontFamily="System, sans-serif"
fontSize={10}
fontWeight={700}
letterSpacing={1}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Metinler */}
<G fill="#111" textAnchor="middle">
@@ -6,7 +6,7 @@ import Svg, {
Path,
Text,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bgImage = require("../../../assets/templates/5.png");
@@ -14,6 +14,7 @@ const bgImage = require("../../../assets/templates/5.png");
const QrTemplate5 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -39,58 +40,60 @@ const QrTemplate5 = ({
/>
{/* QR Placeholder / Live QR */}
<G transform="translate(108 206)">
<Rect
width={164}
height={164}
fill="#fff"
stroke="#dce5ea"
strokeWidth={1.2}
rx={16}
/>
<Path
fill="none"
stroke="#003554"
strokeLinecap="round"
strokeWidth={3}
d="M6 30V12a6 6 0 0 1 6-6h18m128 24V12a6 6 0 0 0-6-6h-18M6 134v18a6 6 0 0 0 6 6h18m128-24v18a6 6 0 0 1-6 6h-18"
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={12}
y={12}
width={140}
height={140}
{!hideQr && (
<G transform="translate(108 206)">
<Rect
width={164}
height={164}
fill="#fff"
stroke="#dce5ea"
strokeWidth={1.2}
rx={16}
/>
) : (
<>
<Rect
width={140}
height={140}
<Path
fill="none"
stroke="#003554"
strokeLinecap="round"
strokeWidth={3}
d="M6 30V12a6 6 0 0 1 6-6h18m128 24V12a6 6 0 0 0-6-6h-18M6 134v18a6 6 0 0 0 6 6h18m128-24v18a6 6 0 0 1-6 6h-18"
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={12}
y={12}
fill="#fafcfd"
stroke="#003554"
strokeDasharray="4 3"
strokeOpacity={0.2}
rx={10}
width={140}
height={140}
/>
<Text
x={82}
y={87}
fill="#003554"
fontFamily="System, sans-serif"
fontSize={11}
fontWeight={700}
letterSpacing={1}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Rect
width={140}
height={140}
x={12}
y={12}
fill="#fafcfd"
stroke="#003554"
strokeDasharray="4 3"
strokeOpacity={0.2}
rx={10}
/>
<Text
x={82}
y={87}
fill="#003554"
fontFamily="System, sans-serif"
fontSize={11}
fontWeight={700}
letterSpacing={1}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Metinler */}
<G fill="#003554" textAnchor="middle">
@@ -9,11 +9,12 @@ import Svg, {
Text,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
const QrTemplate6 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -113,59 +114,61 @@ const QrTemplate6 = ({
<Use href="#b6" transform="matrix(-.95 0 0 .95 360 360)" />
{/* QR Placeholder / Gerçek QR */}
<G transform="translate(95 205)">
<Path
fill="#fdfbf7"
stroke="#122a42"
strokeWidth={3}
d="M0 0h210v210H0z"
/>
<Path
fill="none"
stroke="#c59b58"
strokeWidth={1.5}
d="M4 4h202v202H4z"
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={190}
height={190}
{!hideQr && (
<G transform="translate(95 205)">
<Path
fill="#fdfbf7"
stroke="#122a42"
strokeWidth={3}
d="M0 0h210v210H0z"
/>
) : (
<>
<Path
fill="#faf8f5"
stroke="#122a42"
strokeDasharray="4 3"
d="M12 12h186v186H12z"
<Path
fill="none"
stroke="#c59b58"
strokeWidth={1.5}
d="M4 4h202v202H4z"
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={190}
height={190}
/>
<Circle
cx={105}
cy={95}
r={16}
fill="#f6efe3"
stroke="#c59b58"
strokeWidth={1.2}
/>
<Use href="#a6" transform="matrix(.45 0 0 .45 105 93)" />
<Text
x={105}
y={128}
fill="#122a42"
fontFamily="System, sans-serif"
fontSize={11}
fontWeight="bold"
letterSpacing={1.5}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Path
fill="#faf8f5"
stroke="#122a42"
strokeDasharray="4 3"
d="M12 12h186v186H12z"
/>
<Circle
cx={105}
cy={95}
r={16}
fill="#f6efe3"
stroke="#c59b58"
strokeWidth={1.2}
/>
<Use href="#a6" transform="matrix(.45 0 0 .45 105 93)" />
<Text
x={105}
y={128}
fill="#122a42"
fontFamily="System, sans-serif"
fontSize={11}
fontWeight="bold"
letterSpacing={1.5}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Metinler */}
<G textAnchor="middle">
@@ -7,7 +7,7 @@ import Svg, {
Text,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const template8Bg = require("../../../assets/templates/8.jpeg");
@@ -15,6 +15,7 @@ const template8Bg = require("../../../assets/templates/8.jpeg");
const QrTemplate8 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -40,57 +41,59 @@ const QrTemplate8 = ({
/>
{/* QR Alanı (Merkez) */}
<G transform="translate(348 305)">
<Rect
width={328}
height={336}
fill="#f9f5eb"
stroke="#d1a752"
strokeWidth={1.5}
rx={16}
/>
<Rect
width={304}
height={312}
x={12}
y={12}
fill="#faf8f2"
stroke="#0c4443"
strokeDasharray="6 4"
strokeOpacity={0.3}
strokeWidth={1.5}
rx={12}
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={18}
y={18}
width={292}
height={300}
{!hideQr && (
<G transform="translate(348 305)">
<Rect
width={328}
height={336}
fill="#f9f5eb"
stroke="#d1a752"
strokeWidth={1.5}
rx={16}
/>
) : (
<>
<Circle cx={164} cy={148} r={28} fill="#0c4443" />
<G fill="#d1a752">
<Path d="M164 132.6c4.4 6.6 4.4 15.4 0 24.2-4.4-8.8-4.4-17.6 0-24.2" />
<Path d="M161.8 150.2c-8.8-4.4-13.2 4.4-6.6 8.8 6.6 0 8.8-6.6 6.6-8.8m4.4 0c8.8-4.4 13.2 4.4 6.6 8.8-6.6 0-8.8-6.6-6.6-8.8" />
</G>
<Text
x={164}
y={218}
fill="#0c4443"
fontFamily="System, -apple-system, Roboto, sans-serif"
fontSize={14}
fontWeight={800}
letterSpacing={2}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
<Rect
width={304}
height={312}
x={12}
y={12}
fill="#faf8f2"
stroke="#0c4443"
strokeDasharray="6 4"
strokeOpacity={0.3}
strokeWidth={1.5}
rx={12}
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={18}
y={18}
width={292}
height={300}
/>
) : (
<>
<Circle cx={164} cy={148} r={28} fill="#0c4443" />
<G fill="#d1a752">
<Path d="M164 132.6c4.4 6.6 4.4 15.4 0 24.2-4.4-8.8-4.4-17.6 0-24.2" />
<Path d="M161.8 150.2c-8.8-4.4-13.2 4.4-6.6 8.8 6.6 0 8.8-6.6 6.6-8.8m4.4 0c8.8-4.4 13.2 4.4 6.6 8.8-6.6 0-8.8-6.6-6.6-8.8" />
</G>
<Text
x={164}
y={218}
fill="#0c4443"
fontFamily="System, -apple-system, Roboto, sans-serif"
fontSize={14}
fontWeight={800}
letterSpacing={2}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Üst Çizgiler & OR Ayracı */}
<Path
@@ -9,7 +9,7 @@ import Svg, {
TextPath,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const template9Bg = require("../../../assets/templates/9.jpeg");
@@ -17,6 +17,7 @@ const template9Bg = require("../../../assets/templates/9.jpeg");
const QrTemplate9 = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -46,68 +47,70 @@ const QrTemplate9 = ({
/>
{/* QR Alanı (Merkez) */}
<G transform="translate(362 428)">
<Rect
width={300}
height={300}
fill="#fff"
stroke="#d5cebe"
strokeWidth={1.5}
rx={24}
/>
<Rect
width={276}
height={276}
x={12}
y={12}
fill="#fafaf7"
stroke="#1e3246"
strokeDasharray="6 4"
strokeOpacity={0.35}
strokeWidth={1.5}
rx={16}
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={16}
y={16}
width={268}
height={268}
{!hideQr && (
<G transform="translate(362 428)">
<Rect
width={300}
height={300}
fill="#fff"
stroke="#d5cebe"
strokeWidth={1.5}
rx={24}
/>
) : (
<>
<Circle
cx={150}
cy={132}
r={28}
fill="#f5efe6"
stroke="#1e3246"
strokeWidth={1.5}
<Rect
width={276}
height={276}
x={12}
y={12}
fill="#fafaf7"
stroke="#1e3246"
strokeDasharray="6 4"
strokeOpacity={0.35}
strokeWidth={1.5}
rx={16}
/>
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={16}
y={16}
width={268}
height={268}
/>
<Path
fill="none"
stroke="#1e3246"
strokeLinecap="round"
strokeWidth={1.9}
d="M142.4 116.8v11.4c0 3.8 5.7 3.8 5.7 0v-11.4m-2.85 0v11.4m0 3.8v15.2m9.5-30.4c4.75 3.8 4.75 15.2 0 17.1v13.3"
/>
<Text
x={150}
y={192}
fill="#1e3246"
fontFamily="System, -apple-system, Roboto, sans-serif"
fontSize={14}
fontWeight={800}
letterSpacing={2}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Circle
cx={150}
cy={132}
r={28}
fill="#f5efe6"
stroke="#1e3246"
strokeWidth={1.5}
/>
<Path
fill="none"
stroke="#1e3246"
strokeLinecap="round"
strokeWidth={1.9}
d="M142.4 116.8v11.4c0 3.8 5.7 3.8 5.7 0v-11.4m-2.85 0v11.4m0 3.8v15.2m9.5-30.4c4.75 3.8 4.75 15.2 0 17.1v13.3"
/>
<Text
x={150}
y={192}
fill="#1e3246"
fontFamily="System, -apple-system, Roboto, sans-serif"
fontSize={14}
fontWeight={800}
letterSpacing={2}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Üst Amblem & Rozet */}
<G stroke="#a88b58" transform="translate(290 142)">
@@ -7,11 +7,12 @@ import Svg, {
Text,
Image,
} from "react-native-svg";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./index";
import { type QrTemplateProps, getFontFamily, renderSvgLines } from "./common";
const QrTemplateLumiere = ({
qrBase64,
fields,
hideQr,
...props
}: QrTemplateProps) => {
const rName = fields?.restaurantName;
@@ -91,43 +92,45 @@ const QrTemplateLumiere = ({
<Path stroke="#d4af37" strokeOpacity={0.4} strokeWidth={0.8} d="M120 198h360" />
{/* QR Placeholder / Gerçek QR */}
<G transform="translate(190 215)">
<Rect width={220} height={220} fill="#fff" stroke="#d4af37" strokeWidth={2} rx={20} />
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={200}
height={200}
/>
) : (
<>
<Rect
width={196}
height={196}
x={12}
y={12}
fill="#fafafa"
stroke="#d4af37"
strokeDasharray="4 3"
strokeOpacity={0.4}
rx={14}
{!hideQr && (
<G transform="translate(190 215)">
<Rect width={220} height={220} fill="#fff" stroke="#d4af37" strokeWidth={2} rx={20} />
{qrBase64 ? (
<Image
href={`data:image/png;base64,${qrBase64}`}
x={10}
y={10}
width={200}
height={200}
/>
<Text
x={110}
y={115}
fill="#71717a"
fontFamily="System, sans-serif"
fontSize={12}
fontWeight={600}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
) : (
<>
<Rect
width={196}
height={196}
x={12}
y={12}
fill="#fafafa"
stroke="#d4af37"
strokeDasharray="4 3"
strokeOpacity={0.4}
rx={14}
/>
<Text
x={110}
y={115}
fill="#71717a"
fontFamily="System, sans-serif"
fontSize={12}
fontWeight={600}
textAnchor="middle"
>
{"QR KOD ALANI"}
</Text>
</>
)}
</G>
)}
{/* Metinler (Çok satırlı ve bağımsız font destekli) */}
<G textAnchor="middle">
@@ -0,0 +1,112 @@
import React from "react";
import { Text as SvgText, type SvgProps } from "react-native-svg";
export interface TextItemConfig {
text: string;
fontSize?: number;
fontId?: string;
offsetY?: number; // Dikey konum kaydırma (yukarı / aşağı)
visible?: boolean;
}
export interface QrCustomFields {
restaurantName: TextItemConfig;
subtitle: TextItemConfig;
tableNo: TextItemConfig;
ctaText: TextItemConfig;
wifiText: TextItemConfig;
socialText: TextItemConfig;
}
export interface QrTemplateProps extends SvgProps {
qrBase64?: string;
fields?: Partial<QrCustomFields>;
hideQr?: boolean;
}
// Türkçe karakterleri (İ, ı, Ş, ş, Ğ, ğ, Ü, ü, Ö, ö, Ç, ç) eksiksiz destekleyen sistem fontları
export const FONT_OPTIONS = [
{
id: "sans",
label: "Modern Sans",
fontFamily: "System, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif",
},
{
id: "serif",
label: "Zarif Serif",
fontFamily: "Georgia, 'Times New Roman', serif",
},
{
id: "times",
label: "Klasik (Times)",
fontFamily: "'Times New Roman', Times, Georgia, serif",
},
{
id: "palatino",
label: "Lüks Palatino",
fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
},
{
id: "trebuchet",
label: "Dinamik",
fontFamily: "'Trebuchet MS', 'Segoe UI', Arial, sans-serif",
},
];
export function getFontFamily(fontId?: string, defaultFamily = "Georgia, serif"): string {
if (!fontId) return defaultFamily;
const found = FONT_OPTIONS.find((f) => f.id === fontId);
return found ? found.fontFamily : defaultFamily;
}
// Çok satırlı (Enter / \n) ve dikey kaydırmalı (offsetY) SVG metin render fonksiyonu
export function renderSvgLines({
text,
x,
y,
offsetY = 0,
lineHeight,
fill,
fontFamily,
fontSize,
fontWeight = "normal",
letterSpacing,
textAnchor = "middle",
}: {
text?: string;
x: number;
y: number;
offsetY?: number;
lineHeight?: number;
fill?: string;
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
letterSpacing?: number;
textAnchor?: "middle" | "start" | "end";
}) {
if (!text) return null;
const lines = text.split("\n");
const computedLineHeight = lineHeight || (fontSize ? fontSize * 1.25 : 18);
const startY = y + (offsetY || 0);
return (
<>
{lines.map((line, idx) => (
<SvgText
key={idx}
x={x}
y={startY + idx * computedLineHeight}
fill={fill}
fontFamily={fontFamily}
fontSize={fontSize}
fontWeight={fontWeight}
letterSpacing={letterSpacing}
textAnchor={textAnchor}
>
{line}
</SvgText>
))}
</>
);
}
@@ -1,5 +1,4 @@
import React from "react";
import { Text as SvgText, type SvgProps } from "react-native-svg";
import QrTemplate1 from "./QrTemplateLumiere";
import QrTemplate2 from "./QrTemplate2";
import QrTemplate3 from "./QrTemplate3";
@@ -8,115 +7,9 @@ import QrTemplate5 from "./QrTemplate5";
import QrTemplate6 from "./QrTemplate6";
import QrTemplate8 from "./QrTemplate8";
import QrTemplate9 from "./QrTemplate9";
import { type QrTemplateProps } from "./common";
export interface TextItemConfig {
text: string;
fontSize?: number;
fontId?: string;
offsetY?: number; // Dikey konum kaydırma (yukarı / aşağı)
visible?: boolean;
}
export interface QrCustomFields {
restaurantName: TextItemConfig;
subtitle: TextItemConfig;
tableNo: TextItemConfig;
ctaText: TextItemConfig;
wifiText: TextItemConfig;
socialText: TextItemConfig;
}
export interface QrTemplateProps extends SvgProps {
qrBase64?: string;
fields?: Partial<QrCustomFields>;
}
// Türkçe karakterleri (İ, ı, Ş, ş, Ğ, ğ, Ü, ü, Ö, ö, Ç, ç) eksiksiz destekleyen sistem fontları
export const FONT_OPTIONS = [
{
id: "sans",
label: "Modern Sans",
fontFamily: "System, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif",
},
{
id: "serif",
label: "Zarif Serif",
fontFamily: "Georgia, 'Times New Roman', serif",
},
{
id: "times",
label: "Klasik (Times)",
fontFamily: "'Times New Roman', Times, Georgia, serif",
},
{
id: "palatino",
label: "Lüks Palatino",
fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
},
{
id: "trebuchet",
label: "Dinamik",
fontFamily: "'Trebuchet MS', 'Segoe UI', Arial, sans-serif",
},
];
export function getFontFamily(fontId?: string, defaultFamily = "Georgia, serif"): string {
if (!fontId) return defaultFamily;
const found = FONT_OPTIONS.find((f) => f.id === fontId);
return found ? found.fontFamily : defaultFamily;
}
// Çok satırlı (Enter / \n) ve dikey kaydırmalı (offsetY) SVG metin render fonksiyonu
export function renderSvgLines({
text,
x,
y,
offsetY = 0,
lineHeight,
fill,
fontFamily,
fontSize,
fontWeight = "normal",
letterSpacing,
textAnchor = "middle",
}: {
text?: string;
x: number;
y: number;
offsetY?: number;
lineHeight?: number;
fill?: string;
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
letterSpacing?: number;
textAnchor?: "middle" | "start" | "end";
}) {
if (!text) return null;
const lines = text.split("\n");
const computedLineHeight = lineHeight || (fontSize ? fontSize * 1.25 : 18);
const startY = y + (offsetY || 0);
return (
<>
{lines.map((line, idx) => (
<SvgText
key={idx}
x={x}
y={startY + idx * computedLineHeight}
fill={fill}
fontFamily={fontFamily}
fontSize={fontSize}
fontWeight={fontWeight}
letterSpacing={letterSpacing}
textAnchor={textAnchor}
>
{line}
</SvgText>
))}
</>
);
}
export * from "./common";
// Yeni component eklemek için buraya import edip COMPONENT_TEMPLATES'e kaydet
export const COMPONENT_TEMPLATES: Record<
@@ -142,7 +35,5 @@ export const TEMPLATE_ASPECT_RATIOS: Record<string, number> = {
"qr-5": 380 / 500, // 380x500 = PNG arka planlı modern kart
"qr-6": 400 / 580, // 400x580 = taş & ızgara vintage stant
"qr-8": 1, // 1024x1024 = 1:1 Emerald Gold Özel Tasarım
"qr-9": 1, // 1024x1024 = 1:1 Vintage Table Menu Özel Tasarım
"qr-9": 380 / 500, // 380x500 = Vintage Table Menu Özel Tasarım
};