From e7ba6c7807701312c12c2ee341953eccd934eb89 Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Sat, 22 Aug 2026 16:00:30 +0300 Subject: [PATCH] feat(qr): complete QR studio, dynamic google fonts, mouse canvas drag, and template sync --- .npmrc | 1 + apps/api/src/data/qr-templates.json | 203 ++ apps/api/src/index.ts | 2 + apps/api/src/routes/qr-templates.ts | 156 ++ apps/mobile/app.json | 3 +- apps/mobile/eas.json | 7 + apps/mobile/package.json | 8 +- apps/mobile/src/app/_layout.tsx | 2 +- apps/mobile/src/app/menu/qr.tsx | 749 ++++++-- .../components/qr-canvas/CanvasLayerItem.tsx | 213 ++- .../src/components/qr-canvas/CanvasQrItem.tsx | 176 ++ .../qr-canvas/InstagramTextEditorModal.tsx | 610 ++++++ .../components/qr-canvas/QrCanvasStudio.tsx | 1119 +++++++---- apps/mobile/src/components/qr-canvas/index.ts | 2 + apps/mobile/src/components/qr-canvas/types.ts | 116 +- .../components/qr-templates/QrTemplate2.tsx | 83 +- .../components/qr-templates/QrTemplate3.tsx | 75 +- .../components/qr-templates/QrTemplate4.tsx | 89 +- .../components/qr-templates/QrTemplate5.tsx | 101 +- .../components/qr-templates/QrTemplate6.tsx | 107 +- .../components/qr-templates/QrTemplate8.tsx | 105 +- .../components/qr-templates/QrTemplate9.tsx | 125 +- .../qr-templates/QrTemplateLumiere.tsx | 77 +- .../src/components/qr-templates/common.tsx | 112 ++ .../src/components/qr-templates/index.tsx | 115 +- apps/web/src/app/admin/layout.tsx | 147 ++ apps/web/src/app/admin/page.tsx | 158 ++ apps/web/src/app/admin/templates/page.tsx | 26 + apps/web/src/app/api/qr-templates/route.ts | 142 ++ .../components/TemplateStudioAdminClient.tsx | 1668 +++++++++++++++++ apps/web/src/data/qr-templates.json | 234 +++ pnpm-lock.yaml | 169 +- pnpm-workspace.yaml | 7 + .../20260822000000_qr_stand_templates.sql | 33 + 34 files changed, 5689 insertions(+), 1251 deletions(-) create mode 100644 apps/api/src/data/qr-templates.json create mode 100644 apps/api/src/routes/qr-templates.ts create mode 100644 apps/mobile/src/components/qr-canvas/CanvasQrItem.tsx create mode 100644 apps/mobile/src/components/qr-canvas/InstagramTextEditorModal.tsx create mode 100644 apps/mobile/src/components/qr-templates/common.tsx create mode 100644 apps/web/src/app/admin/layout.tsx create mode 100644 apps/web/src/app/admin/page.tsx create mode 100644 apps/web/src/app/admin/templates/page.tsx create mode 100644 apps/web/src/app/api/qr-templates/route.ts create mode 100644 apps/web/src/components/TemplateStudioAdminClient.tsx create mode 100644 apps/web/src/data/qr-templates.json create mode 100644 supabase/migrations/20260822000000_qr_stand_templates.sql diff --git a/.npmrc b/.npmrc index 1d2dff6..3346e81 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ auto-install-peers=true strict-peer-dependencies=false enable-pre-post-scripts=true +only-built-dependencies=core-js,@swc/core,esbuild,sharp,@esbuild/darwin-arm64,@next/swc-darwin-arm64 diff --git a/apps/api/src/data/qr-templates.json b/apps/api/src/data/qr-templates.json new file mode 100644 index 0000000..ce62a8e --- /dev/null +++ b/apps/api/src/data/qr-templates.json @@ -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" + } + ] + } +] diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0423f0b..cbdc1a4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/routes/qr-templates.ts b/apps/api/src/routes/qr-templates.ts new file mode 100644 index 0000000..8c76880 --- /dev/null +++ b/apps/api/src/routes/qr-templates.ts @@ -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 { + 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 { + 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 }>("/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 }; + }); +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json index c7fdc89..306dcbf 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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", diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index f2c6917..17a7f5d 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -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": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b6e8939..9927ca2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c0bd57d..9aae25a 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -4,7 +4,7 @@ import { StatusBar } from "expo-status-bar"; export default function RootLayout() { return ( <> - + ); diff --git a/apps/mobile/src/app/menu/qr.tsx b/apps/mobile/src/app/menu/qr.tsx index a0048b1..b396555 100644 --- a/apps/mobile/src/app/menu/qr.tsx +++ b/apps/mobile/src/app/menu/qr.tsx @@ -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(null); const [restaurantName, setRestaurantName] = useState("Gourmet Bistro"); const [qr, setQr] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(() => + Object.values(QR_STAND_PRESETS) ); + const [selectedKey, setSelectedKey] = useState( - 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(() => getDefaultLayersForTemplate("qr-1", "Gourmet Bistro") ); + const [qrConfig, setQrConfig] = useState(() => + getDefaultQrConfigForTemplate("qr-1", 290, 290) + ); + + const [selectedLayerId, setSelectedLayerId] = useState(null); + const [isQrSelected, setIsQrSelected] = useState(false); + + // Şablonlar Arasında Düzenlemeleri Koruyan Önbellek + const templateCacheRef = useRef< + Record + >({}); + + const handleLayersChange = (updater: React.SetStateAction) => { + 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) => { + 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(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(`/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(`/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 ( - - - QR Kod ve Şablonlar Hazırlanıyor... + + + + QR Stant Stüdyosu Hazırlanıyor... + ); } if (error || !qr) { return ( - - - + + + {error ?? "QR kod yüklenemedi"} 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" }} > Geri Dön @@ -240,79 +444,221 @@ export default function QrScreen() { } return ( - - - {/* Header Badge */} + 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} + > + + {/* Üst Navigasyon & Başlık Çubuğu */} - - - MASA QR STANT JENERATÖRÜ - + {/* Geri Dön Butonu */} + { + 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, + })} + > + + + + {/* Stüdyo Rozeti */} + + + + INSTAGRAM QR STÜDYOSU + + + + {/* Canlı Yenile / Senkronize Et Butonu */} + 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, + })} + > + + - - Masa Stant Tasarımları - - - Şablon seçin; QR kodunuz otomatik yerleşsin. Baskıya hazır A5 PDF veya PNG olarak indirin. - - - {/* Horizontal Theme Selector */} - {availablePresets.length > 1 && ( + {/* Yatay Şablon Seçici Bar */} + {presets.length > 0 && ( - {availablePresets.map((preset) => { + {presets.map((preset) => { const isSelected = preset.key === selectedKey; + const pRatio = getPresetAspectRatio(preset); + const calcHeight = Math.round(displayWidth / (pRatio || 1)); return ( { - 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, + })} > )} - {/* İnteraktif Instagram / Canva Tarzı QR Stant Stüdyosu */} - - - - TemplateComponent ? ( - { + 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 ( + + - ) : null - } - layers={layers} - onLayersChange={setLayers} - /> - - + {/* İç Dekoratif Çerçeve */} + + + ); + } + // 2. Özel görsel yoksa ve Yerel Hardcoded Component varsa onu kullan + if (COMPONENT_TEMPLATES[selectedKey]) { + const Comp = COMPONENT_TEMPLATES[selectedKey]; + return ( + + ); + } + // 3. Fallback dinamik stant + if (!cur) return null; + return ( + + + + ); + }} + 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 */} - - {/* PDF Download Button */} + {/* Aksiyon Butonları Grubu */} + + {/* PDF İndirme Butonu */} ({ 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() { )} - {/* PNG Download Button */} + {/* PNG İndirme Butonu */} ({ - 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() { ) : ( <> - - + + PNG Olarak Kaydet / Paylaş )} - {/* Direct Link Share Button */} + {/* Menü Linki Paylaş */} ({ - 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, })} > - - + + Menü Linkini Paylaş - {/* Quick Copy Link Box */} + {/* Hızlı Kopyalama Kutusu */} ({ + 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, + })} > - + {qr.targetUrl} 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 = ({ 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(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 ( { + 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, }} > - - {layer.text || "Metin"} - + + + {layer.text} + + - {/* Seçili İken Hızlı Silme Rozeti */} + {/* Seçili İken Üst Köşedeki Küçük Silme Rozeti */} {isSelected && ( 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", }} > - + )} diff --git a/apps/mobile/src/components/qr-canvas/CanvasQrItem.tsx b/apps/mobile/src/components/qr-canvas/CanvasQrItem.tsx new file mode 100644 index 0000000..d1a59e1 --- /dev/null +++ b/apps/mobile/src/components/qr-canvas/CanvasQrItem.tsx @@ -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 = ({ + 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 ( + + + {qrBase64 ? ( + + ) : ( + + + + )} + + {/* Seçili İken Taşıma Rozeti */} + {isSelected && ( + + + + )} + + + ); +}; diff --git a/apps/mobile/src/components/qr-canvas/InstagramTextEditorModal.tsx b/apps/mobile/src/components/qr-canvas/InstagramTextEditorModal.tsx new file mode 100644 index 0000000..f6793a8 --- /dev/null +++ b/apps/mobile/src/components/qr-canvas/InstagramTextEditorModal.tsx @@ -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) => void; + onClose: () => void; + onDelete?: (id: string) => void; +} + +export const InstagramTextEditorModal: React.FC = ({ + visible, + layer, + onSave, + onClose, + onDelete, +}) => { + const insets = useSafeAreaInsets(); + const inputRef = useRef(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(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 ( + + + + {/* ÜST BAR - INSTAGRAM STORY KONTROLLERİ */} + + {/* Sol: İptal / Kapat */} + + + + + {/* Orta Araçlar: Hizalama & Arka Plan Rozeti */} + + {/* Hizalama Butonu */} + + + + + {/* Instagram "A" Arka Plan Butonu */} + + + A + + {bgStyle === "soft" && ( + + )} + {bgStyle === "outline" && ( + + )} + + + {/* Katmanı Sil (Varsa) */} + {onDelete && ( + { + 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)" }]} + > + + + )} + + + {/* Sağ: BİTTİ (Done) Butonu */} + + Bitti + + + + {/* ORTA BÖLÜM: SOL BOYUT SLIDER & CANLI METİN ALANI */} + + {/* Sol: Dikey Boyut Stepper / Gösterge */} + + { + try { Haptics.selectionAsync(); } catch {} + setFontSize((s) => Math.min(48, s + 2)); + }} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={styles.sizeStepBtn} + > + + + + + + + + { + try { Haptics.selectionAsync(); } catch {} + setFontSize((s) => Math.max(8, s - 2)); + }} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={styles.sizeStepBtn} + > + + + + {fontSize}pt + + + {/* Orta: Canlı İnstagram Yazı Alanı */} + + + + + + + + {/* ALT BÖLÜM 1: RENK SEÇİCİ BALONCUKLAR */} + + + {CANVAS_COLOR_PALETTE.map((c) => { + const isSelected = color === c.color; + return ( + handleSelectColor(c.color)} + style={[ + styles.colorCircleWrapper, + isSelected && { borderColor: "#FFFFFF", transform: [{ scale: 1.15 }] }, + ]} + > + + + ); + })} + + + + {/* ALT BÖLÜM 2: INSTAGRAM FONT STİLLERİ CAROUSEL */} + + + {CANVAS_FONT_OPTIONS.map((f) => { + const isSelected = fontId === f.id; + return ( + handleSelectFont(f.id)} + style={[ + styles.fontPill, + isSelected && styles.fontPillActive, + ]} + > + + {f.label} + + + ); + })} + + + + + + ); +}; + +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", + }, +}); diff --git a/apps/mobile/src/components/qr-canvas/QrCanvasStudio.tsx b/apps/mobile/src/components/qr-canvas/QrCanvasStudio.tsx index 0bcbb87..a0fdd8e 100644 --- a/apps/mobile/src/components/qr-canvas/QrCanvasStudio.tsx +++ b/apps/mobile/src/components/qr-canvas/QrCanvasStudio.tsx @@ -1,20 +1,23 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Pressable, ScrollView, - Switch, Text, - TextInput, View, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import ViewShot from "react-native-view-shot"; import { CanvasLayerItem } from "./CanvasLayerItem"; +import { CanvasQrItem } from "./CanvasQrItem"; +import { InstagramTextEditorModal } from "./InstagramTextEditorModal"; import { type CanvasTextLayer, - CANVAS_FONT_OPTIONS, - CANVAS_COLOR_PALETTE, - INSTAGRAM_BADGE_STYLES, + type CanvasQrConfig, + QR_SIZE_PRESETS, + QR_RADIUS_PRESETS, getDefaultLayersForTemplate, + getDefaultQrConfigForTemplate, } from "./types"; interface QrCanvasStudioProps { @@ -26,301 +29,692 @@ interface QrCanvasStudioProps { renderBackground: () => React.ReactNode; layers: CanvasTextLayer[]; onLayersChange: React.Dispatch>; + qrConfig: CanvasQrConfig; + onQrConfigChange: React.Dispatch>; + viewShotRef?: React.RefObject; + selectedLayerId: string | null; + onSelectLayer: (id: string | null) => void; + isQrSelected: boolean; + onSelectQr: (selected: boolean) => void; + onDragStateChange?: (isDragging: boolean) => void; } export const QrCanvasStudio: React.FC = ({ templateKey, restaurantName, + qrBase64, width, height, renderBackground, layers, onLayersChange, + qrConfig, + onQrConfigChange, + viewShotRef, + selectedLayerId, + onSelectLayer, + isQrSelected, + onSelectQr, + onDragStateChange, }) => { - const [selectedLayerId, setSelectedLayerId] = useState(layers[0]?.id || null); + const selectedLayer = layers.find((l) => l.id === selectedLayerId) || null; - const selectedLayer = layers.find((l) => l.id === selectedLayerId); + // Instagram Metin Düzenleyici Modal Durumu + const [editingLayer, setEditingLayer] = useState(null); + const [isTextModalOpen, setIsTextModalOpen] = useState(false); - // Pozisyon Güncelleme - const handleUpdatePosition = (id: string, x: number, y: number) => { + // Instagram/Figma Kılavuz Çizgileri + const [guideLines, setGuideLines] = useState<{ + verticalCenter: boolean; + horizontalCenter: boolean; + }>({ verticalCenter: false, horizontalCenter: false }); + + const lastVRef = useRef(false); + const lastHRef = useRef(false); + + // Sürükleme Başlangıcı + const handleDragStart = () => { + onDragStateChange?.(true); + }; + + // Manyetik Yapışma (Snap) & Kılavuz Çizgileri + const handleSnap = (rawX: number, rawY: number, itemWidth: number, itemHeight: number) => { + const SNAP_THRESHOLD = 6; + let snappedX = Math.round(rawX); + let snappedY = Math.round(rawY); + let vCenter = false; + let hCenter = false; + + // 1. Dikey Kılavuz (Yatayda Tam Ortalandığında) + const itemCenterX = rawX + itemWidth / 2; + const canvasCenterX = width / 2; + if (Math.abs(itemCenterX - canvasCenterX) <= SNAP_THRESHOLD) { + snappedX = Math.round(canvasCenterX - itemWidth / 2); + vCenter = true; + } + + // 2. Yatay Kılavuz (Dikeyde Tam Ortalandığında) + const itemCenterY = rawY + itemHeight / 2; + const canvasCenterY = height / 2; + if (Math.abs(itemCenterY - canvasCenterY) <= SNAP_THRESHOLD) { + snappedY = Math.round(canvasCenterY - itemHeight / 2); + hCenter = true; + } + + // Dokunsal Titreşim + if ((vCenter && !lastVRef.current) || (hCenter && !lastHRef.current)) { + try { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } catch {} + } + lastVRef.current = vCenter; + lastHRef.current = hCenter; + + setGuideLines({ verticalCenter: vCenter, horizontalCenter: hCenter }); + return { snappedX, snappedY }; + }; + + // Sürükleme Bitişi + const handleDragEnd = () => { + setGuideLines({ verticalCenter: false, horizontalCenter: false }); + lastVRef.current = false; + lastHRef.current = false; + onDragStateChange?.(false); + }; + + // Metin Katmanı Pozisyon Güncelleme + const handleUpdateLayerPosition = (id: string, x: number, y: number) => { onLayersChange((prev) => prev.map((l) => (l.id === id ? { ...l, x, y } : l)) ); }; - // Yeni Katman Ekle + // QR Kod Pozisyon Güncelleme + const handleUpdateQrPosition = (x: number, y: number) => { + onQrConfigChange((prev) => ({ ...prev, x, y })); + }; + + // QR Kodu Yatayda Ortala + const handleCenterQrHorizontally = () => { + const centeredX = Math.round((width - qrConfig.size) / 2); + onQrConfigChange((prev) => ({ ...prev, x: centeredX })); + try { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch {} + }; + + // Seçili Metni Yatayda Ortala + const handleCenterLayerHorizontally = (id: string) => { + const target = layers.find((l) => l.id === id); + if (!target) return; + const approxW = 80; + const centeredX = Math.round((width - approxW) / 2); + onLayersChange((prev) => + prev.map((l) => (l.id === id ? { ...l, x: centeredX } : l)) + ); + try { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch {} + }; + + // Yeni Metin Katmanı Ekle (Instagram "Aa" butonu) const handleAddTextLayer = () => { + try { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } catch {} const newLayer: CanvasTextLayer = { id: `layer_${Date.now()}`, text: "Yeni Metin", - x: Math.round(width / 2 - 40), + x: Math.round(width / 2 - 45), y: Math.round(height / 2 - 20), rotation: 0, - fontSize: 14, + fontSize: 16, fontId: "sans", color: "#FFFFFF", - bgBoxColor: "rgba(18, 18, 21, 0.85)", + bgBoxColor: "rgba(18, 18, 21, 0.88)", fontWeight: "800", + textAlign: "center", }; onLayersChange((prev) => [...prev, newLayer]); - setSelectedLayerId(newLayer.id); + onSelectQr(false); + onSelectLayer(newLayer.id); + + // Doğrudan Instagram Text Editor'ı aç + setEditingLayer(newLayer); + setIsTextModalOpen(true); + }; + + // Katman Düzenle Modalını Aç + const handleOpenTextEditor = (layer: CanvasTextLayer) => { + setEditingLayer(layer); + setIsTextModalOpen(true); + }; + + // Katman Düzenleme Kaydet + const handleSaveTextEditor = (updated: Partial) => { + if (!editingLayer) return; + onLayersChange((prev) => + prev.map((l) => (l.id === editingLayer.id ? { ...l, ...updated } : l)) + ); }; // Katman Sil const handleDeleteLayer = (id: string) => { + try { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } catch {} onLayersChange((prev) => prev.filter((l) => l.id !== id)); if (selectedLayerId === id) { - setSelectedLayerId(null); + onSelectLayer(null); } }; - // Katman Kopyala + // Katman Çoğalt const handleDuplicateLayer = (id: string) => { + try { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } catch {} const target = layers.find((l) => l.id === id); if (!target) return; const duplicated: CanvasTextLayer = { ...target, id: `layer_${Date.now()}`, - x: Math.min(width - 50, target.x + 15), - y: Math.min(height - 30, target.y + 15), + x: Math.min(width - 50, target.x + 12), + y: Math.min(height - 30, target.y + 12), }; onLayersChange((prev) => [...prev, duplicated]); - setSelectedLayerId(duplicated.id); + onSelectQr(false); + onSelectLayer(duplicated.id); }; // Şablonu Sıfırla const handleResetLayers = () => { - const defaults = getDefaultLayersForTemplate(templateKey, restaurantName); - onLayersChange(defaults); - setSelectedLayerId(defaults[0]?.id || null); + try { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } catch {} + const defaultLayers = getDefaultLayersForTemplate(templateKey, restaurantName); + const defaultQr = getDefaultQrConfigForTemplate(templateKey, width, height); + onLayersChange(defaultLayers); + onQrConfigChange(defaultQr); + onSelectQr(false); + onSelectLayer(null); }; return ( - {/* 1. İNTERAKTİF TUVAL ALANI */} - - {/* Arka Plan Şablonu & Sabit QR */} - - {renderBackground()} - - - {/* Serbest Sürüklenebilir Katmanlar */} - {layers.map((layer) => ( - - ))} - - - {/* 2. STÜDYO EYLEM BUTONLARI */} + {/* 1. INSTAGRAM STORY / CANVA STİLİ ÜST HIZLI ARAÇLAR */} - {/* Yeni Metin Ekle */} - ({ - flex: 1, - backgroundColor: "#D4AF37", - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 12, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 6, - opacity: pressed ? 0.85 : 1, - })} - > - - - + Metin Ekle - - - - {/* Seçiliyi Kopyala */} - {selectedLayer && ( + + {/* Metin Ekle (+ Aa) */} handleDuplicateLayer(selectedLayer.id)} + onPress={handleAddTextLayer} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + accessible={true} + accessibilityRole="button" + accessibilityLabel="Yeni Metin Ekle" style={({ pressed }) => ({ - backgroundColor: "#242429", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 12, + backgroundColor: "#D4AF37", + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, flexDirection: "row", alignItems: "center", gap: 6, - opacity: pressed ? 0.85 : 1, + opacity: pressed ? 0.8 : 1, })} > - - - Çoğalt + + Aa + Metin + + + {/* QR Seçici / Ayar */} + { + try { Haptics.selectionAsync(); } catch {} + onSelectLayer(null); + onSelectQr(!isQrSelected); + }} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={({ pressed }) => ({ + backgroundColor: isQrSelected ? "#D4AF37" : "#18181B", + borderWidth: 1, + borderColor: isQrSelected ? "#D4AF37" : "rgba(255,255,255,0.12)", + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + flexDirection: "row", + alignItems: "center", + gap: 5, + opacity: pressed ? 0.8 : 1, + })} + > + + + QR Kodu - )} + - {/* Sıfırla */} + {/* Sağ: Sıfırla */} ({ - backgroundColor: "#242429", + backgroundColor: "#18181B", borderWidth: 1, borderColor: "rgba(255,255,255,0.1)", - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 7, + borderRadius: 16, flexDirection: "row", alignItems: "center", - gap: 6, - opacity: pressed ? 0.85 : 1, + gap: 4, + opacity: pressed ? 0.8 : 1, })} > - - - Sıfırla - + + Sıfırla - {/* 3. SEÇİLİ METİN INSTAGRAM TARZI DÜZENLEYİCİ */} - {selectedLayer ? ( + {/* 2. TUVAL ALANI (INSTAGRAM KORUYUCU DOKUNMATİK ÇERÇEVE) */} + { + // Tuvalin boş alanına dokunulduğunda seçimi kaldır (temiz önizleme) + onSelectLayer(null); + onSelectQr(false); + }} + style={{ + width, + height, + borderRadius: 24, + overflow: "hidden", + backgroundColor: "#0C0C0E", + shadowColor: "#000", + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.5, + shadowRadius: 20, + elevation: 8, + marginBottom: 14, + position: "relative", + }} + > + + {/* Arka Plan Şablonu */} + + {renderBackground()} + + + {/* Sürüklenebilir & Boyutlandırılabilir QR Kod */} + { + try { Haptics.selectionAsync(); } catch {} + onSelectLayer(null); + onSelectQr(true); + }} + onUpdatePosition={handleUpdateQrPosition} + onDragStart={handleDragStart} + onDragMove={handleSnap} + onDragEnd={handleDragEnd} + canvasWidth={width} + canvasHeight={height} + /> + + {/* Sürüklenebilir & Çift Dokunmalı Metin Katmanları */} + {layers.map((layer) => ( + { + try { Haptics.selectionAsync(); } catch {} + onSelectQr(false); + onSelectLayer(id); + }} + onEdit={handleOpenTextEditor} + onUpdatePosition={handleUpdateLayerPosition} + onDelete={handleDeleteLayer} + onDragStart={handleDragStart} + onDragMove={handleSnap} + onDragEnd={handleDragEnd} + canvasWidth={width} + canvasHeight={height} + /> + ))} + + + {/* 📐 FIGMA / INSTAGRAM MANYETİK KILAVUZ ÇİZGİLERİ */} + {guideLines.verticalCenter && ( + + )} + + {guideLines.horizontalCenter && ( + + )} + + + {/* 3. SEÇİLİ ÖĞE İÇİN INSTAGRAM TARZI YÜZEN HIZLI ARAÇLAR (FLOATING DOCK) */} + {/* 3A: SEÇİLİ METİN KATMANI ARAÇLARI */} + {selectedLayer && !isQrSelected && ( + + {/* Üst Sıra: Hızlı Butonlar */} + + + SEÇİLİ METİN + + + + {/* Düzenle (Aa) Modalını Aç */} + handleOpenTextEditor(selectedLayer)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={{ + backgroundColor: "#D4AF37", + paddingHorizontal: 12, + paddingVertical: 5, + borderRadius: 14, + flexDirection: "row", + alignItems: "center", + gap: 4, + }} + > + + Aa Düzenle + + + {/* Yatayda Ortala */} + handleCenterLayerHorizontally(selectedLayer.id)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={{ + backgroundColor: "#242429", + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 14, + flexDirection: "row", + alignItems: "center", + gap: 4, + }} + > + + Ortala + + + {/* Çoğalt */} + handleDuplicateLayer(selectedLayer.id)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={{ + backgroundColor: "#242429", + paddingHorizontal: 8, + paddingVertical: 5, + borderRadius: 14, + }} + > + + + + {/* Sil */} + handleDeleteLayer(selectedLayer.id)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={{ + backgroundColor: "rgba(239, 68, 68, 0.15)", + paddingHorizontal: 8, + paddingVertical: 5, + borderRadius: 14, + }} + > + + + + + + {/* Alt Sıra: Hızlı Döndürme Açısı Hapları */} + + EĞİM: + + {[-15, -6, 0, 6, 15].map((deg) => { + const isActive = (selectedLayer.rotation || 0) === deg; + return ( + { + try { Haptics.selectionAsync(); } catch {} + onLayersChange((prev) => + prev.map((l) => (l.id === selectedLayer.id ? { ...l, rotation: deg } : l)) + ); + }} + style={{ + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 10, + backgroundColor: isActive ? "#D4AF37" : "#242429", + }} + > + + {deg}° + + + ); + })} + + + + )} + + {/* 3B: SEÇİLİ QR KOD ARAÇLARI (TAM GELİŞMİŞ AYARLAR) */} + {isQrSelected && ( - {/* Başlık & Kapat */} - + {/* Üst Başlık & Ortala / Kapat */} + + + + + QR KOD AYARLARI + + + - ({ + backgroundColor: "#242429", + paddingHorizontal: 11, + paddingVertical: 5, + borderRadius: 14, + borderWidth: 1, + borderColor: "rgba(212, 175, 55, 0.3)", + flexDirection: "row", alignItems: "center", - justifyContent: "center", + gap: 5, + opacity: pressed ? 0.8 : 1, + })} + > + + Ortala + + + onSelectQr(false)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={{ + backgroundColor: "rgba(255,255,255,0.08)", + paddingHorizontal: 9, + paddingVertical: 5, + borderRadius: 14, }} > - + Kapat + + + + + {/* 1. Hassas Boyut Stepper'ı & Hızlı Seçenekler */} + + + BOYUT: + + { + try { Haptics.selectionAsync(); } catch {} + onQrConfigChange((prev) => ({ ...prev, size: Math.max(70, prev.size - 5) })); + }} + style={{ + backgroundColor: "#27272A", + width: 28, + height: 24, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }} + > + - + + + + + {qrConfig.size} px + + + + { + try { Haptics.selectionAsync(); } catch {} + onQrConfigChange((prev) => ({ ...prev, size: Math.min(220, prev.size + 5) })); + }} + style={{ + backgroundColor: "#27272A", + width: 28, + height: 24, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }} + > + + + - - Metin & Stil Stüdyosu - - setSelectedLayerId(null)} - style={{ - backgroundColor: "rgba(255,255,255,0.08)", - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 8, - }} - > - Seçimi Kaldır - - - - {/* Metin İçeriği (Enter Destekli) */} - - - METİN İÇERİĞİ (ENTER İLE YENİ SATIR): - - - onLayersChange((prev) => - prev.map((l) => (l.id === selectedLayer.id ? { ...l, text: t } : l)) - ) - } - placeholder="Metninizi yazın..." - placeholderTextColor="#71717A" - multiline - style={{ - backgroundColor: "#121215", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.12)", - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - color: "#FFFFFF", - fontSize: 14, - minHeight: 46, - }} - /> - - - {/* DÖNDÜRME & EĞME AÇISI (ROTATION) */} - - - - EĞİM & DÖNDÜRME AÇISI (ROTATION) - - - {selectedLayer.rotation || 0}° - - - - {[-15, -6, 0, 6, 15].map((deg) => { - const isActive = selectedLayer.rotation === deg; + + {[ + { label: "Kompakt", size: 95 }, + { label: "Standart", size: 115 }, + { label: "Büyük", size: 135 }, + { label: "Geniş", size: 155 }, + ].map((p) => { + const isActive = Math.abs(qrConfig.size - p.size) <= 5; return ( - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id ? { ...l, rotation: deg } : l - ) - ) - } + key={p.label} + onPress={() => { + try { Haptics.selectionAsync(); } catch {} + onQrConfigChange((prev) => ({ ...prev, size: p.size })); + }} style={{ flex: 1, backgroundColor: isActive ? "#D4AF37" : "#242429", - paddingVertical: 8, + paddingVertical: 5, borderRadius: 8, alignItems: "center", }} @@ -328,11 +722,11 @@ export const QrCanvasStudio: React.FC = ({ - {deg > 0 ? `+${deg}°` : `${deg}°`} + {p.label} ); @@ -340,125 +734,52 @@ export const QrCanvasStudio: React.FC = ({ - {/* INSTAGRAM TARZI VURGU / KUTU STİLİ */} + {/* 2. Kenarlık Rengi (Border Color) */} - - INSTAGRAM KUTUCUK STİLİ (BADGE): - - - {INSTAGRAM_BADGE_STYLES.map((b) => { - const isActive = selectedLayer.bgBoxColor === b.bgBoxColor; + ÇERÇEVE KENARLIK RENGİ: + + {[ + { color: "#D4AF37", name: "Altın" }, + { color: "#F6EFE2", name: "Krem" }, + { color: "#FFFFFF", name: "Beyaz" }, + { color: "#18181B", name: "Siyah" }, + { color: "#8C6239", name: "Bronz" }, + { color: "#0C4443", name: "Zümrüt" }, + { color: "#B92B27", name: "Kırmızı" }, + { color: "transparent", name: "Yok" }, + ].map((item) => { + const isSelected = (qrConfig.borderColor || "#D4AF37").toLowerCase() === item.color.toLowerCase(); return ( - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id ? { ...l, bgBoxColor: b.bgBoxColor } : l - ) - ) - } - style={{ - backgroundColor: isActive ? "#D4AF37" : "#242429", - paddingHorizontal: 12, - paddingVertical: 7, - borderRadius: 10, - borderWidth: 1.5, - borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.06)", + key={item.name} + onPress={() => { + try { Haptics.selectionAsync(); } catch {} + onQrConfigChange((prev) => ({ ...prev, borderColor: item.color })); }} - > - - {b.label} - - - ); - })} - - - - {/* YAZI RENGİ PALETİ */} - - - YAZI RENGİ: - - - {CANVAS_COLOR_PALETTE.map((c) => { - const isActive = selectedLayer.color === c.color; - return ( - - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id ? { ...l, color: c.color } : l - ) - ) - } style={{ - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: c.color, - borderWidth: isActive ? 3 : 1.5, - borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.3)", + flexDirection: "row", alignItems: "center", - justifyContent: "center", - }} - > - {isActive && ( - - )} - - ); - })} - - - - {/* YAZI TİPİ (FONT) */} - - - YAZI TİPİ (TÜRKÇE UYUMLU): - - - {CANVAS_FONT_OPTIONS.map((f) => { - const isActive = selectedLayer.fontId === f.id; - return ( - - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id ? { ...l, fontId: f.id } : l - ) - ) - } - style={{ - backgroundColor: isActive ? "#D4AF37" : "#242429", - paddingHorizontal: 12, - paddingVertical: 7, + gap: 5, + paddingHorizontal: 8, + paddingVertical: 4, borderRadius: 10, + backgroundColor: isSelected ? "rgba(212, 175, 55, 0.25)" : "#242429", borderWidth: 1.5, - borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.06)", + borderColor: isSelected ? "#D4AF37" : "transparent", }} > - - {f.label} + /> + + {item.name} ); @@ -466,108 +787,104 @@ export const QrCanvasStudio: React.FC = ({ - {/* BOYUT & GÖLGE AYARI */} - - {/* Boyut */} - - - Boyut: - - - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id - ? { ...l, fontSize: Math.max(8, l.fontSize - 1) } - : l - ) - ) - } - style={{ - backgroundColor: "#242429", - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 8, - }} - > - - - - {selectedLayer.fontSize}px - - - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id - ? { ...l, fontSize: Math.min(60, l.fontSize + 1) } - : l - ) - ) - } - style={{ - backgroundColor: "#242429", - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 8, - }} - > - - + {/* 3. Köşe Yuvarlaklığı & Kenarlık Kalınlığı */} + + {/* Köşe */} + + KÖŞE OVAL: + + {[ + { label: "Düz", radius: 0 }, + { label: "Hafif", radius: 8 }, + { label: "Zarif", radius: 14 }, + { label: "Yuvarlak", radius: 24 }, + ].map((r) => { + const isActive = qrConfig.borderRadius === r.radius; + return ( + { + try { Haptics.selectionAsync(); } catch {} + onQrConfigChange((prev) => ({ ...prev, borderRadius: r.radius })); + }} + style={{ + flex: 1, + backgroundColor: isActive ? "#D4AF37" : "#242429", + paddingVertical: 5, + borderRadius: 7, + alignItems: "center", + }} + > + + {r.label} + + + ); + })} + - {/* Gölge Anahtarı */} - - - Gölge - - - onLayersChange((prev) => - prev.map((l) => - l.id === selectedLayer.id ? { ...l, hasShadow: v } : l - ) - ) - } - trackColor={{ false: "#27272A", true: "#D4AF37" }} - thumbColor="#FFFFFF" - ios_backgroundColor="#27272A" - /> + {/* Kalınlık */} + + KALINLIK: + + {[ + { label: "0px", width: 0 }, + { label: "1px", width: 1 }, + { label: "2px", width: 2 }, + { label: "3px", width: 3 }, + ].map((w) => { + const isActive = (qrConfig.borderWidth ?? 2) === w.width; + return ( + { + try { Haptics.selectionAsync(); } catch {} + onQrConfigChange((prev) => ({ ...prev, borderWidth: w.width })); + }} + style={{ + flex: 1, + backgroundColor: isActive ? "#D4AF37" : "#242429", + paddingVertical: 5, + borderRadius: 7, + alignItems: "center", + }} + > + + {w.label} + + + ); + })} + - ) : ( - - - - Stant üzerindeki herhangi bir metne dokunarak sürükleyin veya düzenleyin. - - )} + + {/* 4. INSTAGRAM METİN DÜZENLEME MODALI */} + { + setIsTextModalOpen(false); + setEditingLayer(null); + }} + onDelete={handleDeleteLayer} + /> ); }; diff --git a/apps/mobile/src/components/qr-canvas/index.ts b/apps/mobile/src/components/qr-canvas/index.ts index bc3cc69..897abbe 100644 --- a/apps/mobile/src/components/qr-canvas/index.ts +++ b/apps/mobile/src/components/qr-canvas/index.ts @@ -1,3 +1,5 @@ export * from "./types"; export * from "./CanvasLayerItem"; +export * from "./CanvasQrItem"; +export * from "./InstagramTextEditorModal"; export * from "./QrCanvasStudio"; diff --git a/apps/mobile/src/components/qr-canvas/types.ts b/apps/mobile/src/components/qr-canvas/types.ts index 846f6a8..3779226 100644 --- a/apps/mobile/src/components/qr-canvas/types.ts +++ b/apps/mobile/src/components/qr-canvas/types.ts @@ -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", }, ]; } diff --git a/apps/mobile/src/components/qr-templates/QrTemplate2.tsx b/apps/mobile/src/components/qr-templates/QrTemplate2.tsx index 9bf361c..0327fe4 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplate2.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplate2.tsx @@ -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 = ({ {/* QR Placeholder / Gerçek QR */} - - - {qrBase64 ? ( - - ) : ( - <> - + + {qrBase64 ? ( + - - - - - - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + + + + + + {"QR KOD ALANI"} + + + )} + + )} {/* Metinler (Çok satırlı destekli) */} diff --git a/apps/mobile/src/components/qr-templates/QrTemplate3.tsx b/apps/mobile/src/components/qr-templates/QrTemplate3.tsx index cd61035..61fdcb2 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplate3.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplate3.tsx @@ -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 = ({ {/* QR Placeholder / Gerçek QR */} - - - {qrBase64 ? ( - - ) : ( - <> - + + {qrBase64 ? ( + - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + {"QR KOD ALANI"} + + + )} + + )} {/* Metinler (Çok satırlı destekli) */} diff --git a/apps/mobile/src/components/qr-templates/QrTemplate4.tsx b/apps/mobile/src/components/qr-templates/QrTemplate4.tsx index 48702d9..f855e34 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplate4.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplate4.tsx @@ -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 */} - - - {qrBase64 ? ( - + - ) : ( - <> - - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + {"QR KOD ALANI"} + + + )} + + )} {/* Metinler */} diff --git a/apps/mobile/src/components/qr-templates/QrTemplate5.tsx b/apps/mobile/src/components/qr-templates/QrTemplate5.tsx index 5ed4a74..5f0f31d 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplate5.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplate5.tsx @@ -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 */} - - - - {qrBase64 ? ( - + - ) : ( - <> - + {qrBase64 ? ( + - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + {"QR KOD ALANI"} + + + )} + + )} {/* Metinler */} diff --git a/apps/mobile/src/components/qr-templates/QrTemplate6.tsx b/apps/mobile/src/components/qr-templates/QrTemplate6.tsx index bd9e17b..db0b711 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplate6.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplate6.tsx @@ -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 = ({ {/* QR Placeholder / Gerçek QR */} - - - - {qrBase64 ? ( - + - ) : ( - <> - + {qrBase64 ? ( + - - - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + + + {"QR KOD ALANI"} + + + )} + + )} {/* Metinler */} diff --git a/apps/mobile/src/components/qr-templates/QrTemplate8.tsx b/apps/mobile/src/components/qr-templates/QrTemplate8.tsx index 1cb4834..224beeb 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplate8.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplate8.tsx @@ -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) */} - - - - {qrBase64 ? ( - + - ) : ( - <> - - - - - - - {"QR KOD ALANI"} - - - )} - + + {qrBase64 ? ( + + ) : ( + <> + + + + + + + {"QR KOD ALANI"} + + + )} + + )} {/* Üst Çizgiler & OR Ayracı */} { const rName = fields?.restaurantName; @@ -46,68 +47,70 @@ const QrTemplate9 = ({ /> {/* QR Alanı (Merkez) */} - - - - - {qrBase64 ? ( - + - ) : ( - <> - + + {qrBase64 ? ( + - - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + + {"QR KOD ALANI"} + + + )} + + )} {/* Üst Amblem & Rozet */} diff --git a/apps/mobile/src/components/qr-templates/QrTemplateLumiere.tsx b/apps/mobile/src/components/qr-templates/QrTemplateLumiere.tsx index 74d3246..2e2ba0d 100644 --- a/apps/mobile/src/components/qr-templates/QrTemplateLumiere.tsx +++ b/apps/mobile/src/components/qr-templates/QrTemplateLumiere.tsx @@ -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 = ({ {/* QR Placeholder / Gerçek QR */} - - - {qrBase64 ? ( - - ) : ( - <> - + + {qrBase64 ? ( + - - {"QR KOD ALANI"} - - - )} - + ) : ( + <> + + + {"QR KOD ALANI"} + + + )} + + )} {/* Metinler (Çok satırlı ve bağımsız font destekli) */} diff --git a/apps/mobile/src/components/qr-templates/common.tsx b/apps/mobile/src/components/qr-templates/common.tsx new file mode 100644 index 0000000..dc35476 --- /dev/null +++ b/apps/mobile/src/components/qr-templates/common.tsx @@ -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; + 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) => ( + + {line} + + ))} + + ); +} diff --git a/apps/mobile/src/components/qr-templates/index.tsx b/apps/mobile/src/components/qr-templates/index.tsx index 07cd9af..c0ffd41 100644 --- a/apps/mobile/src/components/qr-templates/index.tsx +++ b/apps/mobile/src/components/qr-templates/index.tsx @@ -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; -} - -// 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) => ( - - {line} - - ))} - - ); -} +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 = { "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 }; - - diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx new file mode 100644 index 0000000..45896b4 --- /dev/null +++ b/apps/web/src/app/admin/layout.tsx @@ -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 ( +
+ {/* Sol Sidebar */} + + + {/* Sağ Ana İçerik */} +
+ {/* Üst Bar */} +
+
+
+ Dinamik Şablon & İçerik Yönetimi +
+
+
+
+ + API: localhost:3001 +
+ + Menulio Web → + +
+
+ + {/* Sayfa İçeriği */} +
+ {children} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..bda8760 --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -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 ( +
+ {/* Karşılama Başlığı */} +
+
+

+ Menulio Sistem & Şablon Yönetimi +

+

+ Masa standı şablonlarını, JSON şablon motorunu ve restoran görünüm ayarlarını yönetin. +

+
+ + + + + + Yeni Şablon Tasarla & Ekle + +
+ + {/* İstatistik Kartları */} +
+
+
+ Aktif Şablonlar +
+
{presetCount} Adet
+
+ Mobil & Web Canlıda +
+
+ +
+
+ Şablon Motoru Tipi +
+
JSON Driven
+
+ 0 App Store güncellemesi +
+
+ +
+
+ Baskı Çıktı Formatı +
+
A5 & 300DPI
+
+ PDF + Vektörel PNG +
+
+ +
+
+ API Durumu +
+
Aktif & Senkron
+
+ REST API v1 +
+
+
+ + {/* Nasıl Çalışır / Mimari Rehber Kutusu */} +
+
+
+ 💡 DİNAMİK ŞABLON MİMARİSİ +
+ +

+ Mobil Uygulamayı Güncellemeden Yeni Şablon Yayınlama +

+ +

+ Menulio, QR masa kartı şablonlarını doğrudan kod içine gömmek yerine JSON şablon spesifikasyonu 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. +

+ +
+
+
1. Arka Planı Belirle
+
+ Figma veya Illustrator'dan SVG/PNG arka planı oluşturup yükleyin veya renk seçin. +
+
+
+
2. QR & Katmanları Ayarla
+
+ QR kodun yerini, boyutunu, restoran ismi ve slogan metinlerinin varsayılan yerlerini belirleyin. +
+
+
+
3. Tek Tıkla Yayınla
+
+ Kaydet butonuna bastığınız anda API üzerinden tüm restoranların mobil uygulamasına dağıtılır. +
+
+
+
+
+ + {/* Hızlı Şablonlar Listesi */} +
+
+

Mevcut Hazır Şablonlar

+ + Tümünü Yönet & Düzenle → + +
+ +
+ {Object.values(QR_STAND_PRESETS).slice(0, 4).map((preset) => ( +
+
+
+ {preset.key} + + Aktif + +
+
{preset.name}
+
{preset.categoryName || "Masa Kartı"}
+
+ +
+ Oran: {preset.aspectRatio || "1/1"} + + Düzenle + +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/templates/page.tsx b/apps/web/src/app/admin/templates/page.tsx new file mode 100644 index 0000000..fdf66e1 --- /dev/null +++ b/apps/web/src/app/admin/templates/page.tsx @@ -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 ( +
+
+
+

+ QR Masa Standı Şablon Stüdyosu +

+

+ Uygulama güncellemesi gerektirmeden yeni şablonlar ekleyin, renkleri ve QR koordinatlarını düzenleyin. +

+
+
+ + +
+ ); +} diff --git a/apps/web/src/app/api/qr-templates/route.ts b/apps/web/src/app/api/qr-templates/route.ts new file mode 100644 index 0000000..92363f8 --- /dev/null +++ b/apps/web/src/app/api/qr-templates/route.ts @@ -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 { + try { + const raw = await fs.readFile(DATA_FILE, "utf-8"); + return JSON.parse(raw); + } catch { + return []; + } +} + +async function writeToDisk(list: any[]): Promise { + 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 }); + } +} + diff --git a/apps/web/src/components/TemplateStudioAdminClient.tsx b/apps/web/src/components/TemplateStudioAdminClient.tsx new file mode 100644 index 0000000..9a7780d --- /dev/null +++ b/apps/web/src/components/TemplateStudioAdminClient.tsx @@ -0,0 +1,1668 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { QR_STAND_PRESETS, type QrStandPreset } from "@menulio/shared"; + +const LOCAL_STORAGE_KEY = "menulio_qr_templates_v2"; + +// Popüler Google Fonts Listesi +export const POPULAR_GOOGLE_FONTS = [ + // 👑 Lüks & Klasik Serif + { name: "Playfair Display", category: "Lüks Serif", family: "'Playfair Display', serif" }, + { name: "Cinzel", category: "Lüks Serif", family: "'Cinzel', serif" }, + { name: "Cormorant Garamond", category: "Lüks Serif", family: "'Cormorant Garamond', serif" }, + { name: "Bodoni Moda", category: "Lüks Serif", family: "'Bodoni Moda', serif" }, + { name: "Italiana", category: "Lüks Serif", family: "'Italiana', serif" }, + { name: "Prata", category: "Lüks Serif", family: "'Prata', serif" }, + { name: "Marcellus", category: "Lüks Serif", family: "'Marcellus', serif" }, + { name: "Cinzel Decorative", category: "Lüks Serif", family: "'Cinzel Decorative', serif" }, + { name: "Abril Fatface", category: "Lüks Serif", family: "'Abril Fatface', serif" }, + + // ✨ Modern & Minimalist Sans + { name: "Plus Jakarta Sans", category: "Modern Sans", family: "'Plus Jakarta Sans', sans-serif" }, + { name: "Inter", category: "Modern Sans", family: "'Inter', sans-serif" }, + { name: "Montserrat", category: "Modern Sans", family: "'Montserrat', sans-serif" }, + { name: "Outfit", category: "Modern Sans", family: "'Outfit', sans-serif" }, + { name: "Space Grotesk", category: "Modern Sans", family: "'Space Grotesk', sans-serif" }, + { name: "Poppins", category: "Modern Sans", family: "'Poppins', sans-serif" }, + { name: "Oswald", category: "Modern Sans", family: "'Oswald', sans-serif" }, + { name: "Syne", category: "Modern Sans", family: "'Syne', sans-serif" }, + + // ✍️ El Yazısı / Kaligrafi + { name: "Great Vibes", category: "Kaligrafi", family: "'Great Vibes', cursive" }, + { name: "Caveat", category: "Kaligrafi", family: "'Caveat', cursive" }, + { name: "Alex Brush", category: "Kaligrafi", family: "'Alex Brush', cursive" }, + { name: "Dancing Script", category: "Kaligrafi", family: "'Dancing Script', cursive" }, + { name: "Pacifico", category: "Kaligrafi", family: "'Pacifico', cursive" }, +]; + +// Hızlı Renk Paletleri +export const QUICK_COLOR_PALETTES = [ + { name: "Altın", value: "#D4AF37" }, + { name: "Krem Altın", value: "#E5C378" }, + { name: "Saf Beyaz", value: "#FFFFFF" }, + { name: "Gece Siyahı", value: "#0C0C0E" }, + { name: "Gümüş Gri", value: "#94A3B8" }, + { name: "Zümrüt Yeşili", value: "#10B981" }, + { name: "Yakut Kırmızı", value: "#EF4444" }, + { name: "Sıcak Amber", value: "#F59E0B" }, + { name: "Buz Mavisi", value: "#38BDF8" }, + { name: "Bronz", value: "#CD7F32" }, +]; + +interface EditableTemplate extends QrStandPreset { + isActive: boolean; + bgType?: "gradient" | "image" | "svg"; + bgImageUrl?: string; + qrConfig: { + xPercent: number; + yPercent: number; + size: number; + borderRadius: number; + borderWidth?: number; + borderColor: string; + bgBoxColor: string; + padding?: number; + }; + layers: Array<{ + id: string; + text: string; + yPercent: number; + fontSize: number; + fontFamily: string; + color: string; + fontWeight: string; + letterSpacing?: number; + }>; +} + +const FALLBACK_DEFAULT: EditableTemplate = { + 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', serif", + color: "#D4AF37", + fontWeight: "800", + }, + { + 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', 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", + }, + ], +}; + +function presetToEditable(p: QrStandPreset): EditableTemplate { + const isSquare = p.aspectRatio === "1/1"; + return { + ...p, + isActive: true, + bgType: "gradient", + qrConfig: { + xPercent: 50, + yPercent: isSquare ? 38 : 36, + size: isSquare ? 115 : 125, + borderRadius: 14, + borderColor: p.borderAccent || "#D4AF37", + bgBoxColor: "#FFFFFF", + }, + layers: [ + { + id: "rName", + text: "{{RESTAURANT_NAME}}", + yPercent: isSquare ? 16 : 14, + fontSize: 18, + fontFamily: "'Playfair Display', serif", + color: p.accentText || "#D4AF37", + fontWeight: "800", + letterSpacing: 2, + }, + { + id: "sub", + text: "RESTAURANT & BAR", + yPercent: isSquare ? 26 : 22, + fontSize: 10, + fontFamily: "'Plus Jakarta Sans', sans-serif", + color: p.subText || "#E5C378", + fontWeight: "600", + letterSpacing: 1.5, + }, + { + id: "cta", + text: "MENÜYÜ GÖRMEK İÇİN OKUTUN", + yPercent: isSquare ? 80 : 82, + fontSize: 11, + fontFamily: "'Playfair Display', serif", + color: p.headerText || "#FFFFFF", + fontWeight: "700", + letterSpacing: 1, + }, + { + id: "table", + text: "MASA NO: 01 • WIFI: Bistro", + yPercent: isSquare ? 90 : 91, + fontSize: 9, + fontFamily: "'Plus Jakarta Sans', sans-serif", + color: p.subText || "#A1A1AA", + fontWeight: "600", + }, + ], + }; +} + +export function TemplateStudioAdminClient() { + const [templates, setTemplates] = useState(() => { + const list = Object.values(QR_STAND_PRESETS).map(presetToEditable); + return list.length > 0 ? list : [FALLBACK_DEFAULT]; + }); + + const [selectedKey, setSelectedKey] = useState(templates[0]?.key || "qr-1"); + const [searchQuery, setSearchQuery] = useState(""); + const [testRestaurantName, setTestRestaurantName] = useState("OZZY KEBAP"); + const [jsonModalOpen, setJsonModalOpen] = useState(false); + const [jsonInput, setJsonInput] = useState(""); + const [saveToast, setSaveToast] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [activeTab, setActiveTab] = useState<"visual" | "json">("visual"); + + // Özel Google Font Giriş State'i + const [customFontInput, setCustomFontInput] = useState(""); + + // Mouse ile Tuval Üzerinde Sürükleme State'leri + const [activeDragItem, setActiveDragItem] = useState<{ + type: "qr" | "layer"; + id?: string; + } | null>(null); + + const [selectedCanvasElement, setSelectedCanvasElement] = useState("rName"); + + const canvasContainerRef = useRef(null); + const fileInputRef = useRef(null); + + // 1. Google Fonts Dinamik Yükleyici + useEffect(() => { + const fontNames = new Set(); + + POPULAR_GOOGLE_FONTS.forEach((f) => fontNames.add(f.name)); + + templates.forEach((t) => { + (t.layers || []).forEach((l) => { + if (l.fontFamily) { + const rawName = (l.fontFamily || "").split(",")[0]?.replace(/['"]/g, "").trim(); + if (rawName && rawName !== "sans-serif" && rawName !== "serif" && rawName !== "cursive") { + fontNames.add(rawName); + } + } + }); + }); + + fontNames.forEach((font) => { + const cleanId = `gfont-${font.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase()}`; + if (!document.getElementById(cleanId)) { + const link = document.createElement("link"); + link.id = cleanId; + link.rel = "stylesheet"; + link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}:ital,wght@0,400;0,600;0,700;0,800;0,900;1,400;1,700&display=swap`; + document.head.appendChild(link); + } + }); + }, [templates]); + + // 2. Önce localStorage, sonra API / DB'den yükle + useEffect(() => { + try { + const cached = localStorage.getItem(LOCAL_STORAGE_KEY); + if (cached) { + const parsed = JSON.parse(cached); + if (Array.isArray(parsed) && parsed.length > 0) { + setTemplates(parsed); + setSelectedKey(parsed[0].key); + } + } + } catch {} + + async function loadFromApi() { + try { + const res = await fetch("/api/qr-templates"); + if (res && res.ok) { + const data = await res.json(); + if (data.templates && data.templates.length > 0) { + const mapped = data.templates.map((t: any) => ({ + ...t, + qrConfig: t.defaultQrConfig || t.qrConfig || { + xPercent: 50, + yPercent: 38, + size: 115, + borderRadius: 14, + borderColor: "#D4AF37", + bgBoxColor: "#FFFFFF", + }, + layers: t.defaultLayers || t.layers || [ + { + id: "rName", + text: "{{RESTAURANT_NAME}}", + yPercent: 16, + fontSize: 18, + fontFamily: "'Playfair Display', serif", + color: t.accentText || "#D4AF37", + fontWeight: "800", + }, + { + id: "sub", + text: "RESTAURANT & BAR", + yPercent: 26, + fontSize: 10, + fontFamily: "'Plus Jakarta Sans', sans-serif", + color: t.subText || "#E5C378", + fontWeight: "600", + letterSpacing: 1.5, + }, + { + id: "cta", + text: "MENÜYÜ GÖRMEK İÇİN OKUTUN", + yPercent: 80, + fontSize: 11, + fontFamily: "'Playfair Display', serif", + color: t.headerText || "#FFFFFF", + fontWeight: "700", + letterSpacing: 1, + }, + { + id: "table", + text: "MASA NO: 01 • WIFI: Bistro", + yPercent: 90, + fontSize: 9, + fontFamily: "'Plus Jakarta Sans', sans-serif", + color: t.subText || "#A1A1AA", + fontWeight: "600", + }, + ], + })); + setTemplates(mapped); + if (!mapped.some((m: any) => m.key === selectedKey)) { + setSelectedKey(mapped[0]?.key || "qr-1"); + } + } + } + } catch {} + } + loadFromApi(); + }, []); + + const currentTemplate: EditableTemplate = + templates.find((t) => t.key === selectedKey) || templates[0] || FALLBACK_DEFAULT; + + const filteredTemplates = templates.filter( + (t) => + t.name.toLowerCase().includes(searchQuery.toLowerCase()) || + t.key.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Güncelleme Yardımcısı + const updateCurrent = (updates: Partial) => { + setTemplates((prev) => { + const next = prev.map((t) => (t.key === currentTemplate.key ? { ...t, ...updates } : t)); + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(next)); + } catch {} + return next; + }); + }; + + // Katman Rengi Güncelleme (Anında hem katmana hem genel template alanına yansır) + const handleLayerColorChange = (layerId: string, newColor: string) => { + const updatedLayers = (currentTemplate.layers || []).map((l) => + l.id === layerId ? { ...l, color: newColor } : l + ); + + const extraUpdates: Partial = { layers: updatedLayers }; + if (layerId === "rName") extraUpdates.accentText = newColor; + if (layerId === "sub") extraUpdates.subText = newColor; + if (layerId === "cta") extraUpdates.headerText = newColor; + if (layerId === "table") extraUpdates.subText = newColor; + + updateCurrent(extraUpdates); + }; + + // Section 2'deki Metin Vurgu Rengi Değişimi + const handleAccentTextColorChange = (newColor: string) => { + const updatedLayers = (currentTemplate.layers || []).map((l) => + l.id === "rName" ? { ...l, color: newColor } : l + ); + updateCurrent({ + accentText: newColor, + layers: updatedLayers, + }); + }; + + // Özel Google Font Ekle + const handleAddCustomGoogleFont = () => { + const trimmed = customFontInput.trim(); + if (!trimmed) return; + + const cleanId = `gfont-${trimmed.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase()}`; + if (!document.getElementById(cleanId)) { + const link = document.createElement("link"); + link.id = cleanId; + link.rel = "stylesheet"; + link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(trimmed)}:wght@400;600;700;800;900&display=swap`; + document.head.appendChild(link); + } + + const targetLayerId = selectedCanvasElement && selectedCanvasElement !== "qr" ? selectedCanvasElement : "rName"; + const updatedLayers = (currentTemplate.layers || []).map((l) => + l.id === targetLayerId ? { ...l, fontFamily: `'${trimmed}', serif` } : l + ); + updateCurrent({ layers: updatedLayers }); + + setCustomFontInput(""); + showToast(`"${trimmed}" fontu yüklendi ve uygulandı! ✨`); + }; + + // Mouse Drag Handler (Tuval üzerinde QR veya Metin Taşıma) + const handleCanvasMouseMove = (e: React.MouseEvent) => { + if (!activeDragItem || !canvasContainerRef.current) return; + const rect = canvasContainerRef.current.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + let xPercent = Math.max(10, Math.min(90, Math.round((mouseX / rect.width) * 100))); + let yPercent = Math.max(8, Math.min(92, Math.round((mouseY / rect.height) * 100))); + + if (Math.abs(xPercent - 50) <= 3) { + xPercent = 50; + } + + if (activeDragItem.type === "qr") { + updateCurrent({ + qrConfig: { + ...currentTemplate.qrConfig, + xPercent, + yPercent, + }, + }); + } else if (activeDragItem.type === "layer" && activeDragItem.id) { + const updatedLayers = (currentTemplate.layers || []).map((l) => + l.id === activeDragItem.id ? { ...l, yPercent } : l + ); + updateCurrent({ layers: updatedLayers }); + } + }; + + const handleCanvasMouseUp = () => { + setActiveDragItem(null); + }; + + // Dosya Yükleme (SVG / PNG / JPG) + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target?.result as string; + if (result) { + updateCurrent({ + bgType: "image", + bgImageUrl: result, + }); + showToast("Arka plan görseli yüklendi!"); + } + }; + reader.readAsDataURL(file); + }; + + // Yeni Şablon Oluştur + const handleCreateNew = () => { + const newKey = `qr-${Date.now().toString().slice(-4)}`; + const newTpl: EditableTemplate = { + key: newKey, + name: `Yeni Lüks Şablon (${newKey.toUpperCase()})`, + categoryName: "Özel Tasarım", + svgTemplateUrl: `/${newKey}.svg`, + aspectRatio: "1/1", + bgType: "gradient", + bgGradient: "radial-gradient(circle at 50% 50%, #1e1b4b 0%, #0f172a 100%)", + plaqueColor: "#0f172a", + borderAccent: "#D4AF37", + accentText: "#D4AF37", + headerText: "#FFFFFF", + subText: "#94A3B8", + 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', serif", + color: "#D4AF37", + fontWeight: "800", + }, + { + id: "cta", + text: "MENÜYÜ GÖRMEK İÇİN OKUTUN", + yPercent: 82, + fontSize: 11, + fontFamily: "'Playfair Display', serif", + color: "#FFFFFF", + fontWeight: "700", + }, + ], + }; + + const nextList = [newTpl, ...templates]; + setTemplates(nextList); + setSelectedKey(newKey); + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(nextList)); + } catch {} + showToast("Yeni şablon taslağı oluşturuldu!"); + }; + + // Şablon Klonla + const handleClone = (tpl: EditableTemplate) => { + const cloneKey = `${tpl.key}-copy-${Date.now().toString().slice(-3)}`; + const cloned: EditableTemplate = { + ...tpl, + key: cloneKey, + name: `${tpl.name} (Kopya)`, + }; + const nextList = [cloned, ...templates]; + setTemplates(nextList); + setSelectedKey(cloneKey); + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(nextList)); + } catch {} + showToast("Şablon başarıyla çoğaltıldı!"); + }; + + // Şablon Sil + const handleDelete = async (key: string) => { + if (templates.length <= 1) { + alert("En az bir şablon kalmalıdır."); + return; + } + if (confirm("Bu şablonu silmek istediğinize emin misiniz?")) { + const remaining = templates.filter((t) => t.key !== key); + setTemplates(remaining); + setSelectedKey(remaining[0]?.key || ""); + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(remaining)); + } catch {} + + try { + const res = await fetch(`/api/qr-templates?key=${encodeURIComponent(key)}`, { method: "DELETE" }); + if (res.ok) { + showToast("Şablon veritabanından ve yayından silindi! 🗑️"); + } else { + showToast("Şablon silindi."); + } + } catch { + showToast("Şablon silindi."); + } + } + }; + + // Toast Bildirimi + const showToast = (msg: string) => { + setSaveToast(msg); + setTimeout(() => setSaveToast(null), 3000); + }; + + // Kaydet ve API'ye / DB'ye Yayınla + const handleSaveAndPublish = async () => { + setIsSaving(true); + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(templates)); + } catch {} + + try { + const payload = { + ...currentTemplate, + defaultQrConfig: currentTemplate.qrConfig, + defaultLayers: currentTemplate.layers, + }; + + const res = await fetch("/api/qr-templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + showToast("Şablon, renkler ve fontlar kaydedildi ve yayınlandı! 🎉"); + } else { + showToast("Şablon yerel olarak kaydedildi."); + } + } catch { + showToast("Şablon yerel olarak kaydedildi."); + } finally { + setIsSaving(false); + } + }; + + // JSON Dışa Aktar + const handleExportJson = () => { + const jsonStr = JSON.stringify(templates, null, 2); + navigator.clipboard.writeText(jsonStr); + showToast("Tüm şablon JSON verisi panoya kopyalandı!"); + }; + + // JSON İçe Aktar + const handleImportJson = () => { + try { + const parsed = JSON.parse(jsonInput); + let nextList = templates; + if (Array.isArray(parsed)) { + nextList = parsed; + setTemplates(parsed); + setSelectedKey(parsed[0]?.key || ""); + } else if (parsed.key) { + nextList = [parsed, ...templates.filter((t) => t.key !== parsed.key)]; + setTemplates(nextList); + setSelectedKey(parsed.key); + } + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(nextList)); + } catch {} + setJsonModalOpen(false); + setJsonInput(""); + showToast("JSON başarıyla içe aktarıldı ve kaydedildi!"); + } catch { + alert("Geçersiz JSON formatı."); + } + }; + + // Aspect ratio hesaplama + const [aspectW, aspectH] = (currentTemplate.aspectRatio || "1/1").split("/").map(Number); + const previewRatio = aspectW && aspectH ? aspectW / aspectH : 1; + const previewWidth = 320; + const previewHeight = Math.round(previewWidth / previewRatio); + + const activeEditingLayer = + (currentTemplate.layers || []).find((l) => l.id === selectedCanvasElement) || + currentTemplate.layers?.[0] || { + id: "rName", + text: "{{RESTAURANT_NAME}}", + yPercent: 16, + fontSize: 18, + fontFamily: "'Playfair Display', serif", + color: "#D4AF37", + fontWeight: "800", + }; + + return ( +
+ {/* Toast Mesajı */} + {saveToast && ( +
+ + + + {saveToast} +
+ )} + + {/* Gizli Dosya Seçici */} + + + {/* Üst Eylem ve Arama Çubuğu */} +
+
+
+ + + + setSearchQuery(e.target.value)} + className="bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-xs text-white placeholder-zinc-500 focus:outline-none focus:border-[#D4AF37] w-64" + /> +
+
+ Toplam: {templates.length} Şablon +
+
+ +
+ + + + + +
+
+ + {/* Ana Çift Kolonlu Çalışma Alanı */} +
+ {/* SOL: ŞABLON LİSTESİ & PARAMETRELER (7 KOLON) */} +
+ {/* Yatay Şablon Kartları */} +
+
+ Şablon Seç & Düzenle +
+
+ {filteredTemplates.map((tpl) => { + const isSelected = tpl.key === selectedKey; + return ( + + ); + })} +
+
+ + {/* Şablon Parametre Editörü */} +
+ {/* Editör Sekmeleri */} +
+
+ + +
+ +
+ + +
+
+ + {/* Sekme 1: Görsel Editör Formu */} + {activeTab === "visual" && ( +
+ {/* 1. Temel Bilgiler */} +
+
+ 1. Temel Şablon Bilgileri +
+
+
+ + updateCurrent({ key: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono focus:border-[#D4AF37] outline-none" + /> +
+
+ + updateCurrent({ name: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] outline-none" + /> +
+
+ + updateCurrent({ categoryName: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] outline-none" + /> +
+
+ + +
+
+
+ + {/* 2. Arka Plan Resmi / SVG / Renkler */} +
+
+
+ 2. Arka Plan Resmi & Tasarım Dokusu +
+ +
+ + {currentTemplate.bgImageUrl ? ( +
+
+ Arka Plan +
+
Özel Arka Plan Yüklendi
+
Canlı tuvalde aktif
+
+
+ +
+ ) : ( +
fileInputRef.current?.click()} + className="border-2 border-dashed border-white/10 hover:border-[#D4AF37]/50 rounded-2xl p-4 text-center cursor-pointer transition-all bg-black/20" + > +
+ SVG Çerçeve veya Mermer/Ahşap Dokulu Görsel Yükleyin +
+
+ SVG, PNG, WebP formatları desteklenir (Önerilen: 800x800 veya 800x1200) +
+
+ )} + + {/* Renk Paleti */} +
+
+ +
+ updateCurrent({ plaqueColor: e.target.value })} + className="w-8 h-8 rounded-lg bg-transparent border-0 cursor-pointer" + /> + updateCurrent({ plaqueColor: e.target.value })} + className="flex-1 bg-black/40 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono" + /> +
+
+ +
+ +
+ updateCurrent({ borderAccent: e.target.value })} + className="w-8 h-8 rounded-lg bg-transparent border-0 cursor-pointer" + /> + updateCurrent({ borderAccent: e.target.value })} + className="flex-1 bg-black/40 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono" + /> +
+
+ +
+ +
+ handleAccentTextColorChange(e.target.value)} + className="w-8 h-8 rounded-lg bg-transparent border-0 cursor-pointer" + /> + handleAccentTextColorChange(e.target.value)} + className="flex-1 bg-black/40 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono" + /> +
+
+
+
+ + {/* 3. TİPOGRAFİ & GOOGLE FONTS & YAZI RENKLERİ STÜDYOSU */} +
+
+
+ + + + 3. Tipografi, Renk & Google Fonts Stüdyosu +
+ + Kütüphane: Google Fonts + +
+ + {/* Düzenlenecek Metin Katmanı Seçimi */} +
+ +
+ {[ + { id: "rName", label: "Restoran Adı" }, + { id: "sub", label: "Alt Başlık" }, + { id: "cta", label: "Yönlendirme (CTA)" }, + { id: "table", label: "Masa No / Wifi" }, + ].map((item) => { + const isSelected = selectedCanvasElement === item.id; + const layerObj = currentTemplate.layers?.find((l) => l.id === item.id); + return ( + + ); + })} +
+
+ + {/* Seçili Katmanın Font ve Renk Ayarları */} + {activeEditingLayer && ( +
+
+ + + Katman: {activeEditingLayer.id === "rName" ? "Restoran Adı" : activeEditingLayer.id === "sub" ? "Alt Başlık" : activeEditingLayer.id === "cta" ? "Yönlendirme (CTA)" : "Masa No"} + + + Renk: {activeEditingLayer.color || "#FFFFFF"} + +
+ + {/* Hızlı Renk Seçimi Paleti */} +
+ +
+ {QUICK_COLOR_PALETTES.map((palette) => { + const isSelectedColor = (activeEditingLayer.color || "").toUpperCase() === palette.value.toUpperCase(); + return ( + + ); + })} +
+
+ + {/* Özel Renk Seçici (Color Picker & Hex Input) */} +
+
+ +
+ handleLayerColorChange(activeEditingLayer.id, e.target.value)} + className="w-8 h-8 rounded-lg bg-transparent border-0 cursor-pointer shrink-0" + /> + handleLayerColorChange(activeEditingLayer.id, e.target.value)} + placeholder="#FFFFFF" + className="flex-1 bg-black/60 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono" + /> +
+
+ +
+ + { + const updated = (currentTemplate.layers || []).map((l) => + l.id === activeEditingLayer.id + ? { ...l, fontSize: Number(e.target.value) } + : l + ); + updateCurrent({ layers: updated }); + }} + className="w-full bg-black/60 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono" + /> +
+
+ + {/* Hızlı Popüler Font Seçimi */} +
+ +
+ {POPULAR_GOOGLE_FONTS.map((font) => { + const isCurrent = (activeEditingLayer.fontFamily || "").includes(font.name); + return ( + + ); + })} +
+
+ + {/* Özel Google Font Arama / Ekleme Kutusu */} +
+ +
+ setCustomFontInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustomGoogleFont(); + } + }} + className="flex-1 bg-black/60 border border-white/10 rounded-xl px-3 py-2 text-xs text-white placeholder-zinc-500 focus:border-[#D4AF37] outline-none" + /> + +
+
+ + {/* Kalınlık ve Harf Boşluğu */} +
+
+ + +
+ +
+ + { + const updated = (currentTemplate.layers || []).map((l) => + l.id === activeEditingLayer.id + ? { ...l, letterSpacing: Number(e.target.value) } + : l + ); + updateCurrent({ layers: updated }); + }} + className="w-full bg-black/60 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono" + /> +
+
+
+ )} +
+ + {/* 4. QR Kod Boyut & Konum Ayarı */} +
+
+ 4. Varsayılan QR Kod & Çerçeve Konfigürasyonu + + (Sağdaki tuvalden mouse ile de sürükleyebilirsiniz) + +
+
+
+ + + updateCurrent({ + qrConfig: { ...currentTemplate.qrConfig, size: Number(e.target.value) }, + }) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono" + /> +
+ +
+ + + updateCurrent({ + qrConfig: { ...currentTemplate.qrConfig, xPercent: Number(e.target.value) }, + }) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono" + /> +
+ +
+ + + updateCurrent({ + qrConfig: { ...currentTemplate.qrConfig, yPercent: Number(e.target.value) }, + }) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono" + /> +
+ +
+ + + updateCurrent({ + qrConfig: { ...currentTemplate.qrConfig, borderRadius: Number(e.target.value) }, + }) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono" + /> +
+
+ +
+
+ + + updateCurrent({ + qrConfig: { ...currentTemplate.qrConfig, borderColor: e.target.value }, + }) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono" + /> +
+ +
+ + + updateCurrent({ + qrConfig: { ...currentTemplate.qrConfig, bgBoxColor: e.target.value }, + }) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white font-mono" + /> +
+
+
+ + {/* Kaydet Butonu */} +
+ + Kaydedilen şablon, renkler ve fontlar anında API ve mobilde yayına girer. + + +
+
+ )} + + {/* Sekme 2: Ham JSON Editörü */} + {activeTab === "json" && ( +
+
+ Bu şablonun tüm JSON spesifikasyonunu buradan doğrudan düzenleyebilirsiniz: +
+