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

This commit is contained in:
AyrisAI
2026-08-22 16:00:30 +03:00
parent c1569c0879
commit e7ba6c7807
34 changed files with 5689 additions and 1251 deletions
+203
View File
@@ -0,0 +1,203 @@
[
{
"key": "qr-1",
"name": "Lumière Restaurant (QR-1)",
"categoryName": "Özel Masa Kartı",
"svgTemplateUrl": "/qr-1.svg",
"aspectRatio": "1/1",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #132034 0%, #0b0d13 100%)",
"plaqueColor": "#132034",
"borderAccent": "#d4af37",
"accentText": "#d4af37",
"headerText": "#FFFFFF",
"subText": "#e5c378",
"dividerColor": "rgba(212, 175, 55, 0.3)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 38,
"size": 115,
"borderRadius": 14,
"borderColor": "#D4AF37",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 16,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#D4AF37",
"fontWeight": "800",
"letterSpacing": 2
},
{
"id": "sub",
"text": "RESTAURANT & BAR",
"yPercent": 26,
"fontSize": 10,
"fontFamily": "Plus Jakarta Sans, sans-serif",
"color": "#E5C378",
"fontWeight": "600",
"letterSpacing": 1.5
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 80,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#FFFFFF",
"fontWeight": "700",
"letterSpacing": 1
},
{
"id": "table",
"text": "MASA NO: 01 • WIFI: Bistro",
"yPercent": 90,
"fontSize": 9,
"fontFamily": "Plus Jakarta Sans, sans-serif",
"color": "#A1A1AA",
"fontWeight": "600"
}
]
},
{
"key": "qr-2",
"name": "The Daily Grind Cafe (QR-2)",
"categoryName": "Özel Masa Kartı",
"svgTemplateUrl": "/qr-2.svg",
"aspectRatio": "400/650",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #252c36 0%, #171c22 100%)",
"plaqueColor": "#f6efe2",
"borderAccent": "#3a2312",
"accentText": "#8c6239",
"headerText": "#2c1a0e",
"subText": "#3a2312",
"dividerColor": "rgba(140, 98, 57, 0.3)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 36,
"size": 125,
"borderRadius": 14,
"borderColor": "#3a2312",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 14,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#8c6239",
"fontWeight": "800",
"letterSpacing": 2
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 82,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#2c1a0e",
"fontWeight": "700",
"letterSpacing": 1
}
]
},
{
"key": "qr-3",
"name": "Pizzeria Bella Napoli (QR-3)",
"categoryName": "Özel Masa Kartı",
"svgTemplateUrl": "/qr-3.svg",
"aspectRatio": "400/650",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #faf4e6 0%, #f7eed8 100%)",
"plaqueColor": "#fdfbf5",
"borderAccent": "#b92b27",
"accentText": "#b92b27",
"headerText": "#18181b",
"subText": "#5d4037",
"dividerColor": "rgba(185, 43, 39, 0.3)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 36,
"size": 125,
"borderRadius": 14,
"borderColor": "#b92b27",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 14,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#b92b27",
"fontWeight": "800",
"letterSpacing": 2
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 82,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#18181b",
"fontWeight": "700",
"letterSpacing": 1
}
]
},
{
"key": "qr-8",
"name": "Emerald & Gold Special (QR-8)",
"categoryName": "Özel Kare Masa Kartı",
"svgTemplateUrl": "/qr-8.svg",
"aspectRatio": "1/1",
"bgType": "gradient",
"bgGradient": "radial-gradient(circle at 50% 50%, #0c4443 0%, #062423 100%)",
"plaqueColor": "#f9f5eb",
"borderAccent": "#d1a752",
"accentText": "#d1a752",
"headerText": "#ffffff",
"subText": "#d1a752",
"dividerColor": "rgba(209, 167, 82, 0.4)",
"isActive": true,
"qrConfig": {
"xPercent": 50,
"yPercent": 38,
"size": 115,
"borderRadius": 14,
"borderColor": "#d1a752",
"bgBoxColor": "#FFFFFF"
},
"layers": [
{
"id": "rName",
"text": "{{RESTAURANT_NAME}}",
"yPercent": 16,
"fontSize": 18,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#d1a752",
"fontWeight": "800"
},
{
"id": "cta",
"text": "MENÜYÜ GÖRMEK İÇİN OKUTUN",
"yPercent": 80,
"fontSize": 11,
"fontFamily": "Playfair Display, Georgia, serif",
"color": "#ffffff",
"fontWeight": "700"
}
]
}
]
+2
View File
@@ -9,6 +9,7 @@ import { menuItemsRoutes } from "./routes/menu-items.js";
import { menusRoutes } from "./routes/menus.js";
import { meRoutes } from "./routes/me.js";
import { qrRoutes } from "./routes/qr.js";
import { qrTemplatesRoutes } from "./routes/qr-templates.js";
import { restaurantsRoutes } from "./routes/restaurants.js";
import { subscriptionRoutes } from "./routes/subscription.js";
@@ -42,6 +43,7 @@ await app.register(menusRoutes);
await app.register(menuCategoriesRoutes);
await app.register(menuItemsRoutes);
await app.register(qrRoutes);
await app.register(qrTemplatesRoutes);
await app.register(aiImportsRoutes);
await app.register(domainsRoutes);
await app.register(analyticsRoutes);
+156
View File
@@ -0,0 +1,156 @@
import type { FastifyInstance } from "fastify";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type QrStandPreset } from "@menulio/shared";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.resolve(__dirname, "../data/qr-templates.json");
export interface DynamicQrTemplate extends QrStandPreset {
id: string;
isActive: boolean;
bgType?: "gradient" | "image" | "svg";
bgImageUrl?: string;
createdAt: string;
updatedAt: string;
defaultQrConfig?: {
xPercent: number;
yPercent: number;
size: number;
borderRadius: number;
borderColor: string;
bgBoxColor: string;
};
defaultLayers?: Array<{
id: string;
text: string;
xPercent?: number;
yPercent: number;
fontSize: number;
fontFamily?: string;
color: string;
fontWeight?: string;
}>;
}
async function loadTemplatesFromDisk(): Promise<DynamicQrTemplate[]> {
try {
const content = await fs.readFile(DATA_FILE, "utf-8");
const parsed = JSON.parse(content);
return parsed.map((p: any) => ({
...p,
id: p.key,
isActive: p.isActive ?? true,
createdAt: p.createdAt || new Date().toISOString(),
updatedAt: p.updatedAt || new Date().toISOString(),
defaultQrConfig: p.defaultQrConfig || p.qrConfig,
defaultLayers: p.defaultLayers || p.layers,
}));
} catch {
return [];
}
}
async function saveTemplatesToDisk(templates: DynamicQrTemplate[]): Promise<void> {
try {
await fs.writeFile(DATA_FILE, JSON.stringify(templates, null, 2), "utf-8");
} catch (err) {
console.error("Failed to write qr-templates to disk:", err);
}
}
export async function qrTemplatesRoutes(app: FastifyInstance) {
// 1. Tüm Aktif Şablonları Listele (Mobil ve Web İçin)
app.get("/qr-templates", async () => {
const list = await loadTemplatesFromDisk();
return {
templates: list.filter((t) => t.isActive),
total: list.length,
};
});
// 2. Admin Şablon Listesi (Aktif + Taslak hepsi)
app.get("/admin/qr-templates", async () => {
const list = await loadTemplatesFromDisk();
return {
templates: list,
total: list.length,
};
});
// 3. Yeni Şablon Ekle veya Mevcut Olanı Güncelle
app.post<{ Body: Partial<DynamicQrTemplate> }>("/admin/qr-templates", async (req, reply) => {
const body = req.body;
if (!body.name) {
return reply.status(400).send({ error: "Şablon adı zorunludur." });
}
const key = body.key || `qr-${Date.now()}`;
const list = await loadTemplatesFromDisk();
const existingIndex = list.findIndex((t) => t.id === key || t.key === key);
const now = new Date().toISOString();
const newTemplate: DynamicQrTemplate = {
key,
id: key,
name: body.name,
categoryName: body.categoryName || "Özel Şablon",
svgTemplateUrl: body.svgTemplateUrl || `/templates/${key}.svg`,
aspectRatio: body.aspectRatio || "1/1",
bgType: body.bgType || "gradient",
bgImageUrl: body.bgImageUrl,
bgGradient: body.bgGradient || "radial-gradient(circle at 50% 50%, #132034 0%, #0b0d13 100%)",
plaqueColor: body.plaqueColor || "#132034",
borderAccent: body.borderAccent || "#D4AF37",
accentText: body.accentText || "#D4AF37",
headerText: body.headerText || "#FFFFFF",
subText: body.subText || "#E5C378",
dividerColor: body.dividerColor || "rgba(212, 175, 55, 0.3)",
isActive: body.isActive !== undefined ? body.isActive : true,
createdAt: existingIndex >= 0 ? list[existingIndex].createdAt : now,
updatedAt: now,
defaultQrConfig: body.defaultQrConfig || (body as any).qrConfig || {
xPercent: 50,
yPercent: 38,
size: 115,
borderRadius: 14,
borderColor: body.borderAccent || "#D4AF37",
bgBoxColor: "#FFFFFF",
},
defaultLayers: body.defaultLayers || (body as any).layers || [
{
id: "rName",
text: "{{RESTAURANT_NAME}}",
yPercent: 16,
fontSize: 18,
color: body.accentText || "#D4AF37",
fontWeight: "800",
},
],
};
if (existingIndex >= 0) {
list[existingIndex] = newTemplate;
} else {
list.unshift(newTemplate);
}
await saveTemplatesToDisk(list);
return { success: true, template: newTemplate };
});
// 4. Şablon Sil
app.delete<{ Params: { id: string } }>("/admin/qr-templates/:id", async (req, reply) => {
const { id } = req.params;
const list = await loadTemplatesFromDisk();
const filtered = list.filter((t) => t.id !== id && t.key !== id);
if (filtered.length === list.length) {
return reply.status(404).send({ error: "Şablon bulunamadı." });
}
await saveTemplatesToDisk(filtered);
return { success: true, deletedId: id };
});
}