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
+203
View File
@@ -0,0 +1,203 @@
[
{
"key": "qr-1",
"name": "Lumière Restaurant (QR-1)",
"categoryName": "Özel Masa Kartı",
"svgTemplateUrl": "/qr-1.svg",
"aspectRatio": "1/1",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #132034 0%, #0b0d13 100%)",
"plaqueColor": "#132034",
"borderAccent": "#d4af37",
"accentText": "#d4af37",
"headerText": "#FFFFFF",
"subText": "#e5c378",
"dividerColor": "rgba(212, 175, 55, 0.3)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 38,
"size": 115,
"borderRadius": 14,
"borderColor": "#D4AF37",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 16,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#D4AF37",
"fontWeight": "800",
"letterSpacing": 2
},
{
"id": "sub",
"text": "RESTAURANT & BAR",
"yPercent": 26,
"fontSize": 10,
"fontFamily": "Plus Jakarta Sans, sans-serif",
"color": "#E5C378",
"fontWeight": "600",
"letterSpacing": 1.5
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 80,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#FFFFFF",
"fontWeight": "700",
"letterSpacing": 1
},
{
"id": "table",
"text": "MASA NO: 01 • WIFI: Bistro",
"yPercent": 90,
"fontSize": 9,
"fontFamily": "Plus Jakarta Sans, sans-serif",
"color": "#A1A1AA",
"fontWeight": "600"
}
]
},
{
"key": "qr-2",
"name": "The Daily Grind Cafe (QR-2)",
"categoryName": "Özel Masa Kartı",
"svgTemplateUrl": "/qr-2.svg",
"aspectRatio": "400/650",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #252c36 0%, #171c22 100%)",
"plaqueColor": "#f6efe2",
"borderAccent": "#3a2312",
"accentText": "#8c6239",
"headerText": "#2c1a0e",
"subText": "#3a2312",
"dividerColor": "rgba(140, 98, 57, 0.3)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 36,
"size": 125,
"borderRadius": 14,
"borderColor": "#3a2312",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 14,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#8c6239",
"fontWeight": "800",
"letterSpacing": 2
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 82,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#2c1a0e",
"fontWeight": "700",
"letterSpacing": 1
}
]
},
{
"key": "qr-3",
"name": "Pizzeria Bella Napoli (QR-3)",
"categoryName": "Özel Masa Kartı",
"svgTemplateUrl": "/qr-3.svg",
"aspectRatio": "400/650",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #faf4e6 0%, #f7eed8 100%)",
"plaqueColor": "#fdfbf5",
"borderAccent": "#b92b27",
"accentText": "#b92b27",
"headerText": "#18181b",
"subText": "#5d4037",
"dividerColor": "rgba(185, 43, 39, 0.3)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 36,
"size": 125,
"borderRadius": 14,
"borderColor": "#b92b27",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 14,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#b92b27",
"fontWeight": "800",
"letterSpacing": 2
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 82,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#18181b",
"fontWeight": "700",
"letterSpacing": 1
}
]
},
{
"key": "qr-8",
"name": "Emerald & Gold Special (QR-8)",
"categoryName": "Özel Kare Masa Kartı",
"svgTemplateUrl": "/qr-8.svg",
"aspectRatio": "1/1",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #0c4443 0%, #062423 100%)",
"plaqueColor": "#f9f5eb",
"borderAccent": "#d1a752",
"accentText": "#d1a752",
"headerText": "#ffffff",
"subText": "#d1a752",
"dividerColor": "rgba(209, 167, 82, 0.4)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 38,
"size": 115,
"borderRadius": 14,
"borderColor": "#d1a752",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 16,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#d1a752",
"fontWeight": "800"
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 80,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#ffffff",
"fontWeight": "700"
}
]
}
]
+2
View File
@@ -9,6 +9,7 @@ import { menuItemsRoutes } from "./routes/menu-items.js";
import { menusRoutes } from "./routes/menus.js";
import { meRoutes } from "./routes/me.js";
import { qrRoutes } from "./routes/qr.js";
import { qrTemplatesRoutes } from "./routes/qr-templates.js";
import { restaurantsRoutes } from "./routes/restaurants.js";
import { subscriptionRoutes } from "./routes/subscription.js";
@@ -42,6 +43,7 @@ await app.register(menusRoutes);
await app.register(menuCategoriesRoutes);
await app.register(menuItemsRoutes);
await app.register(qrRoutes);
await app.register(qrTemplatesRoutes);
await app.register(aiImportsRoutes);
await app.register(domainsRoutes);
await app.register(analyticsRoutes);
+156
View File
@@ -0,0 +1,156 @@
import type { FastifyInstance } from "fastify";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type QrStandPreset } from "@menulio/shared";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.resolve(__dirname, "../data/qr-templates.json");
export interface DynamicQrTemplate extends QrStandPreset {
id: string;
isActive: boolean;
bgType?: "gradient" | "image" | "svg";
bgImageUrl?: string;
createdAt: string;
updatedAt: string;
defaultQrConfig?: {
xPercent: number;
yPercent: number;
size: number;
borderRadius: number;
borderColor: string;
bgBoxColor: string;
};
defaultLayers?: Array<{
id: string;
text: string;
xPercent?: number;
yPercent: number;
fontSize: number;
fontFamily?: string;
color: string;
fontWeight?: string;
}>;
}
async function loadTemplatesFromDisk(): Promise<DynamicQrTemplate[]> {
try {
const content = await fs.readFile(DATA_FILE, "utf-8");
const parsed = JSON.parse(content);
return parsed.map((p: any) => ({
...p,
id: p.key,
isActive: p.isActive ?? true,
createdAt: p.createdAt || new Date().toISOString(),
updatedAt: p.updatedAt || new Date().toISOString(),
defaultQrConfig: p.defaultQrConfig || p.qrConfig,
defaultLayers: p.defaultLayers || p.layers,
}));
} catch {
return [];
}
}
async function saveTemplatesToDisk(templates: DynamicQrTemplate[]): Promise<void> {
try {
await fs.writeFile(DATA_FILE, JSON.stringify(templates, null, 2), "utf-8");
} catch (err) {
console.error("Failed to write qr-templates to disk:", err);
}
}
export async function qrTemplatesRoutes(app: FastifyInstance) {
// 1. Tüm Aktif Şablonları Listele (Mobil ve Web İçin)
app.get("/qr-templates", async () => {
const list = await loadTemplatesFromDisk();
return {
templates: list.filter((t) => t.isActive),
total: list.length,
};
});
// 2. Admin Şablon Listesi (Aktif + Taslak hepsi)
app.get("/admin/qr-templates", async () => {
const list = await loadTemplatesFromDisk();
return {
templates: list,
total: list.length,
};
});
// 3. Yeni Şablon Ekle veya Mevcut Olanı Güncelle
app.post<{ Body: Partial<DynamicQrTemplate> }>("/admin/qr-templates", async (req, reply) => {
const body = req.body;
if (!body.name) {
return reply.status(400).send({ error: "Şablon adı zorunludur." });
}
const key = body.key || `qr-${Date.now()}`;
const list = await loadTemplatesFromDisk();
const existingIndex = list.findIndex((t) => t.id === key || t.key === key);
const now = new Date().toISOString();
const newTemplate: DynamicQrTemplate = {
key,
id: key,
name: body.name,
categoryName: body.categoryName || "Özel Şablon",
svgTemplateUrl: body.svgTemplateUrl || `/templates/${key}.svg`,
aspectRatio: body.aspectRatio || "1/1",
bgType: body.bgType || "gradient",
bgImageUrl: body.bgImageUrl,
bgGradient: body.bgGradient || "radial-gradient(circle at 50% 50%, #132034 0%, #0b0d13 100%)",
plaqueColor: body.plaqueColor || "#132034",
borderAccent: body.borderAccent || "#D4AF37",
accentText: body.accentText || "#D4AF37",
headerText: body.headerText || "#FFFFFF",
subText: body.subText || "#E5C378",
dividerColor: body.dividerColor || "rgba(212, 175, 55, 0.3)",
isActive: body.isActive !== undefined ? body.isActive : true,
createdAt: existingIndex >= 0 ? list[existingIndex].createdAt : now,
updatedAt: now,
defaultQrConfig: body.defaultQrConfig || (body as any).qrConfig || {
xPercent: 50,
yPercent: 38,
size: 115,
borderRadius: 14,
borderColor: body.borderAccent || "#D4AF37",
bgBoxColor: "#FFFFFF",
},
defaultLayers: body.defaultLayers || (body as any).layers || [
{
id: "rName",
text: "{{RESTAURANT_NAME}}",
yPercent: 16,
fontSize: 18,
color: body.accentText || "#D4AF37",
fontWeight: "800",
},
],
};
if (existingIndex >= 0) {
list[existingIndex] = newTemplate;
} else {
list.unshift(newTemplate);
}
await saveTemplatesToDisk(list);
return { success: true, template: newTemplate };
});
// 4. Şablon Sil
app.delete<{ Params: { id: string } }>("/admin/qr-templates/:id", async (req, reply) => {
const { id } = req.params;
const list = await loadTemplatesFromDisk();
const filtered = list.filter((t) => t.id !== id && t.key !== id);
if (filtered.length === list.length) {
return reply.status(404).send({ error: "Şablon bulunamadı." });
}
await saveTemplatesToDisk(filtered);
return { success: true, deletedId: id };
});
}
+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
};
+147
View File
@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import React from "react";
export const metadata: Metadata = {
title: "Menulio Admin Panel | Şablon & Sistem Yönetimi",
description: "Menulio QR Şablon Stüdyosu, Masa Standı Tasarım Motoru ve Sistem Yönetimi",
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col md:flex-row antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Sol Sidebar */}
<aside className="w-full md:w-72 bg-[#121215] border-b md:border-b-0 md:border-r border-white/10 flex flex-col justify-between shrink-0">
<div>
{/* Logo & Başlık */}
<div className="p-6 border-b border-white/10 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/20">
<span className="font-extrabold text-[#0C0C0E] text-lg tracking-wider">M</span>
</div>
<div>
<div className="font-bold text-white tracking-wide text-base group-hover:text-[#D4AF37] transition-colors">
MENULIO
</div>
<div className="text-[10px] font-semibold text-[#D4AF37] tracking-widest uppercase">
Admin Stüdyosu
</div>
</div>
</Link>
</div>
{/* Navigasyon Linkleri */}
<nav className="p-4 space-y-1.5">
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 mb-2">
Tasarım & Şablonlar
</div>
<Link
href="/admin/templates"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25 font-semibold text-sm transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
QR Şablon Stüdyosu
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
JSON
</span>
</Link>
<Link
href="/admin"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Genel Bakış & İstatistikler
</Link>
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 pt-4 mb-2">
Önizleme & Test
</div>
<Link
href="/templates"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Simülatörü
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<Link
href="/qr"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
Web Masa Standı Üretici
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</nav>
</div>
{/* Alt Durum Kartı */}
<div className="p-4 border-t border-white/10">
<div className="p-3.5 rounded-xl bg-white/[0.04] border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-zinc-400">Şablon Motoru</span>
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
CANLI (V2)
</span>
</div>
<div className="text-[11px] text-zinc-500">
Uygulama güncellemesi gerektirmeden JSON ile anında yayınlanır.
</div>
</div>
</div>
</aside>
{/* Sağ Ana İçerik */}
<main className="flex-1 flex flex-col min-w-0 bg-[#0C0C0E]">
{/* Üst Bar */}
<header className="h-16 border-b border-white/10 px-6 flex items-center justify-between bg-[#121215]/50 backdrop-blur shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-zinc-300">
Dinamik Şablon & İçerik Yönetimi
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-zinc-300">
<span className="w-2 h-2 rounded-full bg-[#D4AF37]" />
API: <span className="font-semibold text-white">localhost:3001</span>
</div>
<Link
href="/"
className="text-xs text-zinc-400 hover:text-white transition-colors"
>
Menulio Web &rarr;
</Link>
</div>
</header>
{/* Sayfa İçeriği */}
<div className="flex-1 p-6 md:p-8 overflow-y-auto">
{children}
</div>
</main>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import Link from "next/link";
import { QR_STAND_PRESETS } from "@menulio/shared";
export default function AdminDashboardPage() {
const presetCount = Object.keys(QR_STAND_PRESETS).length;
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Karşılama Başlığı */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
Menulio Sistem & Şablon Yönetimi
</h1>
<p className="text-zinc-400 text-sm mt-1">
Masa standı şablonlarını, JSON şablon motorunu ve restoran görünüm ayarlarını yönetin.
</p>
</div>
<Link
href="/admin/templates"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-bold px-5 py-2.5 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all text-sm self-start"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Yeni Şablon Tasarla & Ekle
</Link>
</div>
{/* İstatistik Kartları */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Aktif Şablonlar
</div>
<div className="text-3xl font-black text-white">{presetCount} Adet</div>
<div className="text-xs text-emerald-400 font-medium flex items-center gap-1">
<span></span> Mobil & Web Canlıda
</div>
</div>
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Şablon Motoru Tipi
</div>
<div className="text-2xl font-black text-[#D4AF37]">JSON Driven</div>
<div className="text-xs text-zinc-400 font-medium">
0 App Store güncellemesi
</div>
</div>
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Baskı Çıktı Formatı
</div>
<div className="text-3xl font-black text-white">A5 & 300DPI</div>
<div className="text-xs text-zinc-400 font-medium">
PDF + Vektörel PNG
</div>
</div>
<div className="p-5 rounded-2xl bg-[#121215] border border-white/10 space-y-2">
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
API Durumu
</div>
<div className="text-2xl font-black text-emerald-400">Aktif & Senkron</div>
<div className="text-xs text-zinc-400 font-medium">
REST API v1
</div>
</div>
</div>
{/* Nasıl Çalışır / Mimari Rehber Kutusu */}
<div className="p-6 rounded-2xl bg-gradient-to-br from-[#18181B] to-[#121215] border border-[#D4AF37]/20 relative overflow-hidden">
<div className="relative z-10 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#D4AF37]/15 border border-[#D4AF37]/30 text-xs font-bold text-[#D4AF37]">
💡 DİNAMİK ŞABLON MİMARİSİ
</div>
<h2 className="text-xl font-bold text-white">
Mobil Uygulamayı Güncellemeden Yeni Şablon Yayınlama
</h2>
<p className="text-sm text-zinc-300 leading-relaxed max-w-3xl">
Menulio, QR masa kartı şablonlarını doğrudan kod içine gömmek yerine <strong>JSON şablon spesifikasyonu</strong> ile çalışır.
Admin panelinden yeni bir SVG/Görsel arka plan ve varsayılan QR/metin koordinatları tanımladığınızda,
tüm mobil kullanıcılar uygulamayı güncellemek zorunda kalmadan yeni şablonu anında görürler.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="p-4 rounded-xl bg-black/40 border border-white/5 space-y-1.5">
<div className="text-xs font-bold text-[#D4AF37]">1. Arka Planı Belirle</div>
<div className="text-xs text-zinc-400">
Figma veya Illustrator'dan SVG/PNG arka planı oluşturup yükleyin veya renk seçin.
</div>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-white/5 space-y-1.5">
<div className="text-xs font-bold text-[#D4AF37]">2. QR & Katmanları Ayarla</div>
<div className="text-xs text-zinc-400">
QR kodun yerini, boyutunu, restoran ismi ve slogan metinlerinin varsayılan yerlerini belirleyin.
</div>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-white/5 space-y-1.5">
<div className="text-xs font-bold text-[#D4AF37]">3. Tek Tıkla Yayınla</div>
<div className="text-xs text-zinc-400">
Kaydet butonuna bastığınız anda API üzerinden tüm restoranların mobil uygulamasına dağıtılır.
</div>
</div>
</div>
</div>
</div>
{/* Hızlı Şablonlar Listesi */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-white">Mevcut Hazır Şablonlar</h3>
<Link
href="/admin/templates"
className="text-xs font-semibold text-[#D4AF37] hover:underline"
>
Tümünü Yönet & Düzenle &rarr;
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.values(QR_STAND_PRESETS).slice(0, 4).map((preset) => (
<div
key={preset.key}
className="p-4 rounded-xl bg-[#121215] border border-white/10 space-y-3 flex flex-col justify-between"
>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-mono text-zinc-500">{preset.key}</span>
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-full font-bold">
Aktif
</span>
</div>
<div className="font-bold text-white text-sm line-clamp-1">{preset.name}</div>
<div className="text-xs text-zinc-400 mt-0.5">{preset.categoryName || "Masa Kartı"}</div>
</div>
<div className="pt-3 border-t border-white/5 flex items-center justify-between text-xs text-zinc-400">
<span>Oran: {preset.aspectRatio || "1/1"}</span>
<Link
href={`/admin/templates?key=${preset.key}`}
className="font-bold text-[#D4AF37] hover:underline"
>
Düzenle
</Link>
</div>
</div>
))}
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { TemplateStudioAdminClient } from "@/components/TemplateStudioAdminClient";
export const metadata: Metadata = {
title: "QR Şablon Stüdyosu | Menulio Admin",
description: "Masa standı şablonlarını görsel editör veya JSON ile tasarlayın, canlı önizleyin ve anında yayınlayın.",
};
export default function AdminTemplatesPage() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 border-b border-white/10 pb-5">
<div>
<h1 className="text-2xl font-extrabold text-white tracking-tight">
QR Masa Standı Şablon Stüdyosu
</h1>
<p className="text-zinc-400 text-xs mt-1">
Uygulama güncellemesi gerektirmeden yeni şablonlar ekleyin, renkleri ve QR koordinatlarını düzenleyin.
</p>
</div>
</div>
<TemplateStudioAdminClient />
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { supabase } from "@/lib/supabase";
const DATA_FILE = path.join(process.cwd(), "src/data/qr-templates.json");
function mapFromDb(row: any) {
return {
key: row.key,
name: row.name,
categoryName: row.category_name,
aspectRatio: row.aspect_ratio || "1/1",
bgType: row.bg_type || "gradient",
bgImageUrl: row.bg_image_url || undefined,
bgGradient: row.bg_gradient || undefined,
plaqueColor: row.plaque_color || undefined,
borderAccent: row.border_accent || undefined,
accentText: row.accent_text || undefined,
headerText: row.header_text || undefined,
subText: row.sub_text || undefined,
dividerColor: row.divider_color || undefined,
qrConfig: row.qr_config,
layers: row.layers,
isActive: row.is_active ?? true,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapToDb(item: any) {
return {
key: item.key,
name: item.name,
category_name: item.categoryName || null,
aspect_ratio: item.aspectRatio || "1/1",
bg_type: item.bgType || "gradient",
bg_image_url: item.bgImageUrl || null,
bg_gradient: item.bgGradient || null,
plaque_color: item.plaqueColor || null,
border_accent: item.borderAccent || null,
accent_text: item.accentText || null,
header_text: item.headerText || null,
sub_text: item.subText || null,
divider_color: item.dividerColor || null,
qr_config: item.qrConfig || item.defaultQrConfig || null,
layers: item.layers || item.defaultLayers || null,
is_active: item.isActive !== undefined ? item.isActive : true,
updated_at: new Date().toISOString(),
};
}
async function readFromDisk(): Promise<any[]> {
try {
const raw = await fs.readFile(DATA_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return [];
}
}
async function writeToDisk(list: any[]): Promise<void> {
try {
await fs.writeFile(DATA_FILE, JSON.stringify(list, null, 2), "utf-8");
} catch {}
}
export async function GET() {
try {
const { data, error } = await supabase
.from("qr_stand_templates")
.select("*")
.order("created_at", { ascending: false });
if (!error && data && data.length > 0) {
const mapped = data.map(mapFromDb);
await writeToDisk(mapped);
return NextResponse.json({ templates: mapped, source: "supabase_db" });
}
} catch {}
const diskTemplates = await readFromDisk();
return NextResponse.json({ templates: diskTemplates, source: "disk_cache" });
}
export async function POST(req: Request) {
try {
const body = await req.json();
if (!body || !body.key) {
return NextResponse.json({ error: "Şablon key zorunludur" }, { status: 400 });
}
// 1. Supabase'e Upsert et
try {
const dbRow = mapToDb(body);
await supabase.from("qr_stand_templates").upsert(dbRow, { onConflict: "key" });
} catch (dbErr) {
console.warn("Supabase upsert warning:", dbErr);
}
// 2. Diske de yaz (offline / backup)
const diskList = await readFromDisk();
const existingIdx = diskList.findIndex((t: any) => t.key === body.key);
if (existingIdx >= 0) {
diskList[existingIdx] = { ...diskList[existingIdx], ...body };
} else {
diskList.unshift(body);
}
await writeToDisk(diskList);
return NextResponse.json({ success: true, template: body });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const { searchParams } = new URL(req.url);
const key = searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "Şablon key zorunludur" }, { status: 400 });
}
// 1. Supabase'den Sil
try {
await supabase.from("qr_stand_templates").delete().eq("key", key);
} catch (dbErr) {
console.warn("Supabase delete warning:", dbErr);
}
// 2. Diskten de Sil
const diskList = await readFromDisk();
const filtered = diskList.filter((t: any) => t.key !== key);
await writeToDisk(filtered);
return NextResponse.json({ success: true, deletedKey: key });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long