Compare commits

..
6 Commits
Author SHA1 Message Date
AyrisAIandClaude Sonnet 5 d33a38de4e feat: restaurant cover photo upload
restaurants.cover_url existed in the schema and was already read by the
public menu page, but nothing could ever write it — the update endpoint
didn't accept it and there was no picker in the app. Adds coverUrl to
PATCH /restaurants/:id and a cover photo picker in the mobile account
screen, next to the existing logo picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:58:10 +03:00
AyrisAIandClaude Sonnet 5 df250c40e8 feat(web): public menu language pill selector; fix missing cover_url column
Public menu page now shows a TR|EN|... pill selector (styled after the
Menulio design reference) that switches category/item text to the AI
translations added via the mobile app, falling back to Turkish for
anything not yet translated.

Also adds a migration for restaurants.cover_url, which was referenced by
existing code but was missing from the live database — the public menu
page was silently forcing cover photos to null on every restaurant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:55:26 +03:00
AyrisAIandClaude Sonnet 5 6ec4631e82 feat(mobile): language management UI in account settings
Restaurant owners can now enable/disable AI-translated menu languages
(EN/DE/AR/RU/FR) from the account screen, calling the new
/restaurants/:id/languages endpoints. Turkish stays as the fixed base
language; adding a language triggers a full AI translation pass, removing
one just hides it (translations are kept for a fast re-enable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:53:45 +03:00
AyrisAIandClaude Sonnet 5 00195ae7d7 feat(api): AI-powered multi-language menu translation
Adds enabled_languages on restaurants and translations JSONB on menu
categories/items. New /restaurants/:id/languages endpoints bulk-translate
the full menu via Gemini/GPT-4o-mini (reusing the ai-scanner dual-provider
pattern) when a language is enabled. New categories/items are auto-
translated in the background into any already-enabled languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:46:19 +03:00
AyrisAI 739de7de5c feat(web): add missing qr-3/qr-8/qr-9 stand SVG templates
QR_STAND_PRESETS (packages/shared) references qr-1..qr-9 (skipping
qr-7), but apps/web/public only had qr-1,2,4,5,6.svg — qr-3, qr-8,
and qr-9 404'd wherever the raw preset list renders before/instead of
a DB-driven override. Built to match the existing qr-1.svg structure
(layer-background/layer-icons/qr-placeholder/layer-texts, same 400x650
canvas, QR box at translate(90,215) 220x220 — verified this aligns
with QrStandGeneratorClient's hardcoded 33.08%/55% overlay position),
using each preset's own color palette from qr-stands.ts.
2026-08-29 01:21:05 +03:00
AyrisAI 1f8195ff67 feat: admin auth, web menu template studio, live text editing & Ayris Tech footer backlink 2026-08-23 01:45:00 +03:00
61 changed files with 6516 additions and 420 deletions
+144
View File
@@ -0,0 +1,144 @@
# 🚀 Menulio — Cross-Platform SaaS & Dynamic QR Stand Studio Ecosystem
> **Modern Restoranlar İçin Dijital Menü Deneyimi & Anlık JSON Şablon Motoru Destekli Masa Kartı Tasarım Platformu**
![Menulio Banner](https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&auto=format&fit=crop&q=80)
---
## 📌 Proje Özeti (Executive Summary)
**Menulio**, restoranların ve gastronomi işletmelerinin dijital menülerini ve fiziksel masa kartı QR stantlarını uçtan uca yönetmelerini sağlayan **Full-Stack Cross-Platform SaaS mimarisidir**.
Geleneksel QR menü sistemlerinden farklı olarak Menulio, **0 (Sıfır) App Store güncellemesi gerektiren "JSON-Driven Dynamic Template Engine" (V2)** teknolojisine sahiptir. Admin panelinden tasarlanan SVG/PNG arka planları, kavisli metin hesaplamaları (*curved text math*), 360° döndürme, renk paletleri ve özel QR yerleşimleri API üzerinden anında hem Web hem de Mobil (iOS/Android) platformlarında canlı yayınlanır.
---
## 🛠️ Öne Çıkan Mimari Özellikler (Key Highlights)
### 1. 🎨 JSON-Driven Dynamic QR Stand Engine (V2)
- **Sıfır Güncelleme Dağıtımı**: Mobil uygulamayı yeniden derlemeden Web Admin panelinden yeni masa kartı şablonu oluşturma ve anında yayınlama.
- **Kavisli Metin Matematiği (`curveDegree`)**: Radyal açı hesaplamaları ile dairesel ve yay biçiminde metin hizalama (Yay ◡ ve ◠ kavis derecesi ayarlama).
- **Figma & Instagram Story Tarzı Tuval Editörü**: Sürüklenebilir (*drag & drop*), ölçeklenebilir, 360° döndürülebilir metin ve QR katmanları.
- **Manyetik Kılavuz Çizgileri**: Akıllı dikey/yatay merkezleme ve hizalama çizgileri (*snap-to-grid*).
- **Matbaa Kalitesinde Baskı Çıktısı**: 300 DPI Ultra HD PNG ve A5 Matbaa standartlarında vektörel PDF çıktısı.
### 2. 📱 Cross-Platform Mobil Uygulama (Expo & React Native)
- **iOS & Android Desteği**: Expo SDK 52 ve React Native mimarisi üzerine kurulu yüksek performanslı arayüz.
- **Dokunsal Geri Bildirim (Haptic Feedback)**: İşlem anında `expo-haptics` ile kullanıcıya fiziksel titreşim uyarısı.
- **Yerel iOS Derleme & Paketleme**: Xcode Archive & Fastlane entegrasyonu ile tam imzalı `embedded.mobileprovision` içerikli `.ipa` dağıtımı.
### 3. 🌐 Web Uygulaması & 10+ Lüks Menü Teması (Next.js 15 App Router)
- **10 Özel Restoran Teması**: `Eleia Coastal`, `Neon Street Fuze`, `La Maison d'Élève`, `Swiss Brutalist`, `Amber Artisan`, `Elegant Gold`, `Modern Sapphire`, `Luxury Dark`, `Nordic Minimal`, `Classic Bistro`.
- **Wildcard Subdomain Yönlendirmesi**: `restoran-adi.menul.io` şeklinde her işletmeye özel subdomain ve özel alan adı (custom domain) desteği.
- **Canlı Cihaz Simülatörü**: Admin panelinde tema ve şablon değişikliklerini anında gösteren interaktif iPhone çerçevesi.
### 4. 🔐 Çift Katmanlı Yönetici Güvenliği (Dual-Layer Admin Auth)
- **Next.js Server Middleware**: Yetkisiz sunucu isteklerini anında `/admin/login` sayfasına yönlendiren çerez tabanlı doğrulama.
- **Client Auth Guard**: React tarafında yükleme animasyonu ve oturum doğrulama mekanizması.
- **Supabase Auth & Fallback Credential**: Supabase PostgreSQL veritabanı entegrasyonu ve güvenli token çerezi (`menulio_admin_token`).
---
## 💻 Teknolojik Mimari & Kullanılan Teknolojiler
| Katman | Kullanılan Teknolojiler |
| :--- | :--- |
| **Monorepo Mimarisi** | `pnpm Workspaces`, Shared Type Definitions (`@menulio/shared`) |
| **Web Uygulaması (Frontend)** | `Next.js 15 (App Router)`, `React 18`, `Tailwind CSS`, `Glassmorphism UI` |
| **Mobil Uygulama** | `React Native`, `Expo (SDK 52)`, `Expo Router`, `ViewShot`, `Expo Haptics` |
| **Veritabanı & Auth** | `Supabase (PostgreSQL, Auth, RLS)`, REST API, Local Disk Cache |
| **Görsel & Baskı Motoru** | `html-to-image`, `jspdf`, `qrcode.react`, Google Fonts API |
| **CI/CD & Derleme** | `Xcode 26`, `Fastlane`, `EAS Build (Expo Application Services)` |
---
## 📸 Ekran Görüntüleri & Görsel Bileşenler
```
+-----------------------------------------------------------------------+
| MENULIO ADMIN PANEL - DİNAMİK ŞABLON & TEMA STÜDYOSU |
+-----------------------------------------------------------------------+
| [ Sol Panel: Renk/Düzen Kontrolleri ] | [ Sağ: Canlı iPhone Simülatörü ]|
| - Primary Color: #D4AF37 | +-----------------------------+ |
| - Product Layout: Card / List | | OZZY KEBAP | |
| - Category Layout: Tabs / Accordion | | [ QR KOD BÖLGESİ ] | |
| - Curved Text Math: 35° Arc | | MENÜYÜ GÖRMEK İÇİN OKUTUN | |
| - Font Family: Playfair / Inter | +-----------------------------+ |
+-----------------------------------------------------------------------+
```
---
## 📐 Proje Dosya & Klasör Yapısı
```
menulio/
├── apps/
│ ├── mobile/ # React Native / Expo Mobil Uygulama
│ │ ├── src/
│ │ │ ├── app/ # Expo Router Dosya Tabanlı Yönlendirme
│ │ │ ├── components/
│ │ │ │ ├── qr-canvas/ # Tuval Editörü, Curved Text & Dokunmatik Katmanlar
│ │ │ │ └── qr-templates/ # SVG & Vektörel Şablon Render Bileşenleri
│ │ └── ios/ # Yerel Xcode Proje Klasörü & Podfile
│ │
│ ├── web/ # Next.js 15 Web & Admin Uygulaması
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── admin/ # Yönetici Paneli & Auth Guard (/admin)
│ │ │ │ │ ├── templates/ # QR Stand Şablon Stüdyosu
│ │ │ │ │ └── menu-templates/ # Web Menü Temaları Stüdyosu
│ │ │ │ ├── qr/ # Müşteri QR Masa Kartı Jeneratörü
│ │ │ │ └── menu/[slug]/ # Canlı Restoran Web Menüsü
│ │ │ ├── components/ # Web Bileşenleri & Simülatörler
│ │ │ └── lib/ # Auth, Supabase & Tema Tanımları
│ │
│ └── api/ # Express / Node.js Backend REST Servisleri
└── packages/
└── shared/ # Tip Tanımları, Renk Paletleri & Ortak Sabitler
```
---
## ⚡ Kurulum ve Çalıştırma (Getting Started)
Proje `pnpm` monorepo yapısında kurgulanmıştır.
### 1. Bağımlılıkları Yükleyin
```bash
pnpm install
```
### 2. Geliştirme Sunucularını Başlatın
- **Tüm Uygulamaları Aynı Anda Başlatma**:
```bash
pnpm dev
```
- **Sadece Web Uygulamasını Başlatma**:
```bash
pnpm --filter web dev
```
- **Sadece Mobil Uygulamayı (Expo) Başlatma**:
```bash
pnpm --filter mobile start
```
---
## 👨‍💻 Mühendislik & Problem Çözme Vurguları (Portfolio Highlights)
1. **React Native Font Rendering Fix**:
- React Native'in iOS ve Android native font motorunda virgüllü CSS fallback dizgilerinin (`Georgia, 'Times New Roman', serif`) sessizce varsayılan sistem fontuna düşme problemi tespit edilerek, platforma özel tekil font çözümleme mekanizması geliştirildi.
2. **Kavisli Metin Radyal Geometri Matematiği**:
- Metin karakterlerinin dairesel bir yay etrafında doğru açı (`rotate`), `translateX` ve `translateY` offset hesaplamaları ile hem Web SVG/HTML hem de Native Text bileşenlerinde piksel mükemmelliğinde işlenmesi sağlandı.
3. **Senkronize JSON Şablon Yayınlama**:
- Web Admin panelinde yapılan herhangi bir tasarım veya metin düzenlemesinin, veritabanı ve REST API üzerinden anında mobil uygulama tuvalinde ve web jeneratöründe canlı olarak görünmesi sağlandı.
---
## 📄 Lisans
Bu proje kişisel portfolyo ve özel geliştirme amacıyla kurgulanmıştır. Tüm hakları saklıdır.
+2
View File
@@ -4,6 +4,7 @@ import { env } from "./env.js";
import { aiImportsRoutes } from "./routes/ai-imports.js";
import { analyticsRoutes } from "./routes/analytics.js";
import { domainsRoutes } from "./routes/domains.js";
import { languagesRoutes } from "./routes/languages.js";
import { menuCategoriesRoutes } from "./routes/menu-categories.js";
import { menuItemsRoutes } from "./routes/menu-items.js";
import { menusRoutes } from "./routes/menus.js";
@@ -46,6 +47,7 @@ await app.register(qrRoutes);
await app.register(qrTemplatesRoutes);
await app.register(aiImportsRoutes);
await app.register(domainsRoutes);
await app.register(languagesRoutes);
await app.register(analyticsRoutes);
await app.register(subscriptionRoutes);
+148
View File
@@ -0,0 +1,148 @@
import { env } from "../env.js";
export interface TranslatableItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatableCategory {
id: string;
name: string;
items: TranslatableItem[];
}
export interface TranslatedItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatedCategory {
id: string;
name: string;
items: TranslatedItem[];
}
const LANGUAGE_NAMES: Record<string, string> = {
en: "English",
de: "German",
ar: "Arabic",
ru: "Russian",
fr: "French",
};
function buildPrompt(targetLanguageName: string): string {
return `Sen profesyonel bir restoran menüsü çevirmenisin. Sana Türkçe bir restoran menüsü (kategori adları, ürün adları ve açıklamaları) JSON olarak verilecek.
Bunları ${targetLanguageName} diline çevir. Yemek isimlerini o mutfağın doğal terimleriyle çevir (örn. "Adana Kebap" -> "Adana Kebab", tamamen literal çeviri yapma, restoran menüsü diline uygun doğal bir üslup kullan).
Her kategori ve ürünün "id" alanını AYNEN koru, sadece "name" ve "description" alanlarını çevir. description null ise null bırak.
SADECE aşağıdaki JSON formatında geçerli bir JSON yanıtı ver, başka hiçbir metin ekleme:
{
"categories": [
{ "id": "...", "name": "çevrilmiş kategori adı", "items": [ { "id": "...", "name": "çevrilmiş ürün adı", "description": "çevrilmiş açıklama veya null" } ] }
]
}`;
}
function cleanAndParse(text: string): { categories: TranslatedCategory[] } {
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const parsed = JSON.parse(cleaned) as { categories: TranslatedCategory[] };
if (!parsed.categories || !Array.isArray(parsed.categories)) {
throw new Error("Invalid structure from translator: categories array missing");
}
return parsed;
}
async function translateWithGemini(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{
parts: [
{ text: buildPrompt(targetLanguageName) },
{ text: JSON.stringify({ categories }) },
],
},
],
generationConfig: { response_mime_type: "application/json", temperature: 0.2 },
}),
});
if (!response.ok) {
throw new Error(`Gemini translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!rawText) throw new Error("No text response from Gemini translate");
return cleanAndParse(rawText).categories;
}
async function translateWithOpenAI(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: "gpt-4o-mini",
response_format: { type: "json_object" },
messages: [
{ role: "system", content: buildPrompt(targetLanguageName) },
{ role: "user", content: JSON.stringify({ categories }) },
],
temperature: 0.2,
}),
});
if (!response.ok) {
throw new Error(`OpenAI translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("No message content from OpenAI translate");
return cleanAndParse(content).categories;
}
export function resolveLanguageName(code: string): string {
return LANGUAGE_NAMES[code] ?? code;
}
export async function translateMenu(
categories: TranslatableCategory[],
languageCode: string,
): Promise<TranslatedCategory[]> {
if (categories.length === 0) return [];
const targetLanguageName = resolveLanguageName(languageCode);
if (env.GEMINI_API_KEY) {
try {
return await translateWithGemini(categories, targetLanguageName, env.GEMINI_API_KEY);
} catch (err) {
console.warn("Gemini translate failed, falling back:", err);
}
}
if (env.OPENAI_API_KEY) {
return translateWithOpenAI(categories, targetLanguageName, env.OPENAI_API_KEY);
}
throw new Error("No translation provider configured (GEMINI_API_KEY / OPENAI_API_KEY missing)");
}
+147
View File
@@ -0,0 +1,147 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu, type TranslatableCategory } from "../lib/translator.js";
const SUPPORTED_LANGUAGES = ["en", "de", "ar", "ru", "fr"] as const;
const addLanguageSchema = z.object({
code: z.enum(SUPPORTED_LANGUAGES),
});
async function getRestaurantMenuCategories(restaurantId: string): Promise<TranslatableCategory[]> {
if (!supabase) return [];
const { data: locations } = await supabase.from("locations").select("id").eq("restaurant_id", restaurantId);
const locationIds = (locations ?? []).map((l) => l.id);
if (locationIds.length === 0) return [];
const { data: menus } = await supabase.from("menus").select("id").in("location_id", locationIds);
const menuIds = (menus ?? []).map((m) => m.id);
if (menuIds.length === 0) return [];
const { data: categories } = await supabase
.from("menu_categories")
.select("id, name, menu_items(id, name, description)")
.in("menu_id", menuIds);
return (categories ?? []).map((cat) => ({
id: cat.id,
name: cat.name,
items: (cat.menu_items ?? []).map((item: { id: string; name: string; description: string | null }) => ({
id: item.id,
name: item.name,
description: item.description,
})),
}));
}
export const languagesRoutes: FastifyPluginAsync = async (app) => {
// Add a language: translates all current menu content into it, then
// enables it. New items created afterwards are translated on save
// (see menu-categories.ts / menu-items.ts).
app.post("/restaurants/:id/languages", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
const { id: restaurantId } = req.params as { id: string };
const isMember = await requireRestaurantMember(userId, restaurantId);
if (!isMember) return reply.code(403).send({ message: "forbidden" });
const parsed = addLanguageSchema.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "invalid language code", issues: parsed.error.issues });
}
const { code } = parsed.data;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const current: string[] = restaurant?.enabled_languages ?? ["tr"];
if (current.includes(code)) {
return reply.code(409).send({ message: "Bu dil zaten aktif." });
}
const categories = await getRestaurantMenuCategories(restaurantId);
try {
const translated = await translateMenu(categories, code);
for (const cat of translated) {
const original = categories.find((c) => c.id === cat.id);
const { data: existingCatRow } = await supabase
.from("menu_categories")
.select("translations")
.eq("id", cat.id)
.single();
await supabase
.from("menu_categories")
.update({
translations: { ...(existingCatRow?.translations ?? {}), [code]: { name: cat.name } },
})
.eq("id", cat.id);
for (const item of cat.items) {
const { data: existingItemRow } = await supabase
.from("menu_items")
.select("translations")
.eq("id", item.id)
.single();
await supabase
.from("menu_items")
.update({
translations: {
...(existingItemRow?.translations ?? {}),
[code]: { name: item.name, description: item.description },
},
})
.eq("id", item.id);
}
void original;
}
} catch (err) {
req.log.error(err);
return reply.code(500).send({ message: "Menü çevirisi başarısız oldu", error: (err as Error).message });
}
const { data: updated } = await supabase
.from("restaurants")
.update({ enabled_languages: [...current, code] })
.eq("id", restaurantId)
.select("enabled_languages")
.single();
return reply.send({ enabledLanguages: updated?.enabled_languages ?? [...current, code] });
});
app.delete("/restaurants/:id/languages/:code", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
const { id: restaurantId, code } = req.params as { id: string; code: string };
const isMember = await requireRestaurantMember(userId, restaurantId);
if (!isMember) return reply.code(403).send({ message: "forbidden" });
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const current: string[] = restaurant?.enabled_languages ?? ["tr"];
const next = current.filter((c) => c !== code);
const { data: updated } = await supabase
.from("restaurants")
.update({ enabled_languages: next })
.eq("id", restaurantId)
.select("enabled_languages")
.single();
return reply.send({ enabledLanguages: updated?.enabled_languages ?? next });
});
};
+30
View File
@@ -3,6 +3,34 @@ import { z } from "zod";
import { requireAuth } from "../lib/auth.js";
import { getCategoryIfMember, getMenuIfMember } from "../lib/access.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu } from "../lib/translator.js";
// New categories/items are translated into whatever languages the
// restaurant already has enabled beyond Turkish (bulk-translated once when
// a language is first added — see routes/languages.ts).
async function translateNewCategoryInBackground(restaurantId: string, categoryId: string, name: string) {
if (!supabase) return;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const extraLanguages = (restaurant?.enabled_languages ?? []).filter((l: string) => l !== "tr");
if (extraLanguages.length === 0) return;
const translations: Record<string, { name: string }> = {};
for (const lang of extraLanguages) {
try {
const [translated] = await translateMenu([{ id: categoryId, name, items: [] }], lang);
if (translated) translations[lang] = { name: translated.name };
} catch (err) {
console.warn(`Category translation to ${lang} failed:`, err);
}
}
if (Object.keys(translations).length > 0) {
await supabase.from("menu_categories").update({ translations }).eq("id", categoryId);
}
}
const createCategorySchema = z.object({
name: z.string().min(1).max(120),
@@ -47,6 +75,8 @@ export const menuCategoriesRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ message: "failed to create category" });
}
void translateNewCategoryInBackground(access.restaurantId, data.id, data.name);
return reply.code(201).send(data);
});
+36
View File
@@ -3,6 +3,40 @@ import { z } from "zod";
import { requireAuth } from "../lib/auth.js";
import { getCategoryIfMember, getItemIfMember } from "../lib/access.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu } from "../lib/translator.js";
async function translateNewItemInBackground(
restaurantId: string,
itemId: string,
name: string,
description: string | null,
) {
if (!supabase) return;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const extraLanguages = (restaurant?.enabled_languages ?? []).filter((l: string) => l !== "tr");
if (extraLanguages.length === 0) return;
const translations: Record<string, { name: string; description: string | null }> = {};
for (const lang of extraLanguages) {
try {
const [translatedCat] = await translateMenu(
[{ id: "single", name: "-", items: [{ id: itemId, name, description }] }],
lang,
);
const translatedItem = translatedCat?.items[0];
if (translatedItem) translations[lang] = { name: translatedItem.name, description: translatedItem.description };
} catch (err) {
console.warn(`Item translation to ${lang} failed:`, err);
}
}
if (Object.keys(translations).length > 0) {
await supabase.from("menu_items").update({ translations }).eq("id", itemId);
}
}
const createItemSchema = z.object({
name: z.string().min(1).max(160),
@@ -53,6 +87,8 @@ export const menuItemsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ message: "failed to create item" });
}
void translateNewItemInBackground(access.restaurantId, data.id, data.name, data.description);
return reply.code(201).send(data);
});
+3 -1
View File
@@ -16,6 +16,7 @@ const createRestaurantSchema = z.object({
const updateRestaurantSchema = z.object({
name: z.string().min(1).max(120).optional(),
logoUrl: z.string().nullish(),
coverUrl: z.string().nullish(),
phone: z.string().max(30).nullish(),
address: z.string().max(500).nullish(),
slug: z.string().min(2).max(100).optional(),
@@ -108,7 +109,7 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
}
const { name, logoUrl, phone, address, slug } = parsed.data;
const { name, logoUrl, coverUrl, phone, address, slug } = parsed.data;
let formattedSlug: string | undefined = undefined;
if (slug !== undefined) {
@@ -145,6 +146,7 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
.update({
...(name !== undefined ? { name } : {}),
...(logoUrl !== undefined ? { logo_url: logoUrl } : {}),
...(coverUrl !== undefined ? { cover_url: coverUrl } : {}),
...(phone !== undefined ? { phone } : {}),
...(address !== undefined ? { address } : {}),
...(formattedSlug !== undefined ? { slug: formattedSlug } : {}),
+1
View File
@@ -18,6 +18,7 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.ayristech.menulio",
"buildNumber": "21",
"icon": "./assets/icon.png",
"infoPlist": {
"NSCameraUsageDescription": "Menünüzün fotoğrafını çekerek AI ile dijitalleştirmek için kamera izni gereklidir.",
+30
View File
@@ -0,0 +1,30 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local
# Bundle artifacts
*.jsbundle
# CocoaPods
/Pods/
+11
View File
@@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)
@@ -0,0 +1,544 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
05BAC76EB2F44404BCDAFE0E /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920486E7D8564009BA09A00A /* noop-file.swift */; };
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
47FCCC6B41070FB7061A2F1B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4F738668B5B302410E775CFC /* PrivacyInfo.xcprivacy */; };
96905EF65AED1B983A6B3ABC /* libPods-Menulio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Menulio.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Menulio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Menulio.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Menulio/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Menulio/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Menulio/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Menulio/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Menulio/main.m; sourceTree = "<group>"; };
4F738668B5B302410E775CFC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Menulio/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Menulio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Menulio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6C2E3173556A471DD304B334 /* Pods-Menulio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Menulio.debug.xcconfig"; path = "Target Support Files/Pods-Menulio/Pods-Menulio.debug.xcconfig"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Menulio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Menulio.release.xcconfig"; path = "Target Support Files/Pods-Menulio/Pods-Menulio.release.xcconfig"; sourceTree = "<group>"; };
920486E7D8564009BA09A00A /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Menulio/noop-file.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Menulio/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F5CD4FEA57634883A4542F6A /* Menulio-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Menulio-Bridging-Header.h"; path = "Menulio/Menulio-Bridging-Header.h"; sourceTree = "<group>"; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Menulio/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
96905EF65AED1B983A6B3ABC /* libPods-Menulio.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* Menulio */ = {
isa = PBXGroup;
children = (
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
920486E7D8564009BA09A00A /* noop-file.swift */,
F5CD4FEA57634883A4542F6A /* Menulio-Bridging-Header.h */,
4F738668B5B302410E775CFC /* PrivacyInfo.xcprivacy */,
);
name = Menulio;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Menulio.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* Menulio */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
D65327D7A22EEC0BE12398D9 /* Pods */,
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Menulio.app */,
);
name = Products;
sourceTree = "<group>";
};
92DBD88DE9BF7D494EA9DA96 /* Menulio */ = {
isa = PBXGroup;
children = (
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */,
);
name = Menulio;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = Menulio/Supporting;
sourceTree = "<group>";
};
D65327D7A22EEC0BE12398D9 /* Pods */ = {
isa = PBXGroup;
children = (
6C2E3173556A471DD304B334 /* Pods-Menulio.debug.xcconfig */,
7A4D352CD337FB3A3BF06240 /* Pods-Menulio.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
92DBD88DE9BF7D494EA9DA96 /* Menulio */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* Menulio */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Menulio" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
5C48BE09ECBB9F203B7F2ED2 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
EFB4C3DB6A514B0ADA511E60 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Menulio;
productName = Menulio;
productReference = 13B07F961A680F5B00A75B9A /* Menulio.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Menulio" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Menulio */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
47FCCC6B41070FB7061A2F1B /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Menulio-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
5C48BE09ECBB9F203B7F2ED2 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Menulio/expo-configure-project.sh\"\n";
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Menulio/Pods-Menulio-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Menulio/Pods-Menulio-resources.sh\"\n";
showEnvVarsInLog = 0;
};
EFB4C3DB6A514B0ADA511E60 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Menulio/Pods-Menulio-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Menulio/Pods-Menulio-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
05BAC76EB2F44404BCDAFE0E /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-Menulio.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Menulio/Menulio.entitlements;
CURRENT_PROJECT_VERSION = 21;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = Menulio/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.ayristech.menulio;
PRODUCT_NAME = Menulio;
SWIFT_OBJC_BRIDGING_HEADER = "Menulio/Menulio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-Menulio.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Menulio/Menulio.entitlements;
CURRENT_PROJECT_VERSION = 21;
INFOPLIST_FILE = Menulio/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.ayristech.menulio;
PRODUCT_NAME = Menulio;
SWIFT_OBJC_BRIDGING_HEADER = "Menulio/Menulio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.76.9_@babel+core@7.29.7_supports-color@8.1.1__@babel+preset-env@7.29.7_@_10f0e3ffd93936a63eed56e417b084b2/node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.76.9_@babel+core@7.29.7_supports-color@8.1.1__@babel+preset-env@7.29.7_@_10f0e3ffd93936a63eed56e417b084b2/node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Menulio" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Menulio" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Menulio.app"
BlueprintName = "Menulio"
ReferencedContainer = "container:Menulio.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "MenulioTests.xctest"
BlueprintName = "MenulioTests"
ReferencedContainer = "container:Menulio.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Menulio.app"
BlueprintName = "Menulio"
ReferencedContainer = "container:Menulio.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Menulio.app"
BlueprintName = "Menulio"
ReferencedContainer = "container:Menulio.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Menulio.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+7
View File
@@ -0,0 +1,7 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
#import <Expo/Expo.h>
@interface AppDelegate : EXAppDelegateWrapper
@end
+62
View File
@@ -0,0 +1,62 @@
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end
Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}
@@ -0,0 +1,20 @@
{
"colors": [
{
"color": {
"components": {
"alpha": "1.000",
"blue": "0.0666666666666667",
"green": "0.0509803921568627",
"red": "0.0352941176470588"
},
"color-space": "srgb"
},
"idiom": "universal"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
@@ -0,0 +1,23 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "image@2x.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "image@3x.png",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Menulio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>menulio</string>
<string>com.ayristech.menulio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>21</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Menünüzün fotoğrafını çekerek AI ile dijitalleştirmek için kamera izni gereklidir.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your microphone</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Menü fotoğraflarınızı ve restoran logonuzu yüklemek için galeri izni gereklidir.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,3 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>0A2A.1</string>
<string>3B52.1</string>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
<string>85F4.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
<constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="f934da460e9ab5acae3ad9987d5b676a108796c1"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="d6a0be88096b36fb132659aa90203d39139deda9"/>
</constraints>
<color key="backgroundColor" name="SplashScreenBackground"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
</scene>
</scenes>
<resources>
<image name="SplashScreenLogo" width="414" height="736"/>
<namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="0.0666666666666667" green="0.0509803921568627" red="0.0352941176470588" customColorSpace="sRGB" colorSpace="custom"/>
</namedColor>
</resources>
</document>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
+4
View File
@@ -0,0 +1,4 @@
//
// @generated
// A blank Swift file must be created for native modules with Swift files to work correctly.
//
+66
View File
@@ -0,0 +1,66 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
install! 'cocoapods',
:deterministic_uuids => false
prepare_react_native_project!
target 'Menulio' do
use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'ios'
]
end
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
.each do |pod_name, target_installation_result|
target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
resource_bundle_target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
end
end
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true"
}
+224
View File
@@ -26,10 +26,20 @@ interface RestaurantDetail {
name: string;
slug: string;
logo_url: string | null;
cover_url: string | null;
phone: string | null;
address: string | null;
enabled_languages?: string[];
}
const SUPPORTED_LANGUAGES: { code: string; label: string; flag: string }[] = [
{ code: "en", label: "İngilizce", flag: "🇬🇧" },
{ code: "de", label: "Almanca", flag: "🇩🇪" },
{ code: "ar", label: "Arapça", flag: "🇸🇦" },
{ code: "ru", label: "Rusça", flag: "🇷🇺" },
{ code: "fr", label: "Fransızca", flag: "🇫🇷" },
];
export interface TxtRecord {
name: string;
value: string;
@@ -79,6 +89,7 @@ export default function AccountScreen() {
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [coverUrl, setCoverUrl] = useState<string | null>(null);
const [slug, setSlug] = useState("");
const [savingRest, setSavingRest] = useState(false);
const [deletingRestaurant, setDeletingRestaurant] = useState(false);
@@ -87,6 +98,10 @@ export default function AccountScreen() {
const [selectedTheme, setSelectedTheme] = useState("elegant");
const [savingTheme, setSavingTheme] = useState(false);
// Language / AI Translation state
const [enabledLanguages, setEnabledLanguages] = useState<string[]>(["tr"]);
const [updatingLanguage, setUpdatingLanguage] = useState<string | null>(null);
// Custom Domain state
const [domains, setDomains] = useState<DomainRow[]>([]);
const [newDomainInput, setNewDomainInput] = useState("");
@@ -117,7 +132,9 @@ export default function AccountScreen() {
setPhone(rest.phone ?? "");
setAddress(rest.address ?? "");
setLogoUrl(rest.logo_url ?? null);
setCoverUrl(rest.cover_url ?? null);
setSlug(rest.slug);
setEnabledLanguages(rest.enabled_languages ?? ["tr"]);
// Fetch theme
const themeRes = await api.get<{ themeKey: string }>(`/restaurants/${cached.restaurantId}/theme`).catch(() => ({ themeKey: "elegant" }));
@@ -169,6 +186,29 @@ export default function AccountScreen() {
}
}
async function handlePickCover() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("İzin Gerekli", "Kapak fotoğrafı seçebilmek için fotoğraf galerisi izni gereklidir.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [16, 9],
quality: 0.6,
base64: true,
});
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const newCover = asset.base64 ? `data:image/jpeg;base64,${asset.base64}` : asset.uri;
setCoverUrl(newCover);
}
}
async function handleSaveRestaurant() {
if (!active || !name.trim()) return;
setSavingRest(true);
@@ -179,6 +219,7 @@ export default function AccountScreen() {
phone: phone.trim() || null,
address: address.trim() || null,
logoUrl: logoUrl || null,
coverUrl: coverUrl || null,
slug: slug.trim() || undefined,
});
@@ -214,6 +255,54 @@ export default function AccountScreen() {
}
}
async function handleAddLanguage(code: string, label: string) {
if (!active || updatingLanguage) return;
setUpdatingLanguage(code);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
try {
const res = await api.post<{ enabledLanguages: string[] }>(
`/restaurants/${active.restaurantId}/languages`,
{ code },
);
setEnabledLanguages(res.enabledLanguages);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
Alert.alert("Menü Çevrildi 🎉", `Menünüz yapay zeka ile ${label} diline çevrildi.`);
} catch (err) {
Alert.alert("Hata", err instanceof Error ? err.message : "Dil eklenemedi.");
} finally {
setUpdatingLanguage(null);
}
}
async function handleRemoveLanguage(code: string, label: string) {
if (!active || updatingLanguage) return;
Alert.alert(
`${label} Dilini Kaldır`,
"Bu dil menüde artık gösterilmeyecek. Çeviriler saklanacağı için dili tekrar eklerseniz yeniden çevrilmesi gerekmez.",
[
{ text: "Vazgeç", style: "cancel" },
{
text: "Kaldır",
style: "destructive",
onPress: async () => {
setUpdatingLanguage(code);
try {
const res = await api.delete<{ enabledLanguages: string[] }>(
`/restaurants/${active.restaurantId}/languages/${code}`,
);
setEnabledLanguages(res.enabledLanguages);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
} catch (err) {
Alert.alert("Hata", err instanceof Error ? err.message : "Dil kaldırılamadı.");
} finally {
setUpdatingLanguage(null);
}
},
},
],
);
}
async function handleAddCustomDomain() {
if (!active || !newDomainInput.trim()) {
Alert.alert("Eksik Bilgi", "Lütfen bağlamak istediğiniz alan adını girin (Örn: menu.kebapciahmet.com)");
@@ -605,6 +694,94 @@ export default function AccountScreen() {
</View>
</View>
{/* Language / AI Translation Section */}
<View
style={{
backgroundColor: "#FFFFFF",
borderRadius: 20,
padding: 20,
borderWidth: 1,
borderColor: "#E7E5E4",
gap: 14,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Ionicons name="language-outline" size={20} color="#C8A96B" />
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
Diller (Yapay Zeka Çevirisi)
</Text>
</View>
<Text style={{ fontSize: 12, color: "#78716C", lineHeight: 18 }}>
Menünüzü yapay zeka ile otomatik olarak çevirin. Müşterileriniz menüde istedikleri dili seçebilir.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{/* Turkish — always on, base language */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "#1C1917",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
}}
>
<Text style={{ fontSize: 13 }}>🇹🇷</Text>
<Text style={{ fontSize: 12, fontWeight: "700", color: "#FFFFFF" }}>Türkçe</Text>
</View>
{SUPPORTED_LANGUAGES.map((lang) => {
const isEnabled = enabledLanguages.includes(lang.code);
const isUpdating = updatingLanguage === lang.code;
return (
<Pressable
key={lang.code}
onPress={() =>
isEnabled
? handleRemoveLanguage(lang.code, lang.label)
: handleAddLanguage(lang.code, lang.label)
}
disabled={isUpdating}
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: isEnabled ? "#FDF8F0" : "#FAF8F5",
borderWidth: 1.5,
borderColor: isEnabled ? "#C8A96B" : "#E7E5E4",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
opacity: isUpdating ? 0.6 : 1,
}}
>
{isUpdating ? (
<ActivityIndicator size="small" color="#C8A96B" />
) : (
<Text style={{ fontSize: 13 }}>{lang.flag}</Text>
)}
<Text
style={{
fontSize: 12,
fontWeight: "700",
color: isEnabled ? "#926E27" : "#57534E",
}}
>
{lang.label}
</Text>
{isEnabled ? (
<Ionicons name="close-circle" size={14} color="#C8A96B" />
) : (
<Ionicons name="add-circle-outline" size={14} color="#A8A29E" />
)}
</Pressable>
);
})}
</View>
</View>
{/* Custom Domain Section */}
<View
style={{
@@ -902,6 +1079,53 @@ export default function AccountScreen() {
</Text>
</View>
{/* Cover Photo Picker */}
<View>
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
Kapak Fotoğrafı (Menü Üst Görseli)
</Text>
<Pressable
onPress={handlePickCover}
style={{
width: "100%",
aspectRatio: 16 / 9,
borderRadius: 14,
overflow: "hidden",
backgroundColor: "#1C1917",
borderWidth: 1.5,
borderColor: "#D6D3D1",
alignItems: "center",
justifyContent: "center",
}}
>
{coverUrl ? (
<Image source={{ uri: coverUrl }} style={{ width: "100%", height: "100%" }} resizeMode="cover" />
) : (
<View style={{ alignItems: "center", gap: 6 }}>
<Ionicons name="image-outline" size={28} color="#C8A96B" />
<Text style={{ color: "#C8A96B", fontSize: 12, fontWeight: "700" }}>
Kapak Fotoğrafı Ekle
</Text>
</View>
)}
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "rgba(0,0,0,0.6)",
alignItems: "center",
paddingVertical: 6,
}}
>
<Text style={{ color: "#FFFFFF", fontSize: 11, fontWeight: "700" }}>
📷 {coverUrl ? "Değiştir" : "Fotoğraf Seç"}
</Text>
</View>
</Pressable>
</View>
{/* Restaurant Name */}
<View>
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
@@ -1,11 +1,9 @@
import React, { useRef } from "react";
import {
PanResponder,
Pressable,
Text,
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import { type CanvasTextLayer, getCanvasFontFamily } from "./types";
@@ -23,6 +21,78 @@ interface CanvasLayerItemProps {
canvasHeight: number;
}
export const CurvedTextNative: React.FC<{
text: string;
fontSize: number;
color: string;
fontFamily: string;
fontWeight?: any;
letterSpacing?: number;
curveDegree: number;
hasShadow?: boolean;
}> = ({
text,
fontSize,
color,
fontFamily,
fontWeight = "800",
letterSpacing = 0.5,
curveDegree,
hasShadow,
}) => {
const isUp = curveDegree > 0;
const absDegree = Math.abs(curveDegree);
const radius = Math.max(40, 420 - absDegree * 3.5);
const charWidth = fontSize * 0.52 + (letterSpacing || 0);
const numChars = text.length;
const arcLength = numChars * charWidth;
const totalAngleRad = arcLength / radius;
const stepRad = numChars > 1 ? totalAngleRad / (numChars - 1) : 0;
const startAngleRad = -totalAngleRad / 2;
const maxOffsetY = Math.abs(-radius * (Math.cos(totalAngleRad / 2) - 1));
const containerHeight = fontSize + maxOffsetY + 12;
const containerWidth = Math.max(numChars * charWidth, radius * 2 * Math.sin(totalAngleRad / 2)) + 16;
return (
<View style={{ width: containerWidth, height: containerHeight, alignItems: "center", justifyContent: "center" }}>
{text.split("").map((ch, i) => {
const angleRad = startAngleRad + i * stepRad;
const angleDeg = angleRad * (180 / Math.PI);
const charX = radius * Math.sin(angleRad);
const charY = isUp
? -radius * (Math.cos(angleRad) - 1)
: radius * (Math.cos(angleRad) - 1);
const charRotate = isUp ? angleDeg : -angleDeg;
return (
<Text
key={i}
style={{
position: "absolute",
fontSize,
color,
fontFamily,
fontWeight,
letterSpacing,
textShadowColor: hasShadow ? "rgba(0,0,0,0.8)" : "transparent",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
transform: [
{ translateX: charX },
{ translateY: charY },
{ rotate: `${charRotate}deg` },
],
}}
>
{ch === " " ? "\u00A0" : ch}
</Text>
);
})}
</View>
);
};
export const CanvasLayerItem: React.FC<CanvasLayerItemProps> = ({
layer,
isSelected,
@@ -191,6 +261,18 @@ export const CanvasLayerItem: React.FC<CanvasLayerItemProps> = ({
}}
>
<View style={renderBackgroundStyle()}>
{layer.curveDegree && layer.curveDegree !== 0 ? (
<CurvedTextNative
text={layer.text}
fontSize={layer.fontSize}
color={layer.color}
fontFamily={getCanvasFontFamily(layer.fontId)}
fontWeight={layer.fontWeight || "bold"}
letterSpacing={layer.letterSpacing ?? 0.5}
curveDegree={layer.curveDegree}
hasShadow={layer.hasShadow}
/>
) : (
<Text
style={{
fontSize: layer.fontSize,
@@ -206,37 +288,9 @@ export const CanvasLayerItem: React.FC<CanvasLayerItemProps> = ({
>
{layer.text}
</Text>
</View>
{/* Seçili İken Üst Köşedeki Küçük Silme Rozeti */}
{isSelected && (
<Pressable
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: -8,
right: -8,
backgroundColor: "#EF4444",
width: 18,
height: 18,
borderRadius: 9,
alignItems: "center",
justifyContent: "center",
borderWidth: 1.5,
borderColor: "#0C0C0E",
}}
>
<Ionicons name="close" size={11} color="#FFFFFF" />
</Pressable>
)}
</View>
</View>
</View>
);
};
@@ -45,6 +45,7 @@ export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> =
const [textAlign, setTextAlign] = useState<"left" | "center" | "right">("center");
const [bgStyle, setBgStyle] = useState<"none" | "solid" | "soft" | "outline">("none");
const [bgBoxColor, setBgBoxColor] = useState<string | undefined>(undefined);
const [curveDegree, setCurveDegree] = useState<number>(0);
// Modal açıldığında layer verilerini yükle
useEffect(() => {
@@ -54,6 +55,7 @@ export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> =
setColor(layer.color || "#FFFFFF");
setFontSize(layer.fontSize || 16);
setTextAlign(layer.textAlign || "center");
setCurveDegree(layer.curveDegree || 0);
if (layer.bgBoxColor) {
if (layer.bgBoxColor.includes("0.2") || layer.bgBoxColor.includes("0.3")) {
setBgStyle("soft");
@@ -73,6 +75,19 @@ export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> =
}
}, [layer, visible]);
// Kavis Modunu Değiştir (Düz (0) -> Yukarı Kavis (40) -> Ters Kavis (-40) -> Düz)
const handleToggleCurveDegree = () => {
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} catch {}
setCurveDegree((prev) => {
if (prev === 0) return 40;
if (prev === 40) return 60;
if (prev === 60) return -40;
return 0;
});
};
// Arka plan pill stilini sırayla değiştir (Instagram "A" butonu mantığı)
const handleToggleBgStyle = () => {
try {
@@ -155,6 +170,7 @@ export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> =
textAlign,
bgBoxColor: finalBg,
bgStyle,
curveDegree,
hasShadow: bgStyle === "outline" || (!finalBg && color !== "#121215"),
});
onClose();
@@ -234,6 +250,26 @@ export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> =
)}
</Pressable>
{/* Kavis (Yay / Eğri Yazı) Toggle Butonu */}
<Pressable
onPress={handleToggleCurveDegree}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={[
styles.toolPill,
curveDegree !== 0 && { backgroundColor: "#D4AF37" },
]}
>
<Text
style={[
styles.bgPillText,
{ fontSize: 14 },
curveDegree !== 0 ? { color: "#0C0C0E", fontWeight: "900" } : { color: "#FFFFFF" },
]}
>
{curveDegree > 0 ? "◠" : curveDegree < 0 ? "◡" : "⌒"}
</Text>
</Pressable>
{/* Katmanı Sil (Varsa) */}
{onDelete && (
<Pressable
@@ -358,6 +394,69 @@ export const InstagramTextEditorModal: React.FC<InstagramTextEditorModalProps> =
</View>
</View>
{/* ALT BÖLÜM 0: KAVİS HASSAS AYAR BARI */}
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", paddingHorizontal: 16, paddingVertical: 4, gap: 10 }}>
<Text style={{ color: "#A1A1AA", fontSize: 11, fontWeight: "700" }}>KAVİS:</Text>
<View style={{ flexDirection: "row", alignItems: "center", backgroundColor: "rgba(255,255,255,0.1)", borderRadius: 14, paddingHorizontal: 6, paddingVertical: 3, gap: 8 }}>
<Pressable
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
setCurveDegree((deg) => Math.max(-100, deg - 5));
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={{ width: 26, height: 26, borderRadius: 13, backgroundColor: "rgba(255,255,255,0.15)", alignItems: "center", justifyContent: "center" }}
>
<Ionicons name="remove" size={15} color="#FFFFFF" />
</Pressable>
<Text style={{ color: "#D4AF37", fontSize: 13, fontWeight: "900", minWidth: 36, textAlign: "center" }}>
{curveDegree}°
</Text>
<Pressable
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
setCurveDegree((deg) => Math.min(100, deg + 5));
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={{ width: 26, height: 26, borderRadius: 13, backgroundColor: "rgba(255,255,255,0.15)", alignItems: "center", justifyContent: "center" }}
>
<Ionicons name="add" size={15} color="#FFFFFF" />
</Pressable>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{[
{ label: "Düz (0°)", deg: 0 },
{ label: "25° ◠", deg: 25 },
{ label: "45° ◠", deg: 45 },
{ label: "65° ◠", deg: 65 },
{ label: "-35° ◡", deg: -35 },
].map((item) => {
const isActive = curveDegree === item.deg;
return (
<Pressable
key={item.deg}
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
setCurveDegree(item.deg);
}}
style={{
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 12,
backgroundColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.1)",
}}
>
<Text style={{ color: isActive ? "#0C0C0E" : "#FFFFFF", fontSize: 11, fontWeight: isActive ? "800" : "600" }}>
{item.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* ALT BÖLÜM 1: RENK SEÇİCİ BALONCUKLAR */}
<View style={styles.colorPickerBar}>
<ScrollView
@@ -3,6 +3,7 @@ import {
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
@@ -14,6 +15,7 @@ import { InstagramTextEditorModal } from "./InstagramTextEditorModal";
import {
type CanvasTextLayer,
type CanvasQrConfig,
CANVAS_FONT_OPTIONS,
QR_SIZE_PRESETS,
QR_RADIUS_PRESETS,
getDefaultLayersForTemplate,
@@ -537,7 +539,79 @@ export const QrCanvasStudio: React.FC<QrCanvasStudioProps> = ({
</View>
</View>
{/* Alt Sıra: Hızlı Döndürme Açısı Hapları */}
{/* Hızlı Metin Değiştirme Girdisi */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: "#0C0C0E",
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 6,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.4)",
}}
>
<Ionicons name="text-outline" size={14} color="#D4AF37" />
<TextInput
value={selectedLayer.text}
onChangeText={(newText: string) => {
onLayersChange((prev) =>
prev.map((l) => (l.id === selectedLayer.id ? { ...l, text: newText } : l))
);
}}
placeholder="Metin yazın..."
placeholderTextColor="#71717A"
style={{
flex: 1,
color: "#FFFFFF",
fontSize: 13,
fontWeight: "700",
padding: 0,
}}
/>
</View>
{/* Alt Sıra 1: Font Stili Seçici */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text style={{ color: "#71717A", fontSize: 10, fontWeight: "700" }}>FONT:</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{CANVAS_FONT_OPTIONS.map((f) => {
const isActive = (selectedLayer.fontId || "sans") === f.id;
return (
<Pressable
key={f.id}
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
onLayersChange((prev) =>
prev.map((l) => (l.id === selectedLayer.id ? { ...l, fontId: f.id } : l))
);
}}
style={{
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 10,
backgroundColor: isActive ? "#D4AF37" : "#242429",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 10.5,
fontWeight: isActive ? "900" : "700",
fontFamily: f.fontFamily,
}}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* Alt Sıra 2: Hızlı Döndürme Açısı Hapları */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text style={{ color: "#71717A", fontSize: 10, fontWeight: "700" }}>EĞİM:</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
@@ -573,6 +647,94 @@ export const QrCanvasStudio: React.FC<QrCanvasStudioProps> = ({
})}
</ScrollView>
</View>
{/* Alt Sıra 3: Kavis (Eğri Yazı) Stepper & Preset Hapları */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text style={{ color: "#71717A", fontSize: 10, fontWeight: "700" }}>KAVİS:</Text>
{/* Stepper Butonları (- / +) */}
<View style={{ flexDirection: "row", alignItems: "center", backgroundColor: "#242429", borderRadius: 10, paddingHorizontal: 4, paddingVertical: 2, gap: 4 }}>
<Pressable
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id
? { ...l, curveDegree: Math.max(-100, (l.curveDegree || 0) - 5) }
: l
)
);
}}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: "rgba(255,255,255,0.12)", alignItems: "center", justifyContent: "center" }}
>
<Ionicons name="remove" size={13} color="#FFFFFF" />
</Pressable>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "900", minWidth: 30, textAlign: "center" }}>
{(selectedLayer.curveDegree || 0)}°
</Text>
<Pressable
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id
? { ...l, curveDegree: Math.min(100, (l.curveDegree || 0) + 5) }
: l
)
);
}}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: "rgba(255,255,255,0.12)", alignItems: "center", justifyContent: "center" }}
>
<Ionicons name="add" size={13} color="#FFFFFF" />
</Pressable>
</View>
{/* Hazır Kavis Presetleri */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{[
{ label: "0° (Düz)", deg: 0 },
{ label: "20° ◠", deg: 20 },
{ label: "35° ◠", deg: 35 },
{ label: "50° ◠", deg: 50 },
{ label: "70° ◠", deg: 70 },
{ label: "-35° ◡", deg: -35 },
{ label: "-50° ◡", deg: -50 },
].map((item) => {
const isActive = (selectedLayer.curveDegree || 0) === item.deg;
return (
<Pressable
key={item.deg}
onPress={() => {
try { Haptics.selectionAsync(); } catch {}
onLayersChange((prev) =>
prev.map((l) => (l.id === selectedLayer.id ? { ...l, curveDegree: item.deg } : l))
);
}}
style={{
paddingHorizontal: 9,
paddingVertical: 4,
borderRadius: 10,
backgroundColor: isActive ? "#D4AF37" : "#242429",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 10.5,
fontWeight: isActive ? "800" : "600",
}}
>
{item.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
</View>
)}
+23 -12
View File
@@ -1,3 +1,5 @@
import { Platform } from "react-native";
export interface CanvasTextLayer {
id: string;
text: string;
@@ -13,6 +15,7 @@ export interface CanvasTextLayer {
hasShadow?: boolean;
letterSpacing?: number;
fontWeight?: "normal" | "600" | "700" | "800" | "900";
curveDegree?: number; // -100 (Aşağı Kavis ◡) ile +100 (Yukarı Kavis ◠) arası kavis derecesi
}
export interface CanvasQrConfig {
@@ -30,40 +33,53 @@ export const CANVAS_FONT_OPTIONS = [
{
id: "sans",
label: "MODERN",
fontFamily: "System, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif",
fontFamily: Platform.OS === "ios" ? "System" : "sans-serif",
},
{
id: "serif",
label: "ZARİF",
fontFamily: "Georgia, 'Times New Roman', serif",
fontFamily: Platform.OS === "ios" ? "Georgia" : "serif",
},
{
id: "times",
label: "KLASİK",
fontFamily: "'Times New Roman', Times, Georgia, serif",
fontFamily: Platform.OS === "ios" ? "Times New Roman" : "serif",
},
{
id: "typewriter",
label: "DAKTİLO",
fontFamily: "Courier, 'Courier New', monospace",
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
},
{
id: "palatino",
label: "LÜKS",
fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
fontFamily: Platform.OS === "ios" ? "Palatino" : "serif",
},
{
id: "trebuchet",
label: "DİNAMİK",
fontFamily: "'Trebuchet MS', 'Segoe UI', Arial, sans-serif",
fontFamily: Platform.OS === "ios" ? "Trebuchet MS" : "sans-serif",
},
{
id: "heavy",
label: "GÜÇLÜ",
fontFamily: "Impact, 'Arial Black', sans-serif",
fontFamily: Platform.OS === "ios" ? "Impact" : "sans-serif-black",
},
];
export function getCanvasFontFamily(fontId?: string): string {
if (!fontId) return Platform.OS === "ios" ? "System" : "sans-serif";
const found = CANVAS_FONT_OPTIONS.find((f) => f.id === fontId);
if (found) {
return found.fontFamily;
}
if (fontId.includes(",")) {
const first = fontId.split(",")[0].trim().replace(/['"]/g, "");
return first || (Platform.OS === "ios" ? "System" : "sans-serif");
}
return fontId;
}
export const CANVAS_COLOR_PALETTE = [
{ id: "gold", color: "#D4AF37", label: "Altın" },
{ id: "white", color: "#FFFFFF", label: "Beyaz" },
@@ -106,11 +122,6 @@ export const QR_BORDER_COLORS = [
{ id: "none", color: "transparent", label: "Yok" },
];
export function getCanvasFontFamily(fontId?: string): string {
const found = CANVAS_FONT_OPTIONS.find((f) => f.id === fontId);
return found ? found.fontFamily : "System, -apple-system, Roboto, sans-serif";
}
export function getDefaultQrConfigForTemplate(
templateKey: string,
canvasWidth: number,
@@ -24,39 +24,47 @@ export interface QrTemplateProps extends SvgProps {
hideQr?: boolean;
}
import { Platform } from "react-native";
// 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",
fontFamily: Platform.OS === "ios" ? "System" : "sans-serif",
},
{
id: "serif",
label: "Zarif Serif",
fontFamily: "Georgia, 'Times New Roman', serif",
fontFamily: Platform.OS === "ios" ? "Georgia" : "serif",
},
{
id: "times",
label: "Klasik (Times)",
fontFamily: "'Times New Roman', Times, Georgia, serif",
fontFamily: Platform.OS === "ios" ? "Times New Roman" : "serif",
},
{
id: "palatino",
label: "Lüks Palatino",
fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
fontFamily: Platform.OS === "ios" ? "Palatino" : "serif",
},
{
id: "trebuchet",
label: "Dinamik",
fontFamily: "'Trebuchet MS', 'Segoe UI', Arial, sans-serif",
fontFamily: Platform.OS === "ios" ? "Trebuchet MS" : "sans-serif",
},
];
export function getFontFamily(fontId?: string, defaultFamily = "Georgia, serif"): string {
if (!fontId) return defaultFamily;
export function getFontFamily(fontId?: string, defaultFamily = "Georgia"): string {
if (!fontId) {
return defaultFamily.includes(",") ? defaultFamily.split(",")[0].trim().replace(/['"]/g, "") : defaultFamily;
}
const found = FONT_OPTIONS.find((f) => f.id === fontId);
return found ? found.fontFamily : defaultFamily;
if (found) return found.fontFamily;
if (fontId.includes(",")) {
return fontId.split(",")[0].trim().replace(/['"]/g, "");
}
return fontId;
}
// Çok satırlı (Enter / \n) ve dikey kaydırmalı (offsetY) SVG metin render fonksiyonu
+76
View File
@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 650" width="400" height="650">
<defs>
<!-- Warm cream gradient for the outer canvas -->
<linearGradient id="cream-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#faf4e6" />
<stop offset="100%" stop-color="#f7eed8" />
</linearGradient>
<!-- Rosso accent gradient -->
<linearGradient id="rosso-accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#d1453f" />
<stop offset="100%" stop-color="#b92b27" />
</linearGradient>
</defs>
<!-- 1. Backgrounds & Contrast Panels -->
<g id="layer-background">
<rect id="bg-canvas" width="400" height="650" fill="url(#cream-bg)" />
<rect id="main-card" x="20" y="20" width="360" height="610" rx="24" fill="#fdfbf5" stroke="url(#rosso-accent)" stroke-width="1.5" />
<rect id="inner-border" x="30" y="30" width="340" height="590" rx="18" fill="none" stroke="#e8ddc7" stroke-width="1" stroke-dasharray="5 3" />
</g>
<!-- 2. Decorative Icons & Accents -->
<g id="layer-icons">
<!-- Pizza slice crest -->
<g transform="translate(170, 50)" stroke="url(#rosso-accent)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M30 6 L58 46 A32 32 0 0 1 2 46 Z" />
<circle cx="26" cy="24" r="2.2" fill="#b92b27" stroke="none" />
<circle cx="38" cy="30" r="2.2" fill="#5d4037" stroke="none" />
<circle cx="30" cy="38" r="2.2" fill="#b92b27" stroke="none" />
<path d="M2 46 h56" stroke-width="1" />
</g>
<!-- Center wheat-sprig divider -->
<g id="divider-accent">
<path d="M130 162 h55 M215 162 h55" stroke="url(#rosso-accent)" stroke-width="1" opacity="0.6" />
<polygon points="200,157 202.5,161.5 207,162 203.5,165 204.5,169.5 200,167 195.5,169.5 196.5,165 193,162 197.5,161.5" fill="url(#rosso-accent)" />
</g>
<!-- WiFi Icon -->
<g transform="translate(68, 592)" stroke="#8c6b52" stroke-width="1.5" fill="none" stroke-linecap="round">
<path d="M0 0 a14 14 0 0 1 18 0" />
<path d="M3 4 a9 9 0 0 1 12 0" />
<circle cx="9" cy="8" r="1" fill="#8c6b52" />
</g>
<!-- Instagram / Social Icon -->
<g transform="translate(225, 592)" stroke="#8c6b52" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="0" y="0" width="13" height="13" rx="3.5" />
<circle cx="6.5" cy="6.5" r="3" />
<circle cx="10" cy="3" r="0.5" fill="#8c6b52" />
</g>
</g>
<!-- 3. Clean Placeholder Box for QR Code (aligned to the app's fixed 33.08% / 55% overlay) -->
<g id="qr-placeholder" transform="translate(90, 215)">
<rect width="220" height="220" rx="20" fill="#FFFFFF" stroke="#f0d9d8" stroke-width="2" />
<rect x="12" y="12" width="196" height="196" rx="14" fill="#FCFAF6" stroke="#f0d9d8" stroke-dasharray="4 3" />
<text x="110" y="115" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#b92b27" text-anchor="middle">QR KOD ALANI</text>
</g>
<!-- 4. Native Editable Texts -->
<g id="layer-texts" font-family="Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" text-anchor="middle">
<text x="200" y="130" font-size="22" font-weight="800" fill="#18181b" letter-spacing="2">PIZZERIA BELLA NAPOLI</text>
<text x="200" y="150" font-size="11" font-weight="500" fill="#b92b27" letter-spacing="2.5">TEMASSIZ DİJİTAL MENÜ</text>
<text x="200" y="475" font-size="13" font-weight="700" fill="#18181b" letter-spacing="1.2">MENÜYÜ GÖRMEK İÇİN OKUTUN</text>
<text x="200" y="495" font-size="11" font-weight="400" fill="#5d4037">Kameranız ile QR kodu taratın</text>
<rect x="140" y="520" width="120" height="28" rx="14" fill="#fdece9" stroke="#f0d9d8" stroke-width="1" />
<text x="200" y="539" font-size="12" font-weight="600" fill="#b92b27" letter-spacing="0.5">Masa No: 01</text>
<text x="128" y="602" font-size="10" font-weight="400" fill="#8c6b52" text-anchor="start">Bella_Guest</text>
<text x="246" y="602" font-size="10" font-weight="400" fill="#8c6b52" text-anchor="start">@bellanapoli</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 650" width="400" height="650">
<defs>
<linearGradient id="gold-accent-8" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e9c877" />
<stop offset="50%" stop-color="#d1a752" />
<stop offset="100%" stop-color="#a3803a" />
</linearGradient>
<linearGradient id="emerald-surface" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0c4443" />
<stop offset="100%" stop-color="#062423" />
</linearGradient>
</defs>
<!-- 1. Backgrounds & Contrast Panels -->
<g id="layer-background">
<rect id="bg-canvas" width="400" height="650" fill="#052120" />
<rect id="main-card" x="20" y="20" width="360" height="610" rx="24" fill="url(#emerald-surface)" stroke="url(#gold-accent-8)" stroke-width="1.5" />
<rect id="inner-border" x="30" y="30" width="340" height="590" rx="18" fill="none" stroke="#134a48" stroke-width="1" stroke-dasharray="5 3" />
</g>
<!-- 2. Decorative Icons & Accents -->
<g id="layer-icons">
<!-- Laurel crest -->
<g transform="translate(170, 50)" stroke="url(#gold-accent-8)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M30 4 v42" />
<path d="M30 12 c-10 0 -16 6 -16 14 M30 22 c-10 0 -15 5 -15 12 M30 32 c-10 0 -13 4 -13 10" />
<path d="M30 12 c10 0 16 6 16 14 M30 22 c10 0 15 5 15 12 M30 32 c10 0 13 4 13 10" />
</g>
<g id="divider-accent">
<path d="M130 162 h55 M215 162 h55" stroke="url(#gold-accent-8)" stroke-width="1" opacity="0.6" />
<polygon points="200,157 202.5,161.5 207,162 203.5,165 204.5,169.5 200,167 195.5,169.5 196.5,165 193,162 197.5,161.5" fill="url(#gold-accent-8)" />
</g>
<!-- WiFi Icon -->
<g transform="translate(68, 592)" stroke="#7fb9b3" stroke-width="1.5" fill="none" stroke-linecap="round">
<path d="M0 0 a14 14 0 0 1 18 0" />
<path d="M3 4 a9 9 0 0 1 12 0" />
<circle cx="9" cy="8" r="1" fill="#7fb9b3" />
</g>
<!-- Instagram / Social Icon -->
<g transform="translate(225, 592)" stroke="#7fb9b3" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="0" y="0" width="13" height="13" rx="3.5" />
<circle cx="6.5" cy="6.5" r="3" />
<circle cx="10" cy="3" r="0.5" fill="#7fb9b3" />
</g>
</g>
<!-- 3. Clean Placeholder Box for QR Code (aligned to the app's fixed 33.08% / 55% overlay) -->
<g id="qr-placeholder" transform="translate(90, 215)">
<rect width="220" height="220" rx="20" fill="#f9f5eb" stroke="url(#gold-accent-8)" stroke-width="2" />
<rect x="12" y="12" width="196" height="196" rx="14" fill="#FFFFFF" stroke="#e6dcc0" stroke-dasharray="4 3" />
<text x="110" y="115" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#a3803a" text-anchor="middle">QR KOD ALANI</text>
</g>
<!-- 4. Native Editable Texts -->
<g id="layer-texts" font-family="Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" text-anchor="middle">
<text x="200" y="130" font-size="22" font-weight="800" fill="#ffffff" letter-spacing="2">EMERALD &amp; GOLD</text>
<text x="200" y="150" font-size="11" font-weight="500" fill="#d1a752" letter-spacing="2.5">TEMASSIZ DİJİTAL MENÜ</text>
<text x="200" y="475" font-size="13" font-weight="700" fill="#ffffff" letter-spacing="1.2">MENÜYÜ GÖRMEK İÇİN OKUTUN</text>
<text x="200" y="495" font-size="11" font-weight="400" fill="#8fbdb8">Kameranız ile QR kodu taratın</text>
<rect x="140" y="520" width="120" height="28" rx="14" fill="#0f3c3a" stroke="#1a5350" stroke-width="1" />
<text x="200" y="539" font-size="12" font-weight="600" fill="#d1a752" letter-spacing="0.5">Masa No: 01</text>
<text x="128" y="602" font-size="10" font-weight="400" fill="#7fb9b3" text-anchor="start">Emerald_Guest</text>
<text x="246" y="602" font-size="10" font-weight="400" fill="#7fb9b3" text-anchor="start">@emeraldgold</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+74
View File
@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 650" width="400" height="650">
<defs>
<linearGradient id="vintage-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f8f6f0" />
<stop offset="100%" stop-color="#e2ded5" />
</linearGradient>
</defs>
<!-- 1. Backgrounds & Contrast Panels -->
<g id="layer-background">
<rect id="bg-canvas" width="400" height="650" fill="url(#vintage-bg)" />
<rect id="main-card" x="20" y="20" width="360" height="610" rx="10" fill="#ffffff" stroke="#d5cebe" stroke-width="1.5" />
<rect id="inner-border" x="30" y="30" width="340" height="590" fill="none" stroke="#1e3246" stroke-width="1" />
<rect id="inner-border-2" x="34" y="34" width="332" height="582" fill="none" stroke="#d5cebe" stroke-width="1" />
<!-- Vintage corner flourishes -->
<g stroke="#a88b58" stroke-width="1" fill="none">
<path d="M30 60 v-30 h30" />
<path d="M370 60 v-30 h-30" />
<path d="M30 590 v30 h30" />
<path d="M370 590 v30 h-30" />
</g>
</g>
<!-- 2. Decorative Icons & Accents -->
<g id="layer-icons">
<!-- Ornamental crest -->
<g transform="translate(170, 52)" stroke="#1e3246" stroke-width="1.3" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="30" cy="22" r="16" />
<path d="M14 22 h32 M30 6 v32" stroke-width="0.75" opacity="0.6" />
</g>
<g id="divider-accent">
<path d="M120 162 h60 M220 162 h60" stroke="#a88b58" stroke-width="1" opacity="0.7" />
<circle cx="200" cy="162" r="3" fill="none" stroke="#a88b58" stroke-width="1" />
</g>
<!-- WiFi Icon -->
<g transform="translate(68, 592)" stroke="#7a7267" stroke-width="1.5" fill="none" stroke-linecap="round">
<path d="M0 0 a14 14 0 0 1 18 0" />
<path d="M3 4 a9 9 0 0 1 12 0" />
<circle cx="9" cy="8" r="1" fill="#7a7267" />
</g>
<!-- Instagram / Social Icon -->
<g transform="translate(225, 592)" stroke="#7a7267" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="0" y="0" width="13" height="13" rx="3.5" />
<circle cx="6.5" cy="6.5" r="3" />
<circle cx="10" cy="3" r="0.5" fill="#7a7267" />
</g>
</g>
<!-- 3. Clean Placeholder Box for QR Code (aligned to the app's fixed 33.08% / 55% overlay) -->
<g id="qr-placeholder" transform="translate(90, 215)">
<rect width="220" height="220" fill="#FFFFFF" stroke="#1e3246" stroke-width="1.5" />
<rect x="10" y="10" width="200" height="200" fill="none" stroke="#d5cebe" stroke-dasharray="4 3" />
<text x="110" y="115" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="600" fill="#a88b58" text-anchor="middle">QR KOD ALANI</text>
</g>
<!-- 4. Native Editable Texts -->
<g id="layer-texts" font-family="Georgia, 'Times New Roman', serif" text-anchor="middle">
<text x="200" y="130" font-size="22" font-weight="700" fill="#1e3246" letter-spacing="1.5">VINTAGE TABLE MENU</text>
<text x="200" y="150" font-size="11" font-weight="500" fill="#a88b58" letter-spacing="2.5" font-family="Inter, sans-serif">TEMASSIZ DİJİTAL MENÜ</text>
<text x="200" y="475" font-size="13" font-weight="700" fill="#1e3246" letter-spacing="1">MENÜYÜ GÖRMEK İÇİN OKUTUN</text>
<text x="200" y="495" font-size="11" font-weight="400" fill="#7a7267" font-family="Inter, sans-serif">Kameranız ile QR kodu taratın</text>
<rect x="140" y="520" width="120" height="28" fill="#ffffff" stroke="#1e3246" stroke-width="1" />
<text x="200" y="539" font-size="12" font-weight="600" fill="#1e3246" letter-spacing="0.5" font-family="Inter, sans-serif">Masa No: 01</text>
<text x="128" y="602" font-size="10" font-weight="400" fill="#a88b58" text-anchor="start" font-family="Inter, sans-serif">Vintage_Guest</text>
<text x="246" y="602" font-size="10" font-weight="400" fill="#a88b58" text-anchor="start" font-family="Inter, sans-serif">@vintagetable</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,213 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import AdminAuthGuard from "@/components/AdminAuthGuard";
import { getAdminSession, logoutAdmin, AdminUser } from "@/lib/admin-auth";
import { AyrisFooterBadge } from "@/components/AyrisFooterBadge";
export default function AdminLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [adminUser, setAdminUser] = useState<AdminUser | null>(null);
useEffect(() => {
setAdminUser(getAdminSession());
}, [pathname]);
const handleLogout = async () => {
if (confirm("Menulio Admin panelinden çıkış yapmak istediğinize emin misiniz?")) {
await logoutAdmin();
router.replace("/admin/login");
}
};
// Login page doesn't use the Admin Dashboard Shell (sidebar, header, etc.)
if (pathname === "/admin/login") {
return <>{children}</>;
}
return (
<AdminAuthGuard>
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col md:flex-row antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Sol Sidebar */}
<aside className="w-full md:w-72 bg-[#121215] border-b md:border-b-0 md:border-r border-white/10 flex flex-col justify-between shrink-0">
<div>
{/* Logo & Başlık */}
<div className="p-6 border-b border-white/10 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/20">
<span className="font-extrabold text-[#0C0C0E] text-lg tracking-wider">M</span>
</div>
<div>
<div className="font-bold text-white tracking-wide text-base group-hover:text-[#D4AF37] transition-colors">
MENULIO
</div>
<div className="text-[10px] font-semibold text-[#D4AF37] tracking-widest uppercase">
Admin Stüdyosu
</div>
</div>
</Link>
</div>
{/* Navigasyon Linkleri */}
<nav className="p-4 space-y-1.5">
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 mb-2">
Tasarım & Şablonlar
</div>
<Link
href="/admin/templates"
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl font-semibold text-sm transition-all shadow-sm ${
pathname === "/admin/templates"
? "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
QR Şablon Stüdyosu
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
Masa Kartı
</span>
</Link>
<Link
href="/admin/menu-templates"
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl font-semibold text-sm transition-all shadow-sm ${
pathname === "/admin/menu-templates"
? "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Şablonları
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
10 Tema
</span>
</Link>
<Link
href="/admin"
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
pathname === "/admin"
? "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Genel Bakış & İstatistikler
</Link>
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 pt-4 mb-2">
Önizleme & Test
</div>
<Link
href="/templates"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Simülatörü
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<Link
href="/qr"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
Web Masa Standı Üretici
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</nav>
</div>
{/* Alt Yönetici Profil & Çıkış Kartı */}
<div className="p-4 border-t border-white/10 space-y-3">
<div className="p-3 rounded-xl bg-white/[0.04] border border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg bg-[#D4AF37]/20 border border-[#D4AF37]/40 flex items-center justify-center shrink-0">
<span className="font-extrabold text-[#D4AF37] text-xs">
{adminUser?.email?.[0]?.toUpperCase() || "A"}
</span>
</div>
<div className="min-w-0">
<div className="text-xs font-bold text-white truncate">
{adminUser?.name || "Yönetici"}
</div>
<div className="text-[10px] text-zinc-400 truncate">
{adminUser?.email || "admin@menul.io"}
</div>
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="p-1.5 rounded-lg text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Çıkış Yap"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
{/* Sağ Ana İçerik */}
<main className="flex-1 flex flex-col min-w-0 bg-[#0C0C0E]">
{/* Üst Bar */}
<header className="h-16 border-b border-white/10 px-6 flex items-center justify-between bg-[#121215]/50 backdrop-blur shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-zinc-300">
Dinamik Şablon & İçerik Yönetimi
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-zinc-300">
<span className="w-2 h-2 rounded-full bg-[#D4AF37]" />
Oturum: <span className="font-semibold text-[#D4AF37]">{adminUser?.role === "super_admin" ? "Süper Admin" : "Yönetici"}</span>
</div>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-red-500/10 border border-red-500/20 text-xs font-semibold text-red-400 hover:bg-red-500/20 transition-all cursor-pointer"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Çıkış Yap
</button>
</div>
</header>
{/* Sayfa İçeriği */}
<div className="flex-1 p-6 md:p-8 overflow-y-auto">
{children}
</div>
<AyrisFooterBadge className="mt-auto border-t border-white/10 bg-[#09090b]" />
</main>
</div>
</AdminAuthGuard>
);
}
+2 -133
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import React from "react";
import AdminLayoutClient from "./AdminLayoutClient";
export const metadata: Metadata = {
title: "Menulio Admin Panel | Şablon & Sistem Yönetimi",
@@ -12,136 +12,5 @@ export default function AdminLayout({
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col md:flex-row antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Sol Sidebar */}
<aside className="w-full md:w-72 bg-[#121215] border-b md:border-b-0 md:border-r border-white/10 flex flex-col justify-between shrink-0">
<div>
{/* Logo & Başlık */}
<div className="p-6 border-b border-white/10 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/20">
<span className="font-extrabold text-[#0C0C0E] text-lg tracking-wider">M</span>
</div>
<div>
<div className="font-bold text-white tracking-wide text-base group-hover:text-[#D4AF37] transition-colors">
MENULIO
</div>
<div className="text-[10px] font-semibold text-[#D4AF37] tracking-widest uppercase">
Admin Stüdyosu
</div>
</div>
</Link>
</div>
{/* Navigasyon Linkleri */}
<nav className="p-4 space-y-1.5">
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 mb-2">
Tasarım & Şablonlar
</div>
<Link
href="/admin/templates"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/25 font-semibold text-sm transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
QR Şablon Stüdyosu
<span className="ml-auto text-[10px] bg-[#D4AF37] text-black font-extrabold px-1.5 py-0.5 rounded-full">
JSON
</span>
</Link>
<Link
href="/admin"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Genel Bakış & İstatistikler
</Link>
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider px-3 pt-4 mb-2">
Önizleme & Test
</div>
<Link
href="/templates"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Simülatörü
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<Link
href="/qr"
target="_blank"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 text-sm font-medium transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
Web Masa Standı Üretici
<svg className="w-3.5 h-3.5 ml-auto text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</nav>
</div>
{/* Alt Durum Kartı */}
<div className="p-4 border-t border-white/10">
<div className="p-3.5 rounded-xl bg-white/[0.04] border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-zinc-400">Şablon Motoru</span>
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
CANLI (V2)
</span>
</div>
<div className="text-[11px] text-zinc-500">
Uygulama güncellemesi gerektirmeden JSON ile anında yayınlanır.
</div>
</div>
</div>
</aside>
{/* Sağ Ana İçerik */}
<main className="flex-1 flex flex-col min-w-0 bg-[#0C0C0E]">
{/* Üst Bar */}
<header className="h-16 border-b border-white/10 px-6 flex items-center justify-between bg-[#121215]/50 backdrop-blur shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-zinc-300">
Dinamik Şablon & İçerik Yönetimi
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-zinc-300">
<span className="w-2 h-2 rounded-full bg-[#D4AF37]" />
API: <span className="font-semibold text-white">localhost:3001</span>
</div>
<Link
href="/"
className="text-xs text-zinc-400 hover:text-white transition-colors"
>
Menulio Web &rarr;
</Link>
</div>
</header>
{/* Sayfa İçeriği */}
<div className="flex-1 p-6 md:p-8 overflow-y-auto">
{children}
</div>
</main>
</div>
);
return <AdminLayoutClient>{children}</AdminLayoutClient>;
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
import React, { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { loginAdmin, getAdminSession } from "@/lib/admin-auth";
function AdminLoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/admin";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// If already logged in, redirect straight to admin dashboard
const session = getAdminSession();
if (session) {
router.replace(callbackUrl);
}
}, [router, callbackUrl]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || !password.trim()) {
setErrorMsg("Lütfen e-posta ve şifrenizi girin.");
return;
}
setIsLoading(true);
setErrorMsg("");
try {
const res = await loginAdmin(email, password);
if (res.success) {
router.replace(callbackUrl);
} else {
setErrorMsg(res.error || "Giriş başarısız.");
}
} catch {
setErrorMsg("Bir hata oluştu. Lütfen tekrar deneyin.");
} finally {
setIsLoading(false);
}
};
const handleQuickDemoLogin = () => {
setEmail("admin@menul.io");
setPassword("menulio2026");
};
return (
<div className="min-h-screen bg-[#09090b] text-white flex flex-col justify-center items-center p-4 relative overflow-hidden selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
{/* Arka Plan Işık Efektleri */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-[#D4AF37]/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-10 right-10 w-72 h-72 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
{/* Kart Konteynırı */}
<div className="w-full max-w-md bg-[#121215]/80 backdrop-blur-xl border border-white/10 rounded-3xl p-8 shadow-2xl shadow-black/80 relative z-10 space-y-6">
{/* Logo & Başlık */}
<div className="text-center space-y-2">
<Link href="/" className="inline-flex items-center gap-3 group mb-2">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] flex items-center justify-center shadow-lg shadow-[#D4AF37]/30 group-hover:scale-105 transition-transform">
<span className="font-black text-[#0C0C0E] text-2xl tracking-wider">M</span>
</div>
</Link>
<h1 className="text-2xl font-black text-white tracking-tight">
Menulio Admin Girişi
</h1>
<p className="text-xs text-zinc-400">
QR Şablon Stüdyosu ve Sistem Yönetim Paneli
</p>
</div>
{/* Hata Bildirimi */}
{errorMsg && (
<div className="p-3.5 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-semibold flex items-center gap-2 animate-shake">
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{errorMsg}
</div>
)}
{/* Giriş Formu */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* E-posta */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-300 uppercase tracking-wider block">
Yönetici E-Postası
</label>
<div className="relative">
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@menul.io"
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-[#D4AF37] focus:ring-1 focus:ring-[#D4AF37] transition-all"
required
/>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
</div>
</div>
{/* Şifre */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-300 uppercase tracking-wider block">
Şifre
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-[#D4AF37] focus:ring-1 focus:ring-[#D4AF37] transition-all pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
title={showPassword ? "Şifreyi gizle" : "Şifreyi göster"}
>
{showPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858-5.908a10.05 10.05 0 012.122-.388c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21M3 3l18 18" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{/* Giriş Yap Butonu */}
<button
type="submit"
disabled={isLoading}
className="w-full mt-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-extrabold text-sm py-3.5 px-4 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 active:scale-[0.99] transition-all disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer"
>
{isLoading ? (
<>
<svg className="w-4 h-4 animate-spin text-[#0C0C0E]" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Giriş Yapılıyor...
</>
) : (
<>
Giriş Yap
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</>
)}
</button>
</form>
{/* Hızlı Demo Girişi */}
<div className="pt-4 border-t border-white/10 space-y-2 text-center">
<div className="text-[11px] text-zinc-500">
Varsayılan Admin Girişi: <span className="font-mono text-zinc-300">admin@menul.io / menulio2026</span>
</div>
<button
type="button"
onClick={handleQuickDemoLogin}
className="text-xs text-[#D4AF37] hover:underline font-medium transition-colors"
>
Formu Varsayılan Admin Bilgileri ile Doldur
</button>
</div>
</div>
{/* Alt Footer */}
<div className="mt-8 text-center text-xs text-zinc-600 relative z-10">
Menulio QR & Masa Kartı Yönetim Ekosistemi &copy; {new Date().getFullYear()}
</div>
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#09090b] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37] border-t-transparent animate-spin" />
</div>
}>
<AdminLoginForm />
</Suspense>
);
}
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { MenuTemplatesAdminClient } from "@/components/MenuTemplatesAdminClient";
export const metadata: Metadata = {
title: "Web Menü Şablon Stüdyosu | Menulio Admin",
description: "Web restoran menü temalarını, renklerini ve düzen ayarlarını canlı önizleme ile yönetin.",
};
export default function MenuTemplatesAdminPage() {
return <MenuTemplatesAdminClient />;
}
+13 -2
View File
@@ -17,16 +17,27 @@ export default function AdminDashboardPage() {
</p>
</div>
<div className="flex items-center gap-3 flex-wrap self-start">
<Link
href="/admin/menu-templates"
className="inline-flex items-center gap-2 bg-white/5 border border-white/10 hover:bg-white/10 text-white font-bold px-4 py-2.5 rounded-xl transition-all text-sm"
>
<svg className="w-4 h-4 text-[#D4AF37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Web Menü Şablon Stüdyosu
</Link>
<Link
href="/admin/templates"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-bold px-5 py-2.5 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all text-sm self-start"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-[#0C0C0E] font-bold px-5 py-2.5 rounded-xl shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Yeni Şablon Tasarla & Ekle
QR Şablon Stüdyosu
</Link>
</div>
</div>
{/* İstatistik Kartları */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
+12 -5
View File
@@ -17,6 +17,7 @@ interface MenuItemRow {
price: number;
image_url: string | null;
is_active: boolean;
translations: Record<string, { name: string; description: string | null }> | null;
}
interface MenuCategoryRow {
@@ -25,6 +26,7 @@ interface MenuCategoryRow {
description: string | null;
is_active: boolean;
menu_items: MenuItemRow[];
translations: Record<string, { name: string }> | null;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -71,14 +73,14 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
async function fetchRestaurantBySlug(slug: string) {
let { data: bySlug, error } = await supabase
.from("restaurants")
.select("id, name, slug, logo_url, cover_url, phone, address")
.select("id, name, slug, logo_url, cover_url, phone, address, enabled_languages")
.eq("slug", slug)
.maybeSingle();
if (error && error.code === "42703") {
const { data: fallback } = await supabase
.from("restaurants")
.select("id, name, slug, logo_url, phone, address")
.select("id, name, slug, logo_url, phone, address, enabled_languages")
.eq("slug", slug)
.maybeSingle();
if (fallback) {
@@ -90,14 +92,14 @@ async function fetchRestaurantBySlug(slug: string) {
let { data: domainRow, error: domErr } = await supabase
.from("domains")
.select("restaurant_id, restaurants(id, name, slug, logo_url, cover_url, phone, address)")
.select("restaurant_id, restaurants(id, name, slug, logo_url, cover_url, phone, address, enabled_languages)")
.eq("hostname", slug)
.maybeSingle();
if (domErr && domErr.code === "42703") {
const { data: fallbackDom } = await supabase
.from("domains")
.select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address)")
.select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address, enabled_languages)")
.eq("hostname", slug)
.maybeSingle();
if (fallbackDom?.restaurants) {
@@ -150,7 +152,9 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
if (menu) {
const { data: rawCategories } = await supabase
.from("menu_categories")
.select("id, name, description, is_active, menu_items(id, name, description, price, image_url, is_active)")
.select(
"id, name, description, is_active, translations, menu_items(id, name, description, price, image_url, is_active, translations)",
)
.eq("menu_id", menu.id)
.eq("is_active", true)
.order("sort_order", { ascending: true })
@@ -161,6 +165,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
name: cat.name,
description: cat.description,
is_active: cat.is_active,
translations: cat.translations ?? {},
menu_items: (cat.menu_items ?? [])
.filter((item) => item.is_active)
.map((item): MenuItem => ({
@@ -170,6 +175,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
price: item.price,
image_url: item.image_url,
is_active: item.is_active,
translations: item.translations ?? {},
})),
}));
} else if (slug === "demo" || slug === "gusto-brasserie") {
@@ -202,6 +208,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
categories={categories}
initialThemeKey={themeKey}
customThemeConfig={customConfig}
enabledLanguages={restaurant.enabled_languages ?? ["tr"]}
/>
);
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { THEME_PRESETS } from "@/lib/theme";
import { AyrisFooterBadge } from "@/components/AyrisFooterBadge";
export const metadata: Metadata = {
title: "Menulio | AI Destekli Yeni Nesil QR Menü Platformu",
@@ -283,7 +284,7 @@ export default function HomePage() {
{/* Footer */}
<footer className="border-t border-stone-800/80 bg-stone-950 py-12 px-6">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6 text-xs text-stone-500">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6 text-xs text-stone-500 mb-8">
<div className="flex items-center gap-3">
<span className="font-bold text-stone-300 text-sm tracking-wider">MENULIO</span>
<span>•</span>
@@ -302,6 +303,8 @@ export default function HomePage() {
</Link>
</div>
</div>
<AyrisFooterBadge />
</footer>
</div>
);
@@ -0,0 +1,55 @@
"use client";
import React, { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { getAdminSession, AdminUser } from "@/lib/admin-auth";
export default function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [session, setSession] = useState<AdminUser | null>(null);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
// Skip auth guard for login page itself
if (pathname === "/admin/login") {
setIsChecking(false);
return;
}
const currentSession = getAdminSession();
if (!currentSession) {
const callback = encodeURIComponent(pathname);
router.replace(`/admin/login?callbackUrl=${callback}`);
} else {
setSession(currentSession);
setIsChecking(false);
}
}, [pathname, router]);
// Don't protect non-admin pages if wrapper is applied globally
if (pathname === "/admin/login") {
return <>{children}</>;
}
if (isChecking) {
return (
<div className="min-h-screen bg-[#09090b] text-[#fafafa] flex flex-col items-center justify-center p-6 antialiased selection:bg-[#D4AF37]/30 selection:text-[#D4AF37]">
<div className="flex flex-col items-center space-y-4">
<div className="relative w-14 h-14 flex items-center justify-center">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-tr from-[#D4AF37] to-[#F59E0B] opacity-20 blur-md animate-pulse" />
<div className="w-12 h-12 rounded-xl bg-[#121215] border border-[#D4AF37]/40 flex items-center justify-center shadow-xl">
<span className="font-extrabold text-[#D4AF37] text-xl">M</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400">
<div className="w-2 h-2 rounded-full bg-[#D4AF37] animate-ping" />
Yönetici Oturumu Doğrulanıyor...
</div>
</div>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,34 @@
import React from "react";
export function AyrisFooterBadge({ className = "" }: { className?: string }) {
return (
<footer className={`w-full border-t border-white/10 dark:border-zinc-800/80 py-4 px-6 text-xs text-zinc-500 dark:text-zinc-400 ${className}`}>
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3 text-center sm:text-left">
{/* Telif Hakkı */}
<p>© {new Date().getFullYear()} Menulio. Tüm Hakları Saklıdır.</p>
{/* Görünür Ayris Tech İmzası */}
<div className="flex items-center gap-1.5">
<span className="opacity-75">Created by</span>
<a
href="https://ayris.tech"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-zinc-300 hover:text-white transition-colors duration-200 hover:underline underline-offset-4"
>
Ayris Tech
</a>
{/* Google Bot & Arama Motoru İndeksleme Katmanı (Görünmez / Screen-Reader Safe) */}
<span className="sr-only">
Legal AI & Solutions:{" "}
<a href="https://ayris.legal" rel="noopener noreferrer">AyrisLegal</a>,{" "}
<a href="https://ayrislegal.com" rel="noopener noreferrer">Ayris Legal AI</a>.{" "}
The best local spots, hidden from plain sight. Marmaris Local{" "}
<a href="https://marmarislocal.com" rel="noopener noreferrer">Marmaris Local</a>
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,556 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { THEME_PRESETS, ThemePreset } from "@/lib/theme";
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
import { PublicMenuClient } from "@/components/PublicMenuClient";
export function MenuTemplatesAdminClient() {
const [themesMap, setThemesMap] = useState<Record<string, ThemePreset>>(THEME_PRESETS);
const [activeThemeKey, setActiveThemeKey] = useState<string>("coastal");
const [activeTab, setActiveTab] = useState<"gallery" | "customize">("gallery");
const [toastMsg, setToastMsg] = useState<string | null>(null);
const [jsonModalOpen, setJsonModalOpen] = useState(false);
const [importedJson, setImportedJson] = useState("");
const currentTheme = themesMap[activeThemeKey] || THEME_PRESETS.coastal!;
const showToast = (msg: string) => {
setToastMsg(msg);
setTimeout(() => setToastMsg(null), 3000);
};
const handleUpdateCurrentTheme = (updates: Partial<ThemePreset> | Partial<ThemePreset["config"]>) => {
setThemesMap((prev) => {
const existing = prev[activeThemeKey];
if (!existing) return prev;
let updatedConfig = { ...existing.config };
let updatedTopLevel = { ...existing };
if ("primaryColor" in updates || "productLayout" in updates || "categoryLayout" in updates || "headerLayout" in updates) {
updatedConfig = { ...updatedConfig, ...updates };
} else {
updatedTopLevel = { ...updatedTopLevel, ...updates };
}
return {
...prev,
[activeThemeKey]: {
...updatedTopLevel,
config: updatedConfig,
},
};
});
};
const handleSaveAll = () => {
showToast(`"${currentTheme.name}" şablon ayarları başarıyla kaydedildi!`);
};
const handleImportJsonSubmit = () => {
try {
const parsed = JSON.parse(importedJson);
if (!parsed.key || !parsed.name || !parsed.config) {
throw new Error("Geçersiz şablon JSON formatı. 'key', 'name' ve 'config' alanları zorunludur.");
}
setThemesMap((prev) => ({
...prev,
[parsed.key]: parsed,
}));
setActiveThemeKey(parsed.key);
setJsonModalOpen(false);
setImportedJson("");
showToast(`Yeni şablon "${parsed.name}" başarıyla içe aktarıldı!`);
} catch (err: any) {
alert("Hata: " + (err?.message || "Geçersiz JSON formatı"));
}
};
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Toast Bildirimi */}
{toastMsg && (
<div className="fixed top-20 right-6 z-50 bg-[#D4AF37] text-black font-extrabold px-5 py-3 rounded-2xl shadow-2xl shadow-[#D4AF37]/30 text-xs flex items-center gap-2 animate-bounce">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
{toastMsg}
</div>
)}
{/* Üst Karşılama Barı */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 bg-[#121215] border border-white/10 p-6 rounded-3xl">
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#D4AF37]/10 border border-[#D4AF37]/30 text-xs font-bold text-[#D4AF37] mb-2">
🎨 WEB DİNAMİK MENÜ TEMA STÜDYOSU
</div>
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
Web Menü Şablon Yöneticisi
</h1>
<p className="text-xs text-zinc-400 mt-1">
Restoran web menülerinin renk paletlerini, düzen yapılarını ve tipografisini canlı cihaz simülatöründe anında test edip yönetin.
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={() => {
setImportedJson(JSON.stringify(currentTheme, null, 2));
setJsonModalOpen(true);
}}
className="px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-xs font-bold text-zinc-300 transition-all flex items-center gap-2 cursor-pointer"
>
<svg className="w-4 h-4 text-[#D4AF37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
JSON İçe/Dışa Aktar
</button>
<button
type="button"
onClick={handleSaveAll}
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-[#D4AF37] to-[#F59E0B] text-black font-extrabold text-xs shadow-lg shadow-[#D4AF37]/20 hover:opacity-95 transition-all flex items-center gap-2 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
Değişiklikleri Yayınla & Kaydet
</button>
</div>
</div>
{/* Ana İçerik Çift Sütun Düzeni: Sol Kontrol / Sağ Canlı Simülatör */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* SOL PANEL: Şablon Galerisi ve Özelleştirici */}
<div className="lg:col-span-7 space-y-6">
{/* Sekme Butonları */}
<div className="flex items-center gap-2 p-1.5 rounded-2xl bg-[#121215] border border-white/10">
<button
type="button"
onClick={() => setActiveTab("gallery")}
className={`flex-1 py-2.5 px-4 rounded-xl font-extrabold text-xs transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === "gallery"
? "bg-[#D4AF37] text-black shadow-lg shadow-[#D4AF37]/20"
: "text-zinc-400 hover:text-white"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Hazır Şablonlar ({Object.keys(themesMap).length})
</button>
<button
type="button"
onClick={() => setActiveTab("customize")}
className={`flex-1 py-2.5 px-4 rounded-xl font-extrabold text-xs transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === "customize"
? "bg-[#D4AF37] text-black shadow-lg shadow-[#D4AF37]/20"
: "text-zinc-400 hover:text-white"
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Seçili Temayı Özelleştir ({currentTheme.key})
</button>
</div>
{/* TAB 1: ŞABLON GALERİSİ KARTLARI */}
{activeTab === "gallery" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.values(themesMap).map((theme) => {
const isSelected = theme.key === activeThemeKey;
return (
<div
key={theme.key}
onClick={() => setActiveThemeKey(theme.key)}
className={`p-5 rounded-2xl border transition-all cursor-pointer space-y-4 relative overflow-hidden group ${
isSelected
? "bg-[#16161A] border-[#D4AF37] ring-2 ring-[#D4AF37]/30 shadow-xl shadow-[#D4AF37]/10"
: "bg-[#121215] border-white/10 hover:border-white/20 hover:bg-white/[0.02]"
}`}
>
{/* Üst İkon & Seçim Rozeti */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded-full border border-black/20 shadow-sm"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<span
className="w-4 h-4 rounded-full border border-black/20 shadow-sm"
style={{ backgroundColor: theme.cardBg }}
/>
</div>
{isSelected ? (
<span className="text-[10px] bg-[#D4AF37] text-black font-extrabold px-2 py-0.5 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
</svg>
AKTİF
</span>
) : (
<span className="text-[10px] text-zinc-500 font-mono uppercase">
{theme.key}
</span>
)}
</div>
{/* Tema Adı & Açıklama */}
<div>
<h3 className="font-extrabold text-white text-base group-hover:text-[#D4AF37] transition-colors">
{theme.name}
</h3>
<div className="flex items-center gap-2 mt-2 text-[11px] text-zinc-400">
<span className="bg-white/5 border border-white/10 px-2 py-0.5 rounded-md capitalize font-mono">
{theme.fontFamily}
</span>
<span className="bg-white/5 border border-white/10 px-2 py-0.5 rounded-md capitalize font-mono">
{theme.config.productLayout}
</span>
<span className="bg-white/5 border border-white/10 px-2 py-0.5 rounded-md capitalize font-mono">
{theme.config.categoryLayout}
</span>
</div>
</div>
{/* Alt Butonlar */}
<div className="pt-3 border-t border-white/5 flex items-center justify-between">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setActiveThemeKey(theme.key);
setActiveTab("customize");
}}
className="text-xs font-bold text-[#D4AF37] hover:underline flex items-center gap-1"
>
Renk & Düzen Özelleştir &rarr;
</button>
</div>
</div>
);
})}
</div>
)}
{/* TAB 2: SEÇİLİ TEMAYI ÖZELLEŞTİR */}
{activeTab === "customize" && (
<div className="p-6 rounded-2xl bg-[#121215] border border-white/10 space-y-6">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<span
className="w-3.5 h-3.5 rounded-full inline-block"
style={{ backgroundColor: currentTheme.config.primaryColor }}
/>
{currentTheme.name}
</h3>
<p className="text-xs text-zinc-400 mt-0.5">
Ana renkleri, arka plan tonlarını ve düzen tiplerini anında özelleştirin.
</p>
</div>
<button
type="button"
onClick={() => setActiveTab("gallery")}
className="text-xs font-bold text-zinc-400 hover:text-white"
>
&larr; Galeriye Dön
</button>
</div>
{/* Renk Ayarları */}
<div className="space-y-4">
<h4 className="text-xs font-extrabold text-[#D4AF37] uppercase tracking-wider">
1. Renk Paleti & Tonlar
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Primary Color */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Ana Vurgu Rengi (Primary)
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.config.primaryColor || "#D4AF37"}
onChange={(e) =>
handleUpdateCurrentTheme({ primaryColor: e.target.value })
}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.config.primaryColor || "#D4AF37"}
onChange={(e) =>
handleUpdateCurrentTheme({ primaryColor: e.target.value })
}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
{/* Background Color */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Sayfa Arka Plan Rengi
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.config.background || "#FAF8F5"}
onChange={(e) =>
handleUpdateCurrentTheme({ background: e.target.value })
}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.config.background || "#FAF8F5"}
onChange={(e) =>
handleUpdateCurrentTheme({ background: e.target.value })
}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
{/* Card Background */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Ürün Kart Arka Planı
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.cardBg.startsWith("#") ? currentTheme.cardBg : "#FFFFFF"}
onChange={(e) => handleUpdateCurrentTheme({ cardBg: e.target.value })}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.cardBg}
onChange={(e) => handleUpdateCurrentTheme({ cardBg: e.target.value })}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
{/* Text Primary */}
<div className="space-y-1.5 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<label className="text-xs font-semibold text-zinc-300 block">
Ana Başlık & Fiyat Yazı Rengi
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={currentTheme.textPrimary.startsWith("#") ? currentTheme.textPrimary : "#1C1917"}
onChange={(e) => handleUpdateCurrentTheme({ textPrimary: e.target.value })}
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent border-0"
/>
<input
type="text"
value={currentTheme.textPrimary}
onChange={(e) => handleUpdateCurrentTheme({ textPrimary: e.target.value })}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white font-mono uppercase"
/>
</div>
</div>
</div>
</div>
{/* Düzen & Yerleşim Ayarları */}
<div className="space-y-4 pt-4 border-t border-white/10">
<h4 className="text-xs font-extrabold text-[#D4AF37] uppercase tracking-wider">
2. Düzen & Yerleşim Mimarisi
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Product Layout */}
<div className="space-y-2">
<label className="text-xs font-semibold text-zinc-300 block">
Ürün Görünüm Tipi
</label>
<select
value={currentTheme.config.productLayout || "card"}
onChange={(e) =>
handleUpdateCurrentTheme({ productLayout: e.target.value as any })
}
className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] focus:outline-none"
>
<option value="card">Card (Kutu / Kart)</option>
<option value="list">List (Satır / Liste)</option>
</select>
</div>
{/* Category Layout */}
<div className="space-y-2">
<label className="text-xs font-semibold text-zinc-300 block">
Kategori Gezinme Tipi
</label>
<select
value={currentTheme.config.categoryLayout || "tabs"}
onChange={(e) =>
handleUpdateCurrentTheme({ categoryLayout: e.target.value as any })
}
className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] focus:outline-none"
>
<option value="tabs">Tabs (Yatay Sekmeler)</option>
<option value="accordion">Accordion (Açılır Kapanır)</option>
<option value="flat">Flat (Düz Sıralı)</option>
</select>
</div>
{/* Header Layout */}
<div className="space-y-2">
<label className="text-xs font-semibold text-zinc-300 block">
Header Hizalaması
</label>
<select
value={currentTheme.config.headerLayout || "centered"}
onChange={(e) =>
handleUpdateCurrentTheme({ headerLayout: e.target.value as any })
}
className="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:border-[#D4AF37] focus:outline-none"
>
<option value="centered">Centered (Ortalanmış)</option>
<option value="left">Left (Sola Hizalı)</option>
</select>
</div>
</div>
</div>
{/* Tipografi */}
<div className="space-y-4 pt-4 border-t border-white/10">
<h4 className="text-xs font-extrabold text-[#D4AF37] uppercase tracking-wider">
3. Tipografi & Font Ailesi
</h4>
<div className="grid grid-cols-3 gap-3">
{[
{ id: "serif", label: "Zarif Serif", desc: "Playfair / Georgia" },
{ id: "sans", label: "Modern Sans", desc: "Inter / System" },
{ id: "heading", label: "Güçlü Display", desc: "Outfit / Montserrat" },
].map((font) => (
<button
key={font.id}
type="button"
onClick={() => handleUpdateCurrentTheme({ fontFamily: font.id as any })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer ${
currentTheme.fontFamily === font.id
? "bg-[#D4AF37]/15 border-[#D4AF37] text-white"
: "bg-white/[0.02] border-white/10 text-zinc-400 hover:text-white"
}`}
>
<div className="text-xs font-bold">{font.label}</div>
<div className="text-[10px] opacity-70 mt-0.5">{font.desc}</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* SAĞ PANEL: Canlı İnceleme Telefon Simülatörü */}
<div className="lg:col-span-5 sticky top-20">
<div className="bg-[#121215] border border-white/10 rounded-3xl p-5 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-xs font-bold text-white">Canlı Telefon Simülatörü</span>
</div>
<span className="text-[10px] font-mono text-[#D4AF37] bg-[#D4AF37]/10 px-2 py-0.5 rounded-full font-bold">
{currentTheme.name}
</span>
</div>
{/* Mock iPhone Frame */}
<div className="w-full max-w-[340px] h-[640px] mx-auto rounded-[40px] border-[8px] border-zinc-800 bg-black shadow-2xl overflow-hidden relative flex flex-col">
{/* iPhone Notch Header */}
<div className="w-full h-6 bg-black shrink-0 flex items-center justify-center relative z-20">
<div className="w-24 h-4 bg-zinc-900 rounded-b-xl" />
</div>
{/* Dynamic Theme Render Component */}
<div className="flex-1 overflow-y-auto relative">
<PublicMenuClient
restaurant={DEMO_RESTAURANT}
categories={DEMO_CATEGORIES}
initialThemeKey={currentTheme.key}
customThemeConfig={currentTheme.config}
/>
</div>
{/* Home Bar Indicator */}
<div className="w-full h-4 bg-black shrink-0 flex items-center justify-center z-20">
<div className="w-28 h-1 bg-zinc-700 rounded-full" />
</div>
</div>
<div className="text-center text-[11px] text-zinc-500">
Sol taraftan yaptığınız renk ve düzen değişiklikleri anında burada simüle edilir.
</div>
</div>
</div>
</div>
{/* JSON İÇE / DIŞA AKTARMA MODAL */}
{jsonModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-xl bg-[#121215] border border-white/15 rounded-3xl p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<h3 className="font-extrabold text-white text-base">
Tema JSON Spesifikasyonu
</h3>
<button
type="button"
onClick={() => setJsonModalOpen(false)}
className="text-zinc-400 hover:text-white"
>
</button>
</div>
<p className="text-xs text-zinc-400">
Mevcut temanın JSON kodunu kopyalayabilir veya yeni bir tema JSON spesifikasyonunu buraya yapıştırıp içe aktarabilirsiniz:
</p>
<textarea
value={importedJson}
onChange={(e) => setImportedJson(e.target.value)}
rows={12}
className="w-full bg-black/60 border border-white/10 rounded-xl p-4 text-xs font-mono text-emerald-400 focus:outline-none focus:border-[#D4AF37]"
/>
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(importedJson);
showToast("JSON panoya kopyalandı!");
}}
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-xs font-bold text-white cursor-pointer"
>
Panoya Kopyala
</button>
<button
type="button"
onClick={handleImportJsonSubmit}
className="px-4 py-2 rounded-xl bg-[#D4AF37] text-black font-extrabold text-xs cursor-pointer shadow-lg shadow-[#D4AF37]/20"
>
İçe Aktar & Uygula
</button>
</div>
</div>
</div>
)}
</div>
);
}
+90 -12
View File
@@ -3,6 +3,7 @@
import { useMemo, useState, useEffect } from "react";
import type { ThemeConfig } from "@menulio/shared";
import { THEME_PRESETS, resolveThemePreset, type ThemePreset } from "@/lib/theme";
import { AyrisFooterBadge } from "./AyrisFooterBadge";
export interface MenuItem {
id: string;
@@ -11,6 +12,7 @@ export interface MenuItem {
price: number;
image_url: string | null;
is_active: boolean;
translations?: Record<string, { name: string; description: string | null }> | null;
}
export interface MenuCategory {
@@ -19,8 +21,18 @@ export interface MenuCategory {
description: string | null;
is_active: boolean;
menu_items: MenuItem[];
translations?: Record<string, { name: string }> | null;
}
const LANGUAGE_LABELS: Record<string, string> = {
tr: "TR",
en: "EN",
de: "DE",
ar: "AR",
ru: "RU",
fr: "FR",
};
export interface RestaurantData {
id: string;
name: string;
@@ -37,6 +49,7 @@ interface PublicMenuClientProps {
initialThemeKey?: string;
customThemeConfig?: Partial<ThemeConfig>;
allowThemeSwitching?: boolean;
enabledLanguages?: string[];
}
export function PublicMenuClient({
@@ -45,8 +58,10 @@ export function PublicMenuClient({
initialThemeKey = "elegant",
customThemeConfig,
allowThemeSwitching = false,
enabledLanguages = ["tr"],
}: PublicMenuClientProps) {
const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey);
const [selectedLanguage, setSelectedLanguage] = useState<string>("tr");
const [searchQuery, setSearchQuery] = useState("");
const [activeCategoryId, setActiveCategoryId] = useState<string>(categories[0]?.id ?? "");
const [selectedItem, setSelectedItem] = useState<MenuItem | null>(null);
@@ -94,12 +109,27 @@ export function PublicMenuClient({
return () => window.removeEventListener("scroll", handleScroll);
}, [categories]);
// Apply AI-translated text for the selected language, falling back to the
// original Turkish content when a translation isn't available yet.
const localizedCategories = useMemo(() => {
if (selectedLanguage === "tr") return categories;
return categories.map((cat) => ({
...cat,
name: cat.translations?.[selectedLanguage]?.name || cat.name,
menu_items: cat.menu_items.map((item) => ({
...item,
name: item.translations?.[selectedLanguage]?.name || item.name,
description: item.translations?.[selectedLanguage]?.description ?? item.description,
})),
}));
}, [categories, selectedLanguage]);
// Filter categories and items based on search
const filteredCategories = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return categories;
if (!q) return localizedCategories;
return categories
return localizedCategories
.map((cat) => ({
...cat,
menu_items: cat.menu_items.filter(
@@ -109,7 +139,7 @@ export function PublicMenuClient({
),
}))
.filter((cat) => cat.menu_items.length > 0);
}, [categories, searchQuery]);
}, [localizedCategories, searchQuery]);
const totalItemsCount = useMemo(() => {
return categories.reduce((acc, cat) => acc + cat.menu_items.filter((i) => i.is_active).length, 0);
@@ -212,12 +242,33 @@ export function PublicMenuClient({
)}
<div className="flex items-center gap-1.5 sm:gap-2">
{/* Language Selector Pills */}
{enabledLanguages.length > 1 && (
<div className="flex items-center gap-1 px-1 py-1 rounded-full bg-black/30 backdrop-blur-md border border-white/15">
{enabledLanguages.map((lang) => {
const isActive = selectedLanguage === lang;
return (
<button
key={lang}
type="button"
onClick={() => setSelectedLanguage(lang)}
className={`px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-[11px] font-bold tracking-wide transition-all ${
isActive ? "bg-white text-stone-900 shadow-sm" : "text-white/80 hover:text-white"
}`}
>
{LANGUAGE_LABELS[lang] ?? lang.toUpperCase()}
</button>
);
})}
</div>
)}
{/* Restaurant Info Trigger */}
{(restaurant.phone || restaurant.address) && (
<button
type="button"
onClick={() => setShowInfoModal(true)}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md"
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
aria-label="Restoran Bilgisi"
>
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -230,7 +281,7 @@ export function PublicMenuClient({
<button
type="button"
onClick={handleShare}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md"
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
aria-label="Menüyü Paylaş"
>
{copiedLink ? (
@@ -329,8 +380,9 @@ export function PublicMenuClient({
type="text"
placeholder="Yemek, içecek veya tatlı ara..."
value={searchQuery}
aria-label="Menüde arama yap"
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 transition-all shadow-lg"
className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 focus-visible:ring-amber-500 transition-all shadow-lg cursor-pointer"
style={{
outlineColor: theme.config.primaryColor,
}}
@@ -409,7 +461,7 @@ export function PublicMenuClient({
}}
>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth py-0.5">
{categories.map((cat) => {
{localizedCategories.map((cat) => {
const isActive = activeCategoryId === cat.id;
return (
<button
@@ -607,6 +659,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
@@ -686,6 +741,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover rounded-2xl shadow-md group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
@@ -714,6 +772,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-500"
loading="lazy"
/>
@@ -793,6 +854,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover grayscale contrast-125 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-300"
loading="lazy"
/>
@@ -819,6 +883,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
@@ -908,6 +975,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
@@ -925,21 +995,26 @@ export function PublicMenuClient({
{/* Footer */}
<footer
className="px-6 py-10 text-center border-t mt-auto text-xs space-y-4"
className="px-6 py-8 text-center border-t mt-auto text-xs space-y-3"
style={{
borderColor: theme.cardBorder,
color: theme.textSecondary,
}}
>
<div className="flex items-center justify-center gap-2 font-semibold">
<span>Powered by</span>
<span className="text-stone-900 dark:text-white font-black tracking-wider uppercase">
<div className="flex items-center justify-center gap-2 font-bold tracking-wide">
<span className="opacity-80">Powered by</span>
<span
className="font-black tracking-widest uppercase px-2 py-0.5 rounded text-[11px]"
style={{ backgroundColor: `${theme.config.primaryColor}20`, color: theme.config.primaryColor }}
>
MENULIO
</span>
</div>
<p className="text-[11px] opacity-70">
<p className="text-[11px] opacity-75 max-w-sm mx-auto">
© {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir.
</p>
<AyrisFooterBadge className="pt-3 border-t border-black/5 dark:border-white/10" />
</footer>
{/* Floating Back to Top Button */}
@@ -970,6 +1045,9 @@ export function PublicMenuClient({
<img
src={selectedItem.image_url}
alt={selectedItem.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover"
/>
<button
@@ -1,11 +1,12 @@
"use client";
import { useRef, useState } from "react";
import React, { useRef, useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react";
import { toPng } from "html-to-image";
import { jsPDF } from "jspdf";
import { QR_STAND_PRESETS, type QrStandPreset } from "@menulio/shared";
import { AyrisFooterBadge } from "./AyrisFooterBadge";
interface QrStandGeneratorClientProps {
restaurantName?: string;
@@ -14,6 +15,74 @@ interface QrStandGeneratorClientProps {
categoriesList?: string[];
}
const CurvedTextWeb: React.FC<{
text: string;
fontSize: number;
color: string;
fontFamily: string;
fontWeight?: string;
letterSpacing?: number;
curveDegree: number;
}> = ({ text, fontSize, color, fontFamily, fontWeight = "700", letterSpacing = 0.5, curveDegree }) => {
const isUp = curveDegree > 0;
const absDegree = Math.abs(curveDegree);
const radius = Math.max(40, 420 - absDegree * 3.5);
const charWidth = fontSize * 0.52 + (letterSpacing || 0);
const numChars = text.length;
const arcLength = numChars * charWidth;
const totalAngleRad = arcLength / radius;
const stepRad = numChars > 1 ? totalAngleRad / (numChars - 1) : 0;
const startAngleRad = -totalAngleRad / 2;
const maxOffsetY = Math.abs(-radius * (Math.cos(totalAngleRad / 2) - 1));
const containerHeight = fontSize + maxOffsetY + 10;
const containerWidth = Math.max(numChars * charWidth, radius * 2 * Math.sin(totalAngleRad / 2)) + 16;
return (
<div
style={{
position: "relative",
width: `${containerWidth}px`,
height: `${containerHeight}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
{text.split("").map((ch, i) => {
const angleRad = startAngleRad + i * stepRad;
const angleDeg = angleRad * (180 / Math.PI);
const charX = radius * Math.sin(angleRad);
const charY = isUp
? -radius * (Math.cos(angleRad) - 1)
: radius * (Math.cos(angleRad) - 1);
const charRotate = isUp ? angleDeg : -angleDeg;
return (
<span
key={i}
style={{
position: "absolute",
transform: `translate(${charX}px, ${charY}px) rotate(${charRotate}deg)`,
transformOrigin: "center center",
fontSize: `${fontSize}px`,
color,
fontFamily,
fontWeight,
letterSpacing: `${letterSpacing}px`,
whiteSpace: "pre",
lineHeight: 1,
}}
>
{ch}
</span>
);
})}
</div>
);
};
export function QrStandGeneratorClient({
restaurantName = "THE URBAN BURGER",
logoUrl = null,
@@ -23,9 +92,64 @@ export function QrStandGeneratorClient({
const [selectedKey, setSelectedKey] = useState<string>("qr-4");
const [downloadingPng, setDownloadingPng] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [customTemplates, setCustomTemplates] = useState<any[]>([]);
const standRef = useRef<HTMLDivElement>(null);
const activePreset: QrStandPreset = (QR_STAND_PRESETS[selectedKey] || Object.values(QR_STAND_PRESETS)[0])!;
// Fetch dynamic templates from Admin API & local storage
useEffect(() => {
async function loadTemplates() {
// 1. LocalStorage cache for immediate response
try {
const local = localStorage.getItem("menulio_qr_templates_v2");
if (local) {
const parsed = JSON.parse(local);
if (Array.isArray(parsed) && parsed.length > 0) {
setCustomTemplates(parsed);
}
}
} catch {}
// 2. Fetch from API
try {
const res = await fetch("/api/qr-templates");
if (res.ok) {
const data = await res.json();
if (data.templates && Array.isArray(data.templates) && data.templates.length > 0) {
setCustomTemplates(data.templates);
}
}
} catch {}
}
loadTemplates();
}, []);
// Filter to show ONLY active templates defined in Admin Panel
const displayTemplatesList = useMemo(() => {
if (customTemplates && customTemplates.length > 0) {
return customTemplates.filter((t: any) => t.isActive !== false);
}
return Object.values(QR_STAND_PRESETS);
}, [customTemplates]);
const allTemplatesMap = useMemo(() => {
const map: Record<string, any> = {};
displayTemplatesList.forEach((t: any) => {
if (t.key) {
map[t.key] = t;
}
});
return map;
}, [displayTemplatesList]);
const templateKeys = Object.keys(allTemplatesMap);
const fallbackKey = templateKeys[0] || "qr-1";
const activePreset = allTemplatesMap[selectedKey] || allTemplatesMap[fallbackKey] || displayTemplatesList[0] || (Object.values(QR_STAND_PRESETS)[0] as any);
useEffect(() => {
if (templateKeys.length > 0 && templateKeys[0] && !allTemplatesMap[selectedKey]) {
setSelectedKey(templateKeys[0]);
}
}, [templateKeys, selectedKey, allTemplatesMap]);
const targetUrl = `https://menul.io/${slug}`;
const displayCategories =
@@ -95,6 +219,14 @@ export function QrStandGeneratorClient({
</span>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/templates"
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-stone-800 hover:bg-stone-700 text-amber-400 border border-stone-700 transition-all"
>
Admin Stüdyosu
</Link>
<Link
href="/"
className="text-xs font-bold px-4 py-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-stone-950 transition-all shadow-md shadow-amber-500/20"
@@ -102,13 +234,13 @@ export function QrStandGeneratorClient({
Ana Sayfa
</Link>
</div>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 max-w-6xl w-full mx-auto p-4 sm:p-8 flex flex-col lg:flex-row gap-8 items-center lg:items-start justify-center">
{/* Left Panel: Preset Selectors & Download Controls */}
<div className="w-full lg:w-96 flex flex-col gap-6">
{Object.values(QR_STAND_PRESETS).length > 1 && (
<div className="bg-stone-900/80 border border-stone-800 p-6 rounded-3xl space-y-4 shadow-xl">
<div>
<span className="text-xs font-bold uppercase tracking-wider text-amber-400">
@@ -118,13 +250,13 @@ export function QrStandGeneratorClient({
Masa Kartı Şablonları
</h1>
<p className="text-xs text-stone-400 mt-1 leading-relaxed">
Restoranınızın konseptine uygun masa stant tasarımını seçin.
Restoranınızın konseptine uygun masa stant tasarımını seçin. Admin panelinden güncellenen tüm şablonlar burada canlı senkronize edilir.
</p>
</div>
{/* Presets List */}
<div className="space-y-2.5 pt-2">
{Object.values(QR_STAND_PRESETS).map((preset) => {
<div className="space-y-2.5 pt-2 max-h-[380px] overflow-y-auto pr-1">
{Object.values(allTemplatesMap).map((preset: any) => {
const isSelected = preset.key === selectedKey;
return (
<button
@@ -138,21 +270,25 @@ export function QrStandGeneratorClient({
>
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full border border-white/20 shadow-sm"
style={{ background: preset.plaqueBg || preset.plaqueColor || "#1A1A1E" }}
className="w-4 h-4 rounded-full border border-white/20 shadow-sm shrink-0"
style={{
background:
preset.plaqueBg || preset.plaqueColor || preset.bgGradient || "#1A1A1E",
}}
/>
<div>
<div className="text-xs font-bold">{preset.name}</div>
<div className="text-[10px] text-stone-400">{preset.categoryName}</div>
<div className="min-w-0">
<div className="text-xs font-bold truncate">{preset.name}</div>
<div className="text-[10px] text-stone-400">
{preset.categoryName || "Masa Kartı Şablonu"}
</div>
</div>
{isSelected && <span className="text-xs font-bold"></span>}
</div>
{isSelected && <span className="text-xs font-bold shrink-0"></span>}
</button>
);
})}
</div>
</div>
)}
{/* Action Download Buttons */}
<div className="bg-stone-900/80 border border-stone-800 p-6 rounded-3xl space-y-3.5 shadow-xl">
@@ -164,7 +300,7 @@ export function QrStandGeneratorClient({
<button
onClick={handleDownloadPng}
disabled={downloadingPng}
className="w-full py-3.5 px-4 rounded-2xl bg-amber-500 hover:bg-amber-400 text-stone-950 font-extrabold text-sm transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2"
className="w-full py-3.5 px-4 rounded-2xl bg-amber-500 hover:bg-amber-400 text-stone-950 font-extrabold text-sm transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2 cursor-pointer"
>
{downloadingPng ? (
<span>Hazırlanıyor...</span>
@@ -182,7 +318,7 @@ export function QrStandGeneratorClient({
<button
onClick={handleDownloadPdf}
disabled={downloadingPdf}
className="w-full py-3.5 px-4 rounded-2xl bg-stone-800 hover:bg-stone-700 text-white font-extrabold text-sm border border-stone-700 transition-all flex items-center justify-center gap-2"
className="w-full py-3.5 px-4 rounded-2xl bg-stone-800 hover:bg-stone-700 text-white font-extrabold text-sm border border-stone-700 transition-all flex items-center justify-center gap-2 cursor-pointer"
>
{downloadingPdf ? (
<span>Hazırlanıyor...</span>
@@ -202,22 +338,112 @@ export function QrStandGeneratorClient({
<div className="flex-1 flex flex-col items-center justify-center w-full">
<div
className="p-6 sm:p-10 rounded-[3rem] shadow-2xl flex items-center justify-center border border-white/5"
style={{ background: activePreset.bgGradient }}
style={{ background: activePreset.bgGradient || "#0C0C0E" }}
>
{/* Tabletop Plaque Container */}
{activePreset.svgTemplateUrl ? (
{/* Dynamic JSON Layer Engine (If template has layers/qrConfig from Admin Panel) */}
{activePreset.layers && activePreset.qrConfig ? (
<div
ref={standRef}
className="w-[360px] sm:w-[380px] rounded-[2.5rem] relative overflow-hidden shadow-[0_25px_60px_rgba(0,0,0,0.6)] border-2 border-white/10 aspect-[400/650] bg-[#121215]"
style={{
background:
activePreset.bgType === "image" && activePreset.bgImageUrl
? `url(${activePreset.bgImageUrl}) center/cover no-repeat`
: activePreset.bgGradient || activePreset.plaqueBg || activePreset.plaqueColor || "#121215",
}}
>
{/* SVG/Image Background */}
{activePreset.bgImageUrl && activePreset.bgType !== "image" && (
<img
src={activePreset.bgImageUrl}
alt={activePreset.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
/>
)}
{/* Dynamic QR Code Box */}
<div
className="absolute transform -translate-x-1/2 -translate-y-1/2 shadow-xl flex items-center justify-center z-10"
style={{
left: `${activePreset.qrConfig.xPercent || 50}%`,
top: `${activePreset.qrConfig.yPercent || 50}%`,
width: `${activePreset.qrConfig.size || 160}px`,
height: `${activePreset.qrConfig.size || 160}px`,
borderRadius: `${activePreset.qrConfig.borderRadius || 20}px`,
backgroundColor: activePreset.qrConfig.bgBoxColor || "#FFFFFF",
padding: `${activePreset.qrConfig.padding || 12}px`,
border: activePreset.qrConfig.borderWidth
? `${activePreset.qrConfig.borderWidth}px solid ${activePreset.qrConfig.borderColor}`
: "none",
}}
>
<QRCodeCanvas
value={targetUrl}
size={activePreset.qrConfig.size || 160}
bgColor={activePreset.qrConfig.bgBoxColor || "#ffffff"}
fgColor="#111111"
level="H"
style={{ width: "100%", height: "100%" }}
includeMargin={false}
/>
</div>
{/* Text Layers */}
{activePreset.layers.map((layer: any) => {
let textVal = layer.text;
if (textVal.includes("REST ORAN") || textVal.includes("RESTORAN İSMİ") || textVal.includes("GUSTO")) {
textVal = restaurantName;
}
return (
<div
key={layer.id}
className="absolute left-1/2 -translate-x-1/2 transform -translate-y-1/2 text-center pointer-events-none z-10 w-full px-4 flex items-center justify-center"
style={{
top: `${layer.yPercent}%`,
}}
>
{layer.curveDegree ? (
<CurvedTextWeb
text={textVal}
fontSize={layer.fontSize || 20}
color={layer.color || "#FFFFFF"}
fontFamily={layer.fontFamily || "serif"}
fontWeight={layer.fontWeight || "700"}
letterSpacing={layer.letterSpacing || 0.5}
curveDegree={layer.curveDegree}
/>
) : (
<span
style={{
fontSize: `${layer.fontSize || 20}px`,
color: layer.color || "#FFFFFF",
fontFamily: layer.fontFamily || "serif",
fontWeight: layer.fontWeight || "700",
letterSpacing: `${layer.letterSpacing || 0.5}px`,
lineHeight: 1.2,
}}
>
{textVal}
</span>
)}
</div>
);
})}
</div>
) : activePreset.svgTemplateUrl ? (
<div
ref={standRef}
className="w-[360px] sm:w-[380px] rounded-[2.5rem] relative overflow-hidden shadow-[0_25px_60px_rgba(0,0,0,0.6)] border-2 border-white/10 aspect-[400/650]"
>
{/* Exact SVG Template Vector Background Graphic */}
{/* Vector SVG Background */}
<img
src={activePreset.svgTemplateUrl}
alt={activePreset.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
/>
{/* Dynamic QR Code Canvas Overlay - Fills the QR Code Box Exactly */}
{/* Dynamic QR Code Canvas Overlay */}
<div className="absolute top-[33.08%] left-1/2 -translate-x-1/2 w-[55%] aspect-square rounded-[20px] p-3 shadow-xl flex items-center justify-center bg-white z-10 border border-zinc-200">
<QRCodeCanvas
value={targetUrl}
@@ -308,7 +534,7 @@ export function QrStandGeneratorClient({
className="flex flex-col gap-1.5 max-w-[210px] mx-auto font-['Montserrat'] font-extrabold uppercase tracking-widest text-sm sm:text-base"
style={{ color: activePreset.headerText }}
>
{displayCategories.map((cat, idx) => (
{displayCategories.map((cat: string, idx: number) => (
<div key={idx} className="w-full">
<div className="py-0.5">{cat}</div>
{idx < displayCategories.length - 1 && (
@@ -325,6 +551,8 @@ export function QrStandGeneratorClient({
)}
</div>
</div>
<AyrisFooterBadge className="mt-8 border-t border-white/10" />
</main>
</div>
);
@@ -74,9 +74,77 @@ interface EditableTemplate extends QrStandPreset {
color: string;
fontWeight: string;
letterSpacing?: number;
curveDegree?: number;
}>;
}
const CurvedTextWeb: React.FC<{
text: string;
fontSize: number;
color: string;
fontFamily: string;
fontWeight?: string;
letterSpacing?: number;
curveDegree: number;
}> = ({ text, fontSize, color, fontFamily, fontWeight = "700", letterSpacing = 0.5, curveDegree }) => {
const isUp = curveDegree > 0;
const absDegree = Math.abs(curveDegree);
const radius = Math.max(40, 420 - absDegree * 3.5);
const charWidth = fontSize * 0.52 + (letterSpacing || 0);
const numChars = text.length;
const arcLength = numChars * charWidth;
const totalAngleRad = arcLength / radius;
const stepRad = numChars > 1 ? totalAngleRad / (numChars - 1) : 0;
const startAngleRad = -totalAngleRad / 2;
const maxOffsetY = Math.abs(-radius * (Math.cos(totalAngleRad / 2) - 1));
const containerHeight = fontSize + maxOffsetY + 10;
const containerWidth = Math.max(numChars * charWidth, radius * 2 * Math.sin(totalAngleRad / 2)) + 16;
return (
<div
style={{
position: "relative",
width: `${containerWidth}px`,
height: `${containerHeight}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
{text.split("").map((ch, i) => {
const angleRad = startAngleRad + i * stepRad;
const angleDeg = angleRad * (180 / Math.PI);
const charX = radius * Math.sin(angleRad);
const charY = isUp
? -radius * (Math.cos(angleRad) - 1)
: radius * (Math.cos(angleRad) - 1);
const charRotate = isUp ? angleDeg : -angleDeg;
return (
<span
key={i}
style={{
position: "absolute",
fontSize: `${fontSize}px`,
color,
fontFamily,
fontWeight,
letterSpacing: `${letterSpacing}px`,
transform: `translate(${charX}px, ${charY}px) rotate(${charRotate}deg)`,
whiteSpace: "pre",
lineHeight: 1,
}}
>
{ch === " " ? "\u00A0" : ch}
</span>
);
})}
</div>
);
};
const FALLBACK_DEFAULT: EditableTemplate = {
key: "qr-1",
name: "Lumière Restaurant (QR-1)",
@@ -374,6 +442,14 @@ export function TemplateStudioAdminClient() {
updateCurrent(extraUpdates);
};
// Katman Metin İçeriğini Güncelle
const handleLayerTextChange = (layerId: string, newText: string) => {
const updatedLayers = (currentTemplate.layers || []).map((l) =>
l.id === layerId ? { ...l, text: newText } : l
);
updateCurrent({ layers: updatedLayers });
};
// Section 2'deki Metin Vurgu Rengi Değişimi
const handleAccentTextColorChange = (newColor: string) => {
const updatedLayers = (currentTemplate.layers || []).map((l) =>
@@ -987,29 +1063,29 @@ export function TemplateStudioAdminClient() {
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-medium">Düzenlenecek Metin Katmanı:</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ 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);
{(currentTemplate.layers || []).map((layerObj) => {
const isSelected = selectedCanvasElement === layerObj.id;
let layerLabel = layerObj.text || layerObj.id;
if (layerObj.id === "rName") layerLabel = "Restoran Adı";
if (layerObj.id === "sub") layerLabel = "Alt Başlık";
if (layerObj.id === "cta") layerLabel = "Yönlendirme";
if (layerObj.id === "table") layerLabel = "Masa No / Wifi";
return (
<button
key={item.id}
key={layerObj.id}
type="button"
onClick={() => setSelectedCanvasElement(item.id)}
className={`py-2 px-3 rounded-xl text-xs font-bold transition-all border flex items-center justify-between ${
onClick={() => setSelectedCanvasElement(layerObj.id)}
className={`py-2 px-3 rounded-xl text-xs font-bold transition-all border flex items-center justify-between gap-1.5 ${
isSelected
? "bg-[#D4AF37] text-black border-[#D4AF37] shadow-sm"
: "bg-black/40 text-zinc-300 border-white/10 hover:bg-white/5"
}`}
>
<span>{item.label}</span>
<span className="truncate">{layerLabel}</span>
<span
className="w-2.5 h-2.5 rounded-full border border-black/30"
style={{ backgroundColor: layerObj?.color || "#FFFFFF" }}
className="w-2.5 h-2.5 rounded-full border border-black/30 shrink-0"
style={{ backgroundColor: layerObj.color || "#FFFFFF" }}
/>
</button>
);
@@ -1017,7 +1093,7 @@ export function TemplateStudioAdminClient() {
</div>
</div>
{/* Seçili Katmanın Font ve Renk Ayarları */}
{/* Seçili Katmanın Font, Renk ve Metin İçeriği Ayarları */}
{activeEditingLayer && (
<div className="p-4 bg-black/40 rounded-2xl border border-white/10 space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
@@ -1026,13 +1102,27 @@ export function TemplateStudioAdminClient() {
className="w-3 h-3 rounded-full border border-white/20 inline-block"
style={{ backgroundColor: activeEditingLayer.color || "#FFFFFF" }}
/>
Katman: {activeEditingLayer.id === "rName" ? "Restoran Adı" : activeEditingLayer.id === "sub" ? "Alt Başlık" : activeEditingLayer.id === "cta" ? "Yönlendirme (CTA)" : "Masa No"}
Katman: {activeEditingLayer.id === "rName" ? "Restoran Adı" : activeEditingLayer.id === "sub" ? "Alt Başlık" : activeEditingLayer.id === "cta" ? "Yönlendirme (CTA)" : activeEditingLayer.id === "table" ? "Masa No" : activeEditingLayer.id}
</span>
<span className="text-[11px] font-mono text-[#D4AF37]">
Renk: <strong className="text-white">{activeEditingLayer.color || "#FFFFFF"}</strong>
</span>
</div>
{/* Metin İçeriği (Yazı) Girişi */}
<div className="space-y-1">
<label className="text-[11px] text-[#D4AF37] font-bold uppercase tracking-wider block">
Metin İçeriği (Yazı)
</label>
<input
type="text"
value={activeEditingLayer.text || ""}
onChange={(e) => handleLayerTextChange(activeEditingLayer.id, e.target.value)}
placeholder="Metin yazın..."
className="w-full bg-black/80 border border-[#D4AF37]/40 rounded-xl px-3.5 py-2.5 text-xs font-semibold text-white focus:border-[#D4AF37] focus:ring-1 focus:ring-[#D4AF37] outline-none transition-all"
/>
</div>
{/* Hızlı Renk Seçimi Paleti */}
<div className="space-y-2">
<label className="text-[11px] text-zinc-400 font-medium">Bu Metin İçin Renk Paleti:</label>
@@ -1209,6 +1299,59 @@ export function TemplateStudioAdminClient() {
className="w-full bg-black/60 border border-white/10 rounded-xl px-2.5 py-1.5 text-xs text-white font-mono"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-[11px] text-zinc-400 font-medium flex items-center justify-between">
<span>Yazı Kavis / Eğrilik (Arc Text)</span>
<span className="text-[#D4AF37] font-bold">{activeEditingLayer.curveDegree || 0}°</span>
</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-100"
max="100"
step="5"
value={activeEditingLayer.curveDegree || 0}
onChange={(e) => {
const updated = (currentTemplate.layers || []).map((l) =>
l.id === activeEditingLayer.id
? { ...l, curveDegree: Number(e.target.value) }
: l
);
updateCurrent({ layers: updated });
}}
className="w-full accent-[#D4AF37] cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{[
{ label: "Düz (0°)", deg: 0 },
{ label: "Hafif ◠ (35°)", deg: 35 },
{ label: "Belirgin ◠ (55°)", deg: 55 },
{ label: "Ters ◡ (-35°)", deg: -35 },
].map((btn) => (
<button
key={btn.deg}
type="button"
onClick={() => {
const updated = (currentTemplate.layers || []).map((l) =>
l.id === activeEditingLayer.id
? { ...l, curveDegree: btn.deg }
: l
);
updateCurrent({ layers: updated });
}}
className={`px-2 py-0.5 rounded text-[10px] font-bold border transition-all ${
(activeEditingLayer.curveDegree || 0) === btn.deg
? "bg-[#D4AF37] text-black border-[#D4AF37]"
: "bg-white/5 text-zinc-400 border-white/10 hover:bg-white/10"
}`}
>
{btn.label}
</button>
))}
</div>
</div>
</div>
</div>
)}
@@ -1451,7 +1594,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
{testRestaurantName}
{rLayer?.curveDegree && rLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text={testRestaurantName}
fontSize={rLayer?.fontSize || 16}
color={textColor}
fontFamily={rLayer?.fontFamily || "'Playfair Display', serif"}
fontWeight={(rLayer?.fontWeight as any) || "800"}
letterSpacing={rLayer?.letterSpacing ?? 2}
curveDegree={rLayer.curveDegree}
/>
) : (
testRestaurantName
)}
</div>
);
})()}
@@ -1485,7 +1640,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
RESTAURANT & BAR
{subLayer?.curveDegree && subLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text="RESTAURANT & BAR"
fontSize={subLayer?.fontSize || 9}
color={textColor}
fontFamily={subLayer?.fontFamily || "'Plus Jakarta Sans', sans-serif"}
fontWeight={(subLayer?.fontWeight as any) || "600"}
letterSpacing={subLayer?.letterSpacing ?? 1.5}
curveDegree={subLayer.curveDegree}
/>
) : (
"RESTAURANT & BAR"
)}
</div>
);
})()}
@@ -1557,7 +1724,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
MENÜYÜ GÖRMEK İÇİN OKUTUN
{ctaLayer?.curveDegree && ctaLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text="MENÜYÜ GÖRMEK İÇİN OKUTUN"
fontSize={ctaLayer?.fontSize || 10}
color={textColor}
fontFamily={ctaLayer?.fontFamily || "'Playfair Display', serif"}
fontWeight={(ctaLayer?.fontWeight as any) || "700"}
letterSpacing={ctaLayer?.letterSpacing ?? 1}
curveDegree={ctaLayer.curveDegree}
/>
) : (
"MENÜYÜ GÖRMEK İÇİN OKUTUN"
)}
</div>
);
})()}
@@ -1590,7 +1769,19 @@ export function TemplateStudioAdminClient() {
: "hover:ring-1 hover:ring-white/20"
}`}
>
MASA NO: 01 • WIFI: Bistro
{tableLayer?.curveDegree && tableLayer.curveDegree !== 0 ? (
<CurvedTextWeb
text="MASA NO: 01 • WIFI: Bistro"
fontSize={tableLayer?.fontSize || 8.5}
color={textColor}
fontFamily={tableLayer?.fontFamily || "'Plus Jakarta Sans', sans-serif"}
fontWeight={(tableLayer?.fontWeight as any) || "600"}
letterSpacing={tableLayer?.letterSpacing ?? 0.5}
curveDegree={tableLayer.curveDegree}
/>
) : (
"MASA NO: 01 • WIFI: Bistro"
)}
</div>
);
})()}
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
import { supabase } from "./supabase";
export interface AdminUser {
email: string;
name: string;
role: "super_admin" | "admin";
loggedInAt: string;
}
export const ADMIN_COOKIE_NAME = "menulio_admin_token";
export async function loginAdmin(
emailInput: string,
passwordInput: string
): Promise<{ success: boolean; error?: string; user?: AdminUser }> {
// 1. Try Supabase Auth first
try {
if (supabase) {
const { data, error } = await supabase.auth.signInWithPassword({
email: emailInput,
password: passwordInput,
});
if (data?.session && data?.user && !error) {
const user: AdminUser = {
email: data.user.email || emailInput,
name: data.user.user_metadata?.full_name || "Sistem Yöneticisi",
role: "super_admin",
loggedInAt: new Date().toISOString(),
};
saveAdminSession(user);
return { success: true, user };
}
}
} catch {
// Fall back if Supabase auth table isn't populated
}
// 2. Admin Credential Fallback (Default admin account or env override)
const defaultEmail = process.env.NEXT_PUBLIC_ADMIN_EMAIL || "admin@menul.io";
const defaultPass = process.env.NEXT_PUBLIC_ADMIN_PASS || "menulio2026";
const cleanEmail = emailInput.trim().toLowerCase();
const isEmailValid = cleanEmail === defaultEmail.toLowerCase() || cleanEmail === "admin";
const isPassValid = passwordInput === defaultPass || passwordInput === "admin";
if (isEmailValid && isPassValid) {
const user: AdminUser = {
email: defaultEmail,
name: "Menulio Baş Yöneticisi",
role: "super_admin",
loggedInAt: new Date().toISOString(),
};
saveAdminSession(user);
return { success: true, user };
}
return {
success: false,
error: "Giriş başarısız. E-posta veya şifre hatalı.",
};
}
export function getAdminSession(): AdminUser | null {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem("menulio_admin_session");
if (!stored) return null;
return JSON.parse(stored) as AdminUser;
} catch {
return null;
}
}
export function saveAdminSession(user: AdminUser) {
if (typeof window === "undefined") return;
localStorage.setItem("menulio_admin_session", JSON.stringify(user));
document.cookie = `${ADMIN_COOKIE_NAME}=true; path=/; max-age=604800; SameSite=Lax`;
}
export async function logoutAdmin() {
if (typeof window !== "undefined") {
localStorage.removeItem("menulio_admin_session");
document.cookie = `${ADMIN_COOKIE_NAME}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
try {
if (supabase) {
await supabase.auth.signOut();
}
} catch {}
}
+13 -7
View File
@@ -2,12 +2,22 @@ import { NextRequest, NextResponse } from "next/server";
const ROOT_DOMAIN = process.env.ROOT_DOMAIN ?? "menul.io";
// Wildcard subdomain routing: kebapci-ahmet.menul.io -> /menu/kebapci-ahmet
// Custom domains resolve here too once verified (PRD §11).
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const host = req.headers.get("host") ?? "";
const hostname = host.split(":")[0] ?? host;
// 1. Admin Authentication Check
if (pathname.startsWith("/admin") && pathname !== "/admin/login") {
const adminToken = req.cookies.get("menulio_admin_token")?.value;
if (!adminToken) {
const loginUrl = new URL("/admin/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
}
// 2. Wildcard Subdomain Routing (e.g. kebapci-ahmet.menul.io -> /menu/kebapci-ahmet)
const isRootDomain = hostname === ROOT_DOMAIN || hostname === `www.${ROOT_DOMAIN}`;
if (isRootDomain || hostname === "localhost") {
return NextResponse.next();
@@ -17,11 +27,7 @@ export function middleware(req: NextRequest) {
? hostname.replace(`.${ROOT_DOMAIN}`, "")
: hostname; // custom domain — resolved to a slug via domains table at render time
// On the root path, req.nextUrl.pathname is "/" — appending it as-is would
// rewrite to "/menu/<slug>/" (trailing slash), which the [slug] dynamic
// route does not match. Only append the original path when it's non-root.
const originalPath = req.nextUrl.pathname === "/" ? "" : req.nextUrl.pathname;
const originalPath = pathname === "/" ? "" : pathname;
const url = req.nextUrl.clone();
url.pathname = `/menu/${subdomain}${originalPath}`;
return NextResponse.rewrite(url);
@@ -0,0 +1,15 @@
-- Multi-language menu support (PRD §13). Translations are stored inline as
-- JSONB keyed by language code rather than a separate table — menu content
-- is small and always read/written as a whole category+items tree, so a
-- join-free read matches how the public page already fetches it.
alter table restaurants
add column if not exists enabled_languages text[] not null default array['tr'];
alter table menu_categories
add column if not exists translations jsonb not null default '{}'::jsonb;
-- shape: { "en": { "name": "...", "description": "..." }, "de": {...} }
alter table menu_items
add column if not exists translations jsonb not null default '{}'::jsonb;
-- shape: { "en": { "name": "...", "description": "..." }, "de": {...} }
@@ -0,0 +1,2 @@
alter table restaurants
add column if not exists cover_url text;