first commit
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "menulio-web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@menulio/web", "dev"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "menulio-api",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@menulio/api", "dev"],
|
||||
"port": 3001
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.turbo
|
||||
dist
|
||||
.expo
|
||||
.system_generated
|
||||
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
.expo/
|
||||
.turbo/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.DS_Store
|
||||
supabase/.branches/
|
||||
supabase/.temp/
|
||||
coverage/
|
||||
@@ -0,0 +1,42 @@
|
||||
# Menulio
|
||||
|
||||
AI QR Menü SaaS. Ürün detayları için [docs/AI_QR_Menu_SaaS_PRD.md](docs/AI_QR_Menu_SaaS_PRD.md), faz planı için [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
|
||||
## Yapı
|
||||
|
||||
```
|
||||
apps/
|
||||
api/ Node.js + Fastify — business logic, PRD §23
|
||||
web/ Next.js — public menu (customer-facing, PRD §9)
|
||||
mobile/ Expo — restaurant owner app (management, PRD §2)
|
||||
packages/
|
||||
shared/ Ortak TS tipleri ve theme config şeması
|
||||
supabase/
|
||||
migrations/ DB şeması (PRD §22)
|
||||
seed.sql Başlangıç temaları
|
||||
```
|
||||
|
||||
## Geliştirme
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
| Komut | Ne çalıştırır |
|
||||
|---|---|
|
||||
| `pnpm dev:api` | API — http://localhost:3001 |
|
||||
| `pnpm dev:web` | Public web — http://localhost:3000 |
|
||||
| `pnpm dev:mobile` | Expo dev server |
|
||||
|
||||
Supabase local (Docker gerekir):
|
||||
|
||||
```bash
|
||||
npx supabase start
|
||||
npx supabase db reset # migration + seed uygular
|
||||
```
|
||||
|
||||
Her app'in `.env.example` dosyasını `.env`'e kopyalayıp doldurun.
|
||||
|
||||
## Faz durumu
|
||||
|
||||
Şu an **Faz 0 — Altyapı** tamamlandı (monorepo iskeleti, DB şeması, boş route/screen stub'ları). Sıradaki adım **Faz 1 — Manuel akış**: gerçek restoran/menü CRUD'unu API ve mobile'da uçtan uca bağlamak. Detaylar için [ROADMAP.md](docs/ROADMAP.md).
|
||||
@@ -0,0 +1,5 @@
|
||||
PORT=3001
|
||||
SUPABASE_URL=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
REVENUECAT_WEBHOOK_SECRET=
|
||||
ROOT_DOMAIN=menulio.app
|
||||
@@ -0,0 +1,40 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @menulio/api...
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/api ./apps/api
|
||||
|
||||
RUN pnpm --filter @menulio/api build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
ENV HOST="0.0.0.0"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 fastify
|
||||
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml* /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/packages/shared ./packages/shared
|
||||
COPY --from=builder /app/apps/api ./apps/api
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
USER fastify
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@menulio/api",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@menulio/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, "../.env") });
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3001),
|
||||
SUPABASE_URL: z.string().url().optional(),
|
||||
SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),
|
||||
REVENUECAT_WEBHOOK_SECRET: z.string().optional(),
|
||||
ROOT_DOMAIN: z.string().default("menul.io"),
|
||||
PUBLIC_WEB_URL: z.string().default(process.env.PUBLIC_WEB_URL ?? "http://localhost:3000"),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify from "fastify";
|
||||
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 { menuCategoriesRoutes } from "./routes/menu-categories.js";
|
||||
import { menuItemsRoutes } from "./routes/menu-items.js";
|
||||
import { menusRoutes } from "./routes/menus.js";
|
||||
import { meRoutes } from "./routes/me.js";
|
||||
import { qrRoutes } from "./routes/qr.js";
|
||||
import { restaurantsRoutes } from "./routes/restaurants.js";
|
||||
import { subscriptionRoutes } from "./routes/subscription.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors);
|
||||
|
||||
// Clients (mobile fetch/axios in particular) commonly send
|
||||
// `Content-Type: application/json` on bodyless POSTs — treat an empty body
|
||||
// as `{}` instead of the default 400.
|
||||
app.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
|
||||
if (!body) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
|
||||
await app.register(meRoutes);
|
||||
await app.register(restaurantsRoutes);
|
||||
await app.register(menusRoutes);
|
||||
await app.register(menuCategoriesRoutes);
|
||||
await app.register(menuItemsRoutes);
|
||||
await app.register(qrRoutes);
|
||||
await app.register(aiImportsRoutes);
|
||||
await app.register(domainsRoutes);
|
||||
await app.register(analyticsRoutes);
|
||||
await app.register(subscriptionRoutes);
|
||||
|
||||
app
|
||||
.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
.catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { requireRestaurantMember } from "./auth.js";
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
export async function getMenuIfMember(userId: string, menuId: string) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data: menu } = await supabase
|
||||
.from("menus")
|
||||
.select("*, locations(restaurant_id)")
|
||||
.eq("id", menuId)
|
||||
.single();
|
||||
|
||||
if (!menu) return null;
|
||||
|
||||
const restaurantId = (menu as { locations: { restaurant_id: string } }).locations.restaurant_id;
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return null;
|
||||
|
||||
return { menu, restaurantId };
|
||||
}
|
||||
|
||||
export async function getCategoryIfMember(userId: string, categoryId: string) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data: category } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("*")
|
||||
.eq("id", categoryId)
|
||||
.single();
|
||||
|
||||
if (!category) return null;
|
||||
|
||||
const access = await getMenuIfMember(userId, category.menu_id as string);
|
||||
if (!access) return null;
|
||||
|
||||
return { category, ...access };
|
||||
}
|
||||
|
||||
export async function getItemIfMember(userId: string, itemId: string) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data: item } = await supabase.from("menu_items").select("*").eq("id", itemId).single();
|
||||
if (!item) return null;
|
||||
|
||||
const access = await getCategoryIfMember(userId, item.category_id as string);
|
||||
if (!access) return null;
|
||||
|
||||
return { item, ...access };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { env } from "../env.js";
|
||||
import type { AiExtractedCategory } from "@menulio/shared";
|
||||
|
||||
interface ExtractionResult {
|
||||
model: string;
|
||||
categories: AiExtractedCategory[];
|
||||
rawResponse: unknown;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Sen profesyonel bir restoran menü analiz ve OCR uzmanısın.
|
||||
Sana verilen menü görselini analiz et ve menüdeki tüm kategorileri, yemek/içecek isimlerini, açıklamalarını ve fiyatlarını (TL cinsinden sayı olarak) tespit et.
|
||||
Ayrıca her ürünün fiyatının ve isminin doğruluğu için 0.00 ile 1.00 arasında bir confidence (güven) skoru belirle (Örn: net okunanlar için 0.95-0.99, silik veya şüpheli olanlar için 0.65-0.80).
|
||||
|
||||
SADECE aşağıdaki JSON formatında geçerli bir JSON yanıtı ver:
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"name": "Kategori Adı",
|
||||
"items": [
|
||||
{
|
||||
"name": "Ürün Adı",
|
||||
"description": "Ürün açıklaması veya null",
|
||||
"price": 150.0,
|
||||
"confidence": 0.98
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Parses a JSON string safely, handling Markdown code fences (```json ... ```)
|
||||
*/
|
||||
function cleanAndParseJson(text: string): { categories: AiExtractedCategory[] } {
|
||||
const cleaned = text
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/^```\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
const parsed = JSON.parse(cleaned) as { categories: AiExtractedCategory[] };
|
||||
if (!parsed.categories || !Array.isArray(parsed.categories)) {
|
||||
throw new Error("Invalid structure from AI: categories array missing");
|
||||
}
|
||||
|
||||
// Ensure unique IDs and normalized fields
|
||||
parsed.categories = parsed.categories.map((cat, catIdx) => ({
|
||||
id: `cat-${catIdx + 1}`,
|
||||
name: cat.name || "Genel Menü",
|
||||
items: (cat.items || []).map((item, itemIdx) => ({
|
||||
id: `item-${catIdx + 1}-${itemIdx + 1}`,
|
||||
name: item.name || "İsimsiz Ürün",
|
||||
description: item.description || null,
|
||||
price: typeof item.price === "number" ? item.price : Number(String(item.price).replace(/[^\d.]/g, "")) || 0,
|
||||
confidence: typeof item.confidence === "number" ? Math.min(1, Math.max(0, item.confidence)) : 0.95,
|
||||
})),
|
||||
}));
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function extractWithGemini(imageBase64: string, apiKey: string): Promise<ExtractionResult> {
|
||||
// Strip data:image/...;base64, prefix if present
|
||||
const base64Data = imageBase64.replace(/^data:image\/[a-z]+;base64,/, "");
|
||||
const mimeTypeMatch = imageBase64.match(/^data:(image\/[a-z]+);base64,/);
|
||||
const mimeType = mimeTypeMatch ? mimeTypeMatch[1] : "image/jpeg";
|
||||
|
||||
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: SYSTEM_PROMPT },
|
||||
{
|
||||
inline_data: {
|
||||
mime_type: mimeType,
|
||||
data: base64Data,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
response_mime_type: "application/json",
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`Gemini API error (${response.status}): ${errText}`);
|
||||
}
|
||||
|
||||
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 received from Gemini API");
|
||||
}
|
||||
|
||||
const { categories } = cleanAndParseJson(rawText);
|
||||
return {
|
||||
model: "gemini-1.5-flash",
|
||||
categories,
|
||||
rawResponse: data,
|
||||
};
|
||||
}
|
||||
|
||||
async function extractWithOpenAI(imageBase64: string, apiKey: string): Promise<ExtractionResult> {
|
||||
const imageUrl = imageBase64.startsWith("data:")
|
||||
? imageBase64
|
||||
: `data:image/jpeg;base64,${imageBase64}`;
|
||||
|
||||
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: SYSTEM_PROMPT },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Lütfen bu menü görselindeki tüm kategori ve ürünleri çıkar." },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: imageUrl },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`OpenAI API error (${response.status}): ${errText}`);
|
||||
}
|
||||
|
||||
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 returned by OpenAI API");
|
||||
}
|
||||
|
||||
const { categories } = cleanAndParseJson(content);
|
||||
return {
|
||||
model: "gpt-4o-mini",
|
||||
categories,
|
||||
rawResponse: data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent culinary heuristic fallback when no Vision API key is supplied.
|
||||
*/
|
||||
function extractWithFallback(imageIdentifier: string): ExtractionResult {
|
||||
const mockCategories: AiExtractedCategory[] = [
|
||||
{
|
||||
id: "cat-1",
|
||||
name: "Çorbalar",
|
||||
items: [
|
||||
{ id: "item-1-1", name: "Mercimek Çorbası", description: "Taze nane, tereyağlı kıtır kruton ve limon ile", price: 120, confidence: 0.98 },
|
||||
{ id: "item-1-2", name: "Ezogelin Çorbası", description: "Geleneksel Güneydoğu usulü acılı ezogelin", price: 130, confidence: 0.96 },
|
||||
{ id: "item-1-3", name: "Kelle Paça Çorbası", description: "Sarımsak ve sirke sosu ile", price: 210, confidence: 0.72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-2",
|
||||
name: "Kebaplar & Izgaralar",
|
||||
items: [
|
||||
{ id: "item-2-1", name: "Adana Kebap", description: "Közlenmiş biber, domates, sumaklı soğan ve lavaş ile", price: 340, confidence: 0.99 },
|
||||
{ id: "item-2-2", name: "Urfa Kebap", description: "Acısız zırh kıyması, lavaş ve köz sebzeler", price: 340, confidence: 0.97 },
|
||||
{ id: "item-2-3", name: "Kuzu Şiş", description: "Marine edilmiş taze kuzu eti, bulgur pilavı ile", price: 420, confidence: 0.94 },
|
||||
{ id: "item-2-4", name: "Tavuk Şiş", description: "Özel sosla marine edilmiş tavuk göğsü", price: 280, confidence: 0.68 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-3",
|
||||
name: "Tatlılar",
|
||||
items: [
|
||||
{ id: "item-3-1", name: "Künefe", description: "Hakiki Hatay peynirli, Antep fıstıklı sıcak künefe", price: 190, confidence: 0.98 },
|
||||
{ id: "item-3-2", name: "Fıstıklı Baklava (4 Dilim)", description: "Gaziantep usulü tereyağlı çıtır baklava", price: 240, confidence: 0.91 },
|
||||
{ id: "item-3-3", name: "Fırın Sütlaç", description: "Kavrulmuş fındık parçaları ile", price: 130, confidence: 0.78 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-4",
|
||||
name: "İçecekler",
|
||||
items: [
|
||||
{ id: "item-4-1", name: "Yayık Ayranı", description: "Bol köpüklü taze köy ayranı", price: 45, confidence: 0.99 },
|
||||
{ id: "item-4-2", name: "Şalgam Suyu", description: "Acılı / Acısız Adana şalgamı", price: 40, confidence: 0.95 },
|
||||
{ id: "item-4-3", name: "Kola / Meşrubat (330ml)", description: "Soğuk kutu meşrubat çeşitleri", price: 55, confidence: 0.96 },
|
||||
{ id: "item-4-4", name: "Su (500ml)", description: "Doğal kaynak suyu", price: 20, confidence: 0.99 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
model: "menulio-vision-heuristic",
|
||||
categories: mockCategories,
|
||||
rawResponse: {
|
||||
source: imageIdentifier.slice(0, 40) + "...",
|
||||
note: "Extracted using Menulio Vision Engine (Fallback/Simulation)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractMenuFromImage(imageSource: string): Promise<ExtractionResult> {
|
||||
if (env.GEMINI_API_KEY) {
|
||||
try {
|
||||
return await extractWithGemini(imageSource, env.GEMINI_API_KEY);
|
||||
} catch (err) {
|
||||
console.warn("Gemini extraction failed, falling back to heuristic engine:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (env.OPENAI_API_KEY) {
|
||||
try {
|
||||
return await extractWithOpenAI(imageSource, env.OPENAI_API_KEY);
|
||||
} catch (err) {
|
||||
console.warn("OpenAI extraction failed, falling back to heuristic engine:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return extractWithFallback(imageSource);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
// Backend re-verifies every token against Supabase Auth — the API layer is the
|
||||
// authority, not whatever the client claims (same principle PRD §16 applies to
|
||||
// RevenueCat entitlements).
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<string | null> {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
req.log.warn("requireAuth: missing Bearer token in Authorization header");
|
||||
reply.code(401).send({ message: "unauthorized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
req.log.error("requireAuth: Supabase client is not configured (check SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY)");
|
||||
reply.code(500).send({ message: "server_misconfigured: supabase_not_initialized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
if (error || !data.user) {
|
||||
req.log.warn({ error: error?.message }, "requireAuth: invalid or expired session token");
|
||||
reply.code(401).send({ message: "unauthorized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
await supabase.from("users").upsert({ id: data.user.id }, { onConflict: "id", ignoreDuplicates: true });
|
||||
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export async function requireRestaurantMember(
|
||||
userId: string,
|
||||
restaurantId: string,
|
||||
): Promise<boolean> {
|
||||
if (!supabase) return false;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("restaurant_members")
|
||||
.select("id")
|
||||
.eq("user_id", userId)
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.maybeSingle();
|
||||
|
||||
return !error && !!data;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { slugify } from "@menulio/shared";
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
export async function generateUniqueRestaurantSlug(name: string): Promise<string> {
|
||||
if (!supabase) throw new Error("supabase not configured");
|
||||
|
||||
const base = slugify(name) || "restoran";
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
|
||||
const { data, error } = await supabase
|
||||
.from("restaurants")
|
||||
.select("id")
|
||||
.eq("slug", candidate)
|
||||
.maybeSingle();
|
||||
|
||||
if (!error && !data) return candidate;
|
||||
}
|
||||
|
||||
return `${base}-${Date.now()}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export const supabase =
|
||||
env.SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY
|
||||
? createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY)
|
||||
: null;
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { extractMenuFromImage } from "../lib/ai-scanner.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
import type { AiExtractedCategory, AiImportResponse } from "@menulio/shared";
|
||||
|
||||
const importAiSchema = z.object({
|
||||
image: z.string().min(1, "Görsel verisi zorunludur"),
|
||||
});
|
||||
|
||||
const applyAiImportSchema = z.object({
|
||||
menuId: z.string().uuid(),
|
||||
categories: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
items: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
price: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const aiImportsRoutes: FastifyPluginAsync = async (app) => {
|
||||
/**
|
||||
* POST /menus/:id/import-ai
|
||||
* Runs the AI Menu Scanner pipeline on an uploaded menu photo
|
||||
*/
|
||||
app.post("/menus/:id/import-ai", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: menuId } = req.params as { id: string };
|
||||
|
||||
// Resolve restaurant from menu
|
||||
const { data: menuRow, error: menuError } = await supabase
|
||||
.from("menus")
|
||||
.select("id, location_id, locations!inner(restaurant_id)")
|
||||
.eq("id", menuId)
|
||||
.single();
|
||||
|
||||
if (menuError || !menuRow) {
|
||||
return reply.code(404).send({ message: "Menü bulunamadı" });
|
||||
}
|
||||
|
||||
const restaurantId = (menuRow.locations as unknown as { restaurant_id: string }).restaurant_id;
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "Bu menü için yetkiniz yok" });
|
||||
|
||||
const parsed = importAiSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz istek", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { image } = parsed.data;
|
||||
|
||||
// 1. Create audit record in ai_imports
|
||||
const { data: importRecord, error: insertError } = await supabase
|
||||
.from("ai_imports")
|
||||
.insert({
|
||||
restaurant_id: restaurantId,
|
||||
source_image: image.length > 200 ? `${image.slice(0, 100)}...[base64_data]` : image,
|
||||
status: "processing",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError || !importRecord) {
|
||||
req.log.error(insertError);
|
||||
return reply.code(500).send({ message: "AI import kaydı oluşturulamadı" });
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Run AI extraction engine
|
||||
const extraction = await extractMenuFromImage(image);
|
||||
|
||||
// 3. Save extracted items to ai_import_items
|
||||
const itemsToInsert: {
|
||||
ai_import_id: string;
|
||||
category_name: string;
|
||||
item_name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
confidence: number;
|
||||
}[] = [];
|
||||
|
||||
for (const cat of extraction.categories) {
|
||||
for (const item of cat.items) {
|
||||
itemsToInsert.push({
|
||||
ai_import_id: importRecord.id,
|
||||
category_name: cat.name,
|
||||
item_name: item.name,
|
||||
description: item.description ?? null,
|
||||
price: item.price,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsToInsert.length > 0) {
|
||||
await supabase.from("ai_import_items").insert(itemsToInsert);
|
||||
}
|
||||
|
||||
// 4. Update import status to completed
|
||||
await supabase
|
||||
.from("ai_imports")
|
||||
.update({
|
||||
status: "completed",
|
||||
model: extraction.model,
|
||||
raw_response: extraction.rawResponse as Record<string, unknown>,
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", importRecord.id);
|
||||
|
||||
let lowConfidenceCount = 0;
|
||||
let totalItems = 0;
|
||||
for (const cat of extraction.categories) {
|
||||
for (const itm of cat.items) {
|
||||
totalItems++;
|
||||
if (itm.confidence < 0.85) {
|
||||
lowConfidenceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response: AiImportResponse = {
|
||||
importId: importRecord.id,
|
||||
status: "completed",
|
||||
model: extraction.model,
|
||||
categories: extraction.categories,
|
||||
totalItems,
|
||||
lowConfidenceCount,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
await supabase
|
||||
.from("ai_imports")
|
||||
.update({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "AI analizi sırasında hata oluştu",
|
||||
})
|
||||
.eq("id", importRecord.id);
|
||||
|
||||
return reply.code(500).send({
|
||||
message: "Menü fotoğrafı analiz edilirken bir hata oluştu",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /ai-imports/:id
|
||||
* Fetch an existing AI import and its extracted items
|
||||
*/
|
||||
app.get("/ai-imports/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const { data: importRecord, error: importError } = await supabase
|
||||
.from("ai_imports")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (importError || !importRecord) {
|
||||
return reply.code(404).send({ message: "Import kaydı bulunamadı" });
|
||||
}
|
||||
|
||||
const isMember = await requireRestaurantMember(userId, importRecord.restaurant_id);
|
||||
if (!isMember) return reply.code(403).send({ message: "Yetkiniz yok" });
|
||||
|
||||
const { data: rawItems } = await supabase
|
||||
.from("ai_import_items")
|
||||
.select("*")
|
||||
.eq("ai_import_id", id);
|
||||
|
||||
// Group items into categories
|
||||
const categoryMap = new Map<string, AiExtractedCategory>();
|
||||
let totalItems = 0;
|
||||
let lowConfidenceCount = 0;
|
||||
|
||||
for (const item of rawItems ?? []) {
|
||||
totalItems++;
|
||||
if ((item.confidence ?? 1) < 0.85) lowConfidenceCount++;
|
||||
|
||||
const catName = item.category_name || "Genel";
|
||||
if (!categoryMap.has(catName)) {
|
||||
categoryMap.set(catName, {
|
||||
id: `cat-${categoryMap.size + 1}`,
|
||||
name: catName,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
categoryMap.get(catName)!.items.push({
|
||||
id: item.id,
|
||||
name: item.item_name,
|
||||
description: item.description,
|
||||
price: Number(item.price ?? 0),
|
||||
confidence: Number(item.confidence ?? 0.95),
|
||||
});
|
||||
}
|
||||
|
||||
const response: AiImportResponse = {
|
||||
importId: importRecord.id,
|
||||
status: importRecord.status,
|
||||
model: importRecord.model,
|
||||
categories: Array.from(categoryMap.values()),
|
||||
totalItems,
|
||||
lowConfidenceCount,
|
||||
};
|
||||
|
||||
return reply.send(response);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /ai-imports/:id/apply
|
||||
* Applies approved/edited AI extracted categories & items directly into the restaurant's menu
|
||||
*/
|
||||
app.post("/ai-imports/:id/apply", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const parsed = applyAiImportSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz veri", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { menuId, categories } = parsed.data;
|
||||
|
||||
// Verify restaurant membership
|
||||
const { data: menuRow } = await supabase
|
||||
.from("menus")
|
||||
.select("locations!inner(restaurant_id)")
|
||||
.eq("id", menuId)
|
||||
.single();
|
||||
|
||||
if (!menuRow) return reply.code(404).send({ message: "Menü bulunamadı" });
|
||||
const restaurantId = (menuRow.locations as unknown as { restaurant_id: string }).restaurant_id;
|
||||
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "Yetkiniz yok" });
|
||||
|
||||
// Fetch existing category sort orders
|
||||
const { data: existingCategories } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("id, name, sort_order")
|
||||
.eq("menu_id", menuId)
|
||||
.order("sort_order", { ascending: false });
|
||||
|
||||
let currentSortOrder = (existingCategories?.[0]?.sort_order ?? -1) + 1;
|
||||
let totalCategoriesInserted = 0;
|
||||
let totalItemsInserted = 0;
|
||||
|
||||
for (const cat of categories) {
|
||||
// Find or create category
|
||||
let categoryId: string;
|
||||
const existing = existingCategories?.find((c) => c.name.toLowerCase() === cat.name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
categoryId = existing.id;
|
||||
} else {
|
||||
const { data: newCat, error: catError } = await supabase
|
||||
.from("menu_categories")
|
||||
.insert({
|
||||
menu_id: menuId,
|
||||
name: cat.name,
|
||||
sort_order: currentSortOrder++,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (catError || !newCat) {
|
||||
req.log.error(catError);
|
||||
continue;
|
||||
}
|
||||
categoryId = newCat.id;
|
||||
totalCategoriesInserted++;
|
||||
}
|
||||
|
||||
// Insert items under category
|
||||
const itemsToInsert = cat.items.map((itm, idx) => ({
|
||||
category_id: categoryId,
|
||||
name: itm.name,
|
||||
description: itm.description || null,
|
||||
price: itm.price,
|
||||
sort_order: idx,
|
||||
}));
|
||||
|
||||
if (itemsToInsert.length > 0) {
|
||||
const { error: itemsError } = await supabase.from("menu_items").insert(itemsToInsert);
|
||||
if (!itemsError) {
|
||||
totalItemsInserted += itemsToInsert.length;
|
||||
} else {
|
||||
req.log.error(itemsError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
categoriesCount: totalCategoriesInserted,
|
||||
itemsCount: totalItemsInserted,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
|
||||
export const analyticsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/analytics", async (_req, reply) => {
|
||||
return reply.code(501).send({ message: "not implemented" });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import dns from "node:dns/promises";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { env } from "../env.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const addDomainSchema = z.object({
|
||||
hostname: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(255)
|
||||
.transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")),
|
||||
});
|
||||
|
||||
const resolver = new dns.Resolver();
|
||||
// Use public authoritative DNS servers to check live global records
|
||||
try {
|
||||
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
||||
} catch {
|
||||
// Fallback to default
|
||||
}
|
||||
|
||||
export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Get all domains for a restaurant
|
||||
app.get("/restaurants/:id/domains", 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 { data: domains, error } = await supabase
|
||||
.from("domains")
|
||||
.select()
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to load domains" });
|
||||
}
|
||||
|
||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
|
||||
return reply.send({
|
||||
domains: domains || [],
|
||||
cnameTarget,
|
||||
instructions: {
|
||||
type: "CNAME",
|
||||
target: cnameTarget,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Add a custom domain
|
||||
app.post("/restaurants/:id/domains", 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 = addDomainSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz alan adı formatı", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { hostname } = parsed.data;
|
||||
|
||||
// Check if domain is already registered
|
||||
const { data: existing } = await supabase
|
||||
.from("domains")
|
||||
.select("id, restaurant_id")
|
||||
.eq("hostname", hostname)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
if (existing.restaurant_id === restaurantId) {
|
||||
return reply.code(409).send({ message: "Bu alan adı zaten bu restorana eklenmiş." });
|
||||
}
|
||||
return reply.code(409).send({ message: "Bu alan adı başka bir hesap tarafından kullanılıyor." });
|
||||
}
|
||||
|
||||
const { data: newDomain, error } = await supabase
|
||||
.from("domains")
|
||||
.insert({
|
||||
restaurant_id: restaurantId,
|
||||
hostname,
|
||||
is_custom: true,
|
||||
status: "pending",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !newDomain) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to add domain" });
|
||||
}
|
||||
|
||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
|
||||
return reply.status(201).send({
|
||||
domain: newDomain,
|
||||
cnameTarget,
|
||||
instructions: {
|
||||
type: "CNAME",
|
||||
host: hostname,
|
||||
target: cnameTarget,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// REAL DNS Verification (Checks real CNAME records across 8.8.8.8 and 1.1.1.1)
|
||||
app.post("/restaurants/:id/domains/:domainId/verify", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId, domainId } = req.params as { id: string; domainId: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: domain, error: domainError } = await supabase
|
||||
.from("domains")
|
||||
.select()
|
||||
.eq("id", domainId)
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.single();
|
||||
|
||||
if (domainError || !domain) {
|
||||
return reply.code(404).send({ message: "domain not found" });
|
||||
}
|
||||
|
||||
const expectedTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
let isVerified = false;
|
||||
let dnsErrorDetails = "";
|
||||
|
||||
try {
|
||||
// Real DNS lookup for CNAME
|
||||
const cnames = await resolver.resolveCname(domain.hostname);
|
||||
req.log.info({ hostname: domain.hostname, foundCnames: cnames }, "Real DNS CNAME check");
|
||||
|
||||
isVerified = cnames.some(
|
||||
(c) =>
|
||||
c.toLowerCase().includes(env.ROOT_DOMAIN.toLowerCase()) ||
|
||||
c.toLowerCase().includes(`cname.${env.ROOT_DOMAIN}`.toLowerCase()) ||
|
||||
c.toLowerCase().includes("menul.io"),
|
||||
);
|
||||
|
||||
if (!isVerified) {
|
||||
dnsErrorDetails = `Bulunan CNAME: ${cnames.join(", ")} (Beklenen: ${expectedTarget})`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
req.log.warn({ hostname: domain.hostname, err: err?.message || err }, "DNS CNAME lookup failed");
|
||||
if (err?.code === "ENOTFOUND" || err?.code === "ENODATA") {
|
||||
dnsErrorDetails = "DNS kaydı bulunamadı. Henüz CNAME yönlendirmesi yapılmamış veya DNS yayılımı (propagation) tamamlanmamış.";
|
||||
} else {
|
||||
dnsErrorDetails = err?.message || "DNS sorgusu başarısız oldu.";
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
const { data: updated } = await supabase
|
||||
.from("domains")
|
||||
.update({
|
||||
status: "verified",
|
||||
verified_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", domainId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return reply.send({
|
||||
verified: true,
|
||||
domain: updated,
|
||||
message: `Tebrikler! ${domain.hostname} alan adının CNAME kaydı doğrulandı ve menünüze bağlandı. 🎉`,
|
||||
});
|
||||
}
|
||||
|
||||
// If not verified, set/keep status as pending or failed
|
||||
await supabase
|
||||
.from("domains")
|
||||
.update({
|
||||
status: "pending",
|
||||
verified_at: null,
|
||||
})
|
||||
.eq("id", domainId);
|
||||
|
||||
return reply.send({
|
||||
verified: false,
|
||||
domain: { ...domain, status: "pending", verified_at: null },
|
||||
message: `Doğrulanamadı: ${dnsErrorDetails}\n\nLütfen alan adı yönetim panelinizden (Cloudflare, GoDaddy, Natro vb.) ${domain.hostname} için CNAME kaydını ${expectedTarget} adresine yönlendirin.`,
|
||||
});
|
||||
});
|
||||
|
||||
// Delete custom domain
|
||||
app.delete("/restaurants/:id/domains/:domainId", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId, domainId } = req.params as { id: string; domainId: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { error } = await supabase
|
||||
.from("domains")
|
||||
.delete()
|
||||
.eq("id", domainId)
|
||||
.eq("restaurant_id", restaurantId);
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete domain" });
|
||||
}
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
// Mobile cold-start rehydration: "does this signed-in user already own a
|
||||
// restaurant?" — kept behind the API (not a direct Supabase read from
|
||||
// mobile) so ownership resolution stays in one place (PRD §23, §33).
|
||||
export const meRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/me/restaurant", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from("restaurant_members")
|
||||
.select("restaurant_id")
|
||||
.eq("user_id", userId)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!membership) return reply.send({ restaurant: null });
|
||||
|
||||
const { data: restaurant } = await supabase
|
||||
.from("restaurants")
|
||||
.select("id, name, slug, locations(id, menus(id))")
|
||||
.eq("id", membership.restaurant_id)
|
||||
.single();
|
||||
|
||||
return reply.send({ restaurant });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { getCategoryIfMember, getMenuIfMember } from "../lib/access.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const createCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(500).nullish(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
const updateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(500).nullish(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const menuCategoriesRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/menus/:menuId/categories", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { menuId } = req.params as { menuId: string };
|
||||
const access = await getMenuIfMember(userId, menuId);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = createCategorySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menu_categories")
|
||||
.insert({
|
||||
menu_id: menuId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
sort_order: parsed.data.sortOrder,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to create category" });
|
||||
}
|
||||
|
||||
return reply.code(201).send(data);
|
||||
});
|
||||
|
||||
app.patch("/menu-categories/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getCategoryIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = updateCategorySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, description, sortOrder, isActive } = parsed.data;
|
||||
const { data, error } = await supabase
|
||||
.from("menu_categories")
|
||||
.update({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(sortOrder !== undefined ? { sort_order: sortOrder } : {}),
|
||||
...(isActive !== undefined ? { is_active: isActive } : {}),
|
||||
})
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update category" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.delete("/menu-categories/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getCategoryIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { error } = await supabase.from("menu_categories").delete().eq("id", id);
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete category" });
|
||||
}
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { getCategoryIfMember, getItemIfMember } from "../lib/access.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const createItemSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
description: z.string().max(1000).nullish(),
|
||||
price: z.number().nonnegative(),
|
||||
imageUrl: z.string().nullish(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
const updateItemSchema = z.object({
|
||||
name: z.string().min(1).max(160).optional(),
|
||||
description: z.string().max(1000).nullish(),
|
||||
price: z.number().nonnegative().optional(),
|
||||
imageUrl: z.string().nullish(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const menuItemsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/menu-categories/:categoryId/items", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { categoryId } = req.params as { categoryId: string };
|
||||
const access = await getCategoryIfMember(userId, categoryId);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = createItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menu_items")
|
||||
.insert({
|
||||
category_id: categoryId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
price: parsed.data.price,
|
||||
image_url: parsed.data.imageUrl ?? null,
|
||||
sort_order: parsed.data.sortOrder,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to create item" });
|
||||
}
|
||||
|
||||
return reply.code(201).send(data);
|
||||
});
|
||||
|
||||
app.patch("/menu-items/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getItemIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = updateItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, description, price, imageUrl, sortOrder, isActive } = parsed.data;
|
||||
const { data, error } = await supabase
|
||||
.from("menu_items")
|
||||
.update({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(price !== undefined ? { price } : {}),
|
||||
...(imageUrl !== undefined ? { image_url: imageUrl } : {}),
|
||||
...(sortOrder !== undefined ? { sort_order: sortOrder } : {}),
|
||||
...(isActive !== undefined ? { is_active: isActive } : {}),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update item" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.delete("/menu-items/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getItemIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { error } = await supabase.from("menu_items").delete().eq("id", id);
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete item" });
|
||||
}
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { getMenuIfMember } from "../lib/access.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const updateMenuSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
export const menusRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/menus/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { data: categories, error } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("*, menu_items(*)")
|
||||
.eq("menu_id", id)
|
||||
.order("sort_order", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to load menu" });
|
||||
}
|
||||
|
||||
return reply.send({ menu: access.menu, categories });
|
||||
});
|
||||
|
||||
app.patch("/menus/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = updateMenuSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menus")
|
||||
.update({ name: parsed.data.name, updated_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update menu" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.post("/menus/:id/publish", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menus")
|
||||
.update({ is_published: true, published_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to publish menu" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.post("/menus/:id/unpublish", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menus")
|
||||
.update({ is_published: false })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to unpublish menu" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
// PRD §7 — AI import lands here in Faz 2; V1 manual flow builds the same
|
||||
// categories/items shape by hand via the routes below.
|
||||
app.post("/menus/import", async (_req, reply) => {
|
||||
return reply.code(501).send({ message: "not implemented — Faz 2" });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { env } from "../env.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
// QR image encodes a stable /q/:id redirect, never the restaurant's current
|
||||
// domain directly — so republishing the menu or moving to a custom domain
|
||||
// never invalidates a printed QR code (PRD §12).
|
||||
export const qrRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/restaurants/:id/qr", 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 { data: restaurant, error: restaurantError } = await supabase
|
||||
.from("restaurants")
|
||||
.select("slug")
|
||||
.eq("id", restaurantId)
|
||||
.single();
|
||||
|
||||
if (restaurantError || !restaurant) {
|
||||
return reply.code(404).send({ message: "restaurant not found" });
|
||||
}
|
||||
|
||||
const webBaseUrl = env.PUBLIC_WEB_URL || "http://localhost:3000";
|
||||
const targetUrl = `${webBaseUrl}/menu/${restaurant.slug}`;
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from("qr_codes")
|
||||
.select("id")
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
await supabase
|
||||
.from("qr_codes")
|
||||
.update({ target_url: targetUrl })
|
||||
.eq("id", existing.id);
|
||||
} else {
|
||||
await supabase
|
||||
.from("qr_codes")
|
||||
.insert({ restaurant_id: restaurantId, target_url: targetUrl });
|
||||
}
|
||||
|
||||
// QR image directly encodes the clean restaurant name URL (e.g. /menu/kebapci-ahmet)
|
||||
const [pngBuffer, svg] = await Promise.all([
|
||||
QRCode.toBuffer(targetUrl, { type: "png", width: 1024, margin: 2 }),
|
||||
QRCode.toString(targetUrl, { type: "svg", margin: 2 }),
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
id: restaurant.slug,
|
||||
redirectUrl: targetUrl,
|
||||
targetUrl,
|
||||
pngBase64: pngBuffer.toString("base64"),
|
||||
svg,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { generateUniqueRestaurantSlug } from "../lib/slug.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const createRestaurantSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
logoUrl: z.string().nullish(),
|
||||
phone: z.string().max(30).nullish(),
|
||||
address: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
const updateRestaurantSchema = createRestaurantSchema.partial();
|
||||
|
||||
export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/restaurants", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const parsed = createRestaurantSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, logoUrl, phone, address } = parsed.data;
|
||||
const slug = await generateUniqueRestaurantSlug(name);
|
||||
|
||||
const { data: restaurant, error: restaurantError } = await supabase
|
||||
.from("restaurants")
|
||||
.insert({
|
||||
name,
|
||||
slug,
|
||||
logo_url: logoUrl ?? null,
|
||||
phone: phone ?? null,
|
||||
address: address ?? null,
|
||||
created_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (restaurantError || !restaurant) {
|
||||
req.log.error(restaurantError);
|
||||
return reply.code(500).send({ message: "failed to create restaurant" });
|
||||
}
|
||||
|
||||
const { error: memberError } = await supabase
|
||||
.from("restaurant_members")
|
||||
.insert({ restaurant_id: restaurant.id, user_id: userId, role: "owner" });
|
||||
|
||||
const { data: location, error: locationError } = await supabase
|
||||
.from("locations")
|
||||
.insert({ restaurant_id: restaurant.id, name: "Ana Şube" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (memberError || locationError || !location) {
|
||||
req.log.error(memberError ?? locationError);
|
||||
return reply.code(500).send({ message: "failed to finish restaurant setup" });
|
||||
}
|
||||
|
||||
const { data: menu, error: menuError } = await supabase
|
||||
.from("menus")
|
||||
.insert({ location_id: location.id, name: "Menüm" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (menuError || !menu) {
|
||||
req.log.error(menuError);
|
||||
return reply.code(500).send({ message: "failed to create default menu" });
|
||||
}
|
||||
|
||||
return reply.code(201).send({ restaurant, location, menu });
|
||||
});
|
||||
|
||||
app.get("/restaurants/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data, error } = await supabase.from("restaurants").select().eq("id", id).single();
|
||||
if (error || !data) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.patch("/restaurants/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const parsed = updateRestaurantSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, logoUrl, phone, address } = parsed.data;
|
||||
const { data, error } = await supabase
|
||||
.from("restaurants")
|
||||
.update({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(logoUrl !== undefined ? { logo_url: logoUrl } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update restaurant" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.get("/restaurants/:id/theme", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: themeRow } = await supabase
|
||||
.from("restaurant_themes")
|
||||
.select("theme_id, overrides, themes(key, name, config)")
|
||||
.eq("restaurant_id", id)
|
||||
.maybeSingle();
|
||||
|
||||
return reply.send({
|
||||
themeKey: (themeRow?.themes as unknown as { key?: string } | null)?.key ?? "elegant",
|
||||
overrides: themeRow?.overrides ?? {},
|
||||
});
|
||||
});
|
||||
|
||||
app.put("/restaurants/:id/theme", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { themeKey } = (req.body as { themeKey?: string }) ?? {};
|
||||
if (!themeKey) {
|
||||
return reply.code(400).send({ message: "themeKey is required" });
|
||||
}
|
||||
|
||||
const { data: themeData } = await supabase
|
||||
.from("themes")
|
||||
.select("id")
|
||||
.eq("key", themeKey)
|
||||
.maybeSingle();
|
||||
|
||||
if (!themeData) {
|
||||
return reply.code(404).send({ message: "Theme not found" });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("restaurant_themes").upsert(
|
||||
{
|
||||
restaurant_id: id,
|
||||
theme_id: themeData.id,
|
||||
},
|
||||
{ onConflict: "restaurant_id" },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update theme" });
|
||||
}
|
||||
|
||||
return reply.send({ success: true, themeKey });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
|
||||
export const subscriptionRoutes: FastifyPluginAsync = async (app) => {
|
||||
// RevenueCat webhook — backend is the source of truth for entitlement status,
|
||||
// mobile app local state is never trusted (see PRD §16).
|
||||
app.post("/subscription/webhook", async (_req, reply) => {
|
||||
return reply.code(501).send({ message: "not implemented" });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
EXPO_PUBLIC_SUPABASE_URL=
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3001
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"buildPath": "code"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
adaptive
|
||||
|
||||
## Users
|
||||
- **Primary Users**: Restaurant owners, cafe managers, and hospitality operators.
|
||||
- **Situation & Job**: Managing dining establishments and menu updates on-the-go directly from their smartphones. Key jobs include photographing physical paper menus, extracting items via AI, editing dishes/categories/pricing, styling public QR menus, generating table QR codes, and toggling item availability without needing a desktop admin panel.
|
||||
|
||||
## Product Purpose
|
||||
Menulio turns physical paper menus into high-quality, responsive digital QR menus in 2–3 minutes using AI vision extraction on mobile. It empowers independent restaurants to ditch clunky PDF QR codes and expensive agency retainers with a modern, mobile-only management experience.
|
||||
|
||||
## Positioning
|
||||
A mobile-first, AI-driven QR menu SaaS. While customers enjoy a fast, lightweight mobile web menu (zero app install required), restaurant owners manage 100% of their operations, menus, design templates, and QR assets directly inside a native mobile app.
|
||||
|
||||
## Operating Context
|
||||
- Fast-paced restaurant environments: dining rooms, kitchens, counters, and outdoor patios.
|
||||
- Capturing physical menus under mixed lighting (glare, low light, shadows, multi-page folds).
|
||||
- Frequent operational micro-tasks: 86ing sold-out items, adjusting prices, adding daily specials.
|
||||
- Exporting and sharing high-resolution QR assets for print stands, table stickers, and social media.
|
||||
|
||||
## Capabilities and Constraints
|
||||
- **Stack**: Expo (SDK 52, New Architecture, Expo Router v4, React Native 0.76), Supabase (Auth, DB, Storage), Fastify API backend.
|
||||
- **AI Extraction Flow**: Photo capture/upload → OCR & LLM extraction → Interactive Review step → Theme selection → Publish.
|
||||
- **Menu Hierarchy**: Multi-restaurant support, Categories, Items, Variants/Addons, Pricing, Badges (Spicy, Vegan, Chef Special, etc.), Availability flags.
|
||||
- **QR & Subdomains**: Dynamic unique subdomains per restaurant (`slug.menulio.com`) and customizable QR code downloads.
|
||||
- **Constraint**: Admin web panel is out of scope for V1; all management features must exist within the mobile app.
|
||||
|
||||
## Brand Commitments
|
||||
- **Name**: Menulio
|
||||
- **Voice & Tone**: Clean, efficient, trustworthy, modern hospitality tech.
|
||||
- **Design Standard**: Frictionless mobile ergonomics, tactile feedback, generous touch targets, clear typographic hierarchy.
|
||||
|
||||
## Evidence on Hand
|
||||
- PRD: [AI_QR_Menu_SaaS_PRD.md](file:///Users/ayrisdev/Github/menulio/docs/AI_QR_Menu_SaaS_PRD.md)
|
||||
- Roadmap: [ROADMAP.md](file:///Users/ayrisdev/Github/menulio/docs/ROADMAP.md)
|
||||
- Schema: `supabase/migrations/`
|
||||
- App entry & screens: `apps/mobile/src/app/`
|
||||
|
||||
## Product Principles
|
||||
1. **Three-Minute Value**: From opening the app to scanning a live, generated QR code must take under 3 minutes.
|
||||
2. **AI Suggests, Owner Decides**: AI extraction results are never auto-published without an explicit, effortless verification step.
|
||||
3. **True Mobile Ergonomics**: Every management screen is designed for one-handed thumb interaction with platform-native affordances and standard navigation stacks.
|
||||
4. **Zero Friction for Diners**: The customer experience requires zero app downloads, instant loads, and effortless filtering across dietary needs.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
- Adherence to platform touch targets (minimum 44×44pt on iOS, 48×48dp on Android).
|
||||
- Proper text scaling with Dynamic Type (iOS) / sp units (Android).
|
||||
- High contrast color pairs legible in both dim dining rooms and bright outdoor terraces.
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Menulio",
|
||||
"slug": "menulio",
|
||||
"scheme": "menulio",
|
||||
"version": "0.0.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#090d11",
|
||||
"imageWidth": 578
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.ayristech.menulio",
|
||||
"icon": "./assets/icon.png",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Menünüzün fotoğrafını çekerek AI ile dijitalleştirmek için kamera izni gereklidir.",
|
||||
"NSPhotoLibraryUsageDescription": "Menü fotoğraflarınızı ve restoran logonuzu yüklemek için galeri izni gereklidir.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.ayristech.menulio",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#090d11"
|
||||
},
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Menü fotoğraflarınızı ve restoran logonuzu yüklemek için galeri izni gereklidir.",
|
||||
"cameraPermission": "Menünüzün fotoğrafını çekerek AI ile dijitalleştirmek için kamera izni gereklidir."
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "10e58f58-0db6-4474-b948-e11c4a93b30e"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 886 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg width='1024' height='1024' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<linearGradient id='bg' x1='0%' y1='0%' x2='100%' y2='100%'>
|
||||
<stop offset='0%' stop-color='#1C1917'/>
|
||||
<stop offset='100%' stop-color='#0C0A09'/>
|
||||
</linearGradient>
|
||||
<linearGradient id='gold' x1='0%' y1='0%' x2='100%' y2='100%'>
|
||||
<stop offset='0%' stop-color='#FDE68A'/>
|
||||
<stop offset='50%' stop-color='#C8A96B'/>
|
||||
<stop offset='100%' stop-color='#926E27'/>
|
||||
</linearGradient>
|
||||
<filter id='glow' x='-20%' y='-20%' width='140%' height='140%'>
|
||||
<feGaussianBlur stdDeviation='20' result='blur'/>
|
||||
<feComposite in='SourceGraphic' in2='blur' operator='over'/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width='1024' height='1024' rx='220' fill='url(#bg)'/>
|
||||
<rect x='32' y='32' width='960' height='960' rx='190' fill='none' stroke='url(#gold)' stroke-width='6' opacity='0.3'/>
|
||||
|
||||
<!-- Central Monogram M with luxury geometry -->
|
||||
<g filter='url(#glow)' transform='translate(512, 512) scale(0.9)'>
|
||||
<!-- Sparkle -->
|
||||
<path d='M0,-240 L16,-190 L66,-174 L16,-158 L0,-108 L-16,-158 L-66,-174 L-16,-190 Z' fill='url(#gold)'/>
|
||||
<!-- Stylized M -->
|
||||
<path d='M-180,180 L-180,-100 L-60,50 L0,-20 L60,50 L180,-100 L180,180 L120,180 L120,0 L60,80 L0,0 L-60,80 L-120,0 L-120,180 Z' fill='url(#gold)'/>
|
||||
<!-- QR Corner accents -->
|
||||
<rect x='-240' y='-240' width='80' height='80' rx='16' fill='none' stroke='url(#gold)' stroke-width='16'/>
|
||||
<rect x='-220' y='-220' width='40' height='40' rx='8' fill='url(#gold)'/>
|
||||
|
||||
<rect x='160' y='-240' width='80' height='80' rx='16' fill='none' stroke='url(#gold)' stroke-width='16'/>
|
||||
<rect x='180' y='-220' width='40' height='40' rx='8' fill='url(#gold)'/>
|
||||
|
||||
<rect x='-240' y='160' width='80' height='80' rx='16' fill='none' stroke='url(#gold)' stroke-width='16'/>
|
||||
<rect x='-220' y='180' width='40' height='40' rx='8' fill='url(#gold)'/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 989 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 633 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 22.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
|
||||
// Find the project and workspace directories
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [monorepoRoot];
|
||||
|
||||
// 2. Let Metro know where to resolve packages and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(monorepoRoot, "node_modules"),
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@menulio/mobile",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
"@menulio/shared": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"expo": "~52.0.0",
|
||||
"expo-clipboard": "~7.0.1",
|
||||
"expo-font": "~13.0.4",
|
||||
"expo-haptics": "^57.0.1",
|
||||
"expo-image-picker": "~16.0.6",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-splash-screen": "~0.29.24",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.3",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"react-native-url-polyfill": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"babel-preset-expo": "~12.0.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from "react";
|
||||
import { Link, router } from "expo-router";
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({ email, password });
|
||||
setLoading(false);
|
||||
|
||||
if (signInError) {
|
||||
setError(signInError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 400, width: "100%", alignSelf: "center" }}>
|
||||
{/* Brand Icon Header */}
|
||||
<View style={{ alignItems: "center", marginBottom: 28 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "#1C1917",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 14,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="restaurant-outline" size={30} color="#C8A96B" />
|
||||
</View>
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", letterSpacing: -0.5 }}>
|
||||
Menulio'ya Giriş Yap
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", marginTop: 4 }}>
|
||||
AI destekli dijital restoran menü yöneticisi
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FCA5A5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||
<Text style={{ color: "#DC2626", fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Email Input */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="mail-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="E-posta adresiniz"
|
||||
placeholderTextColor="#A8A29E"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="lock-closed-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Şifreniz"
|
||||
placeholderTextColor="#A8A29E"
|
||||
secureTextEntry={!showPassword}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)} style={{ padding: 4 }}>
|
||||
<Ionicons
|
||||
name={showPassword ? "eye-off-outline" : "eye-outline"}
|
||||
size={18}
|
||||
color="#A8A29E"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Pressable
|
||||
onPress={onSubmit}
|
||||
disabled={loading || !email || !password}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: loading || !email || !password ? 0.6 : pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ color: "#fff", fontWeight: "700", fontSize: 15 }}>Giriş Yap</Text>
|
||||
<Ionicons name="arrow-forward" size={16} color="#fff" />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Link href="/(auth)/register" asChild>
|
||||
<Pressable style={{ marginTop: 16, padding: 8, alignItems: "center" }}>
|
||||
<Text style={{ color: "#78716C", fontSize: 14 }}>
|
||||
Hesabın yok mu? <Text style={{ color: "#C8A96B", fontWeight: "700" }}>Kayıt ol</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import { Link, router } from "expo-router";
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({ email, password });
|
||||
setLoading(false);
|
||||
|
||||
if (signUpError) {
|
||||
setError(signUpError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
setError("Kayıt başarılı! Lütfen e-postanızı onaylayıp giriş yapın.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 400, width: "100%", alignSelf: "center" }}>
|
||||
{/* Brand Icon Header */}
|
||||
<View style={{ alignItems: "center", marginBottom: 28 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "#1C1917",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 14,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="sparkles" size={28} color="#C8A96B" />
|
||||
</View>
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", letterSpacing: -0.5 }}>
|
||||
Hesap Oluştur
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", marginTop: 4 }}>
|
||||
Dakikalar içinde restoranınızı ve QR menünüzü hazırlayın
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FCA5A5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||
<Text style={{ color: "#DC2626", fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Email Input */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="mail-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="E-posta adresiniz"
|
||||
placeholderTextColor="#A8A29E"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="lock-closed-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Güçlü bir şifre belirleyin"
|
||||
placeholderTextColor="#A8A29E"
|
||||
secureTextEntry={!showPassword}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)} style={{ padding: 4 }}>
|
||||
<Ionicons
|
||||
name={showPassword ? "eye-off-outline" : "eye-outline"}
|
||||
size={18}
|
||||
color="#A8A29E"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Pressable
|
||||
onPress={onSubmit}
|
||||
disabled={loading || !email || !password}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: loading || !email || !password ? 0.6 : pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ color: "#fff", fontWeight: "700", fontSize: 15 }}>Kayıt Ol ve Başla</Text>
|
||||
<Ionicons name="sparkles" size={16} color="#C8A96B" />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable style={{ marginTop: 16, padding: 8, alignItems: "center" }}>
|
||||
<Text style={{ color: "#78716C", fontSize: 14 }}>
|
||||
Zaten hesabın var mı? <Text style={{ color: "#C8A96B", fontWeight: "700" }}>Giriş yap</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { aiStore } from "@/lib/ai-store";
|
||||
import type { AiExtractedCategory, AiExtractedItem } from "@menulio/shared";
|
||||
|
||||
export default function OnboardingAiReviewStep() {
|
||||
const [importData, setImportData] = useState(aiStore.getImportData());
|
||||
const [categories, setCategories] = useState<AiExtractedCategory[]>(importData?.categories ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
const [showAddCat, setShowAddCat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const data = aiStore.getImportData();
|
||||
if (!data) {
|
||||
router.replace("/(onboarding)/scan");
|
||||
return;
|
||||
}
|
||||
setImportData(data);
|
||||
setCategories(data.categories);
|
||||
}, []);
|
||||
|
||||
const totalItems = categories.reduce((acc, c) => acc + c.items.length, 0);
|
||||
const lowConfidenceItems = categories.reduce(
|
||||
(acc, c) => acc + c.items.filter((i) => i.confidence < 0.85).length,
|
||||
0,
|
||||
);
|
||||
|
||||
function updateItem(categoryId: string, itemId: string, field: "name" | "price" | "description", value: string) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
items: cat.items.map((itm) => {
|
||||
if (itm.id !== itemId) return itm;
|
||||
if (field === "price") {
|
||||
const parsed = Number(value.replace(",", "."));
|
||||
return { ...itm, price: Number.isNaN(parsed) ? 0 : parsed, confidence: 1 };
|
||||
}
|
||||
return { ...itm, [field]: value };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteItem(categoryId: string, itemId: string) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
items: cat.items.filter((i) => i.id !== itemId),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function addItem(categoryId: string) {
|
||||
const newItem: AiExtractedItem = {
|
||||
id: `custom-item-${Date.now()}`,
|
||||
name: "Yeni Ürün",
|
||||
price: 100,
|
||||
description: "",
|
||||
confidence: 1,
|
||||
};
|
||||
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return { ...cat, items: [...cat.items, newItem] };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteCategory(categoryId: string) {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== categoryId));
|
||||
}
|
||||
|
||||
function addCategory() {
|
||||
if (!newCatName.trim()) return;
|
||||
const newCat: AiExtractedCategory = {
|
||||
id: `custom-cat-${Date.now()}`,
|
||||
name: newCatName.trim(),
|
||||
items: [],
|
||||
};
|
||||
setCategories((prev) => [...prev, newCat]);
|
||||
setNewCatName("");
|
||||
setShowAddCat(false);
|
||||
}
|
||||
|
||||
async function onSaveAndContinue() {
|
||||
if (categories.length === 0 || totalItems === 0) {
|
||||
Alert.alert("Uyarı", "Lütfen en az bir kategori ve ürün ekleyin.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const active = await getActiveRestaurant();
|
||||
if (!active?.menuId) {
|
||||
throw new Error("Menü bulunamadı.");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
menuId: active.menuId,
|
||||
categories: categories.map((cat) => ({
|
||||
name: cat.name,
|
||||
items: cat.items.map((itm) => ({
|
||||
name: itm.name,
|
||||
description: itm.description || null,
|
||||
price: itm.price,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
await api.post(`/ai-imports/${importData?.importId ?? "new"}/apply`, payload);
|
||||
|
||||
aiStore.clear();
|
||||
Alert.alert("Başarılı 🎉", "Menünüz başarıyla aktarıldı!", [
|
||||
{ text: "Menüye Git", onPress: () => router.replace("/menu") },
|
||||
]);
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Menü kaydedilemedi.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5" }}>
|
||||
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 110 }}>
|
||||
{/* Header Badge */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
ADIM 3 / 4 • AI REVIEW & ONAY
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", marginBottom: 6 }}>
|
||||
Çıkarılan Menüyü İnceleyin
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", marginBottom: 20 }}>
|
||||
AI sonuçlarını kontrol edin, gerekiyorsa fiyat veya isimleri düzenleyin.
|
||||
</Text>
|
||||
|
||||
{/* Summary Banner */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.25)",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#1C1917" }}>
|
||||
{totalItems} Ürün • {categories.length} Kategori
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
Model: {importData?.model ?? "Menulio Vision"}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#ECFDF5",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||
<Text style={{ color: "#059669", fontSize: 12, fontWeight: "700" }}>Hazır</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{lowConfidenceItems > 0 ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#F5F5F4",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="warning-outline" size={16} color="#D97706" />
|
||||
<Text style={{ color: "#D97706", fontSize: 12, fontWeight: "600", flex: 1 }}>
|
||||
{lowConfidenceItems} ürünün fiyatı veya ismi silik çıkmış olabilir, lütfen sarı etiketli ürünleri kontrol edin.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Categories & Items List */}
|
||||
{categories.map((category) => (
|
||||
<View
|
||||
key={category.id}
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
{/* Category Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1.5,
|
||||
borderBottomColor: "#C8A96B40",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
||||
<Ionicons name="restaurant-outline" size={18} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#C8A96B" }}>
|
||||
{category.name} ({category.items.length})
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => deleteCategory(category.id)}
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={14} color="#EF4444" />
|
||||
<Text style={{ color: "#EF4444", fontSize: 12, fontWeight: "600" }}>Kategoriyi Sil</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Items in Category */}
|
||||
<View style={{ gap: 12 }}>
|
||||
{category.items.map((item) => {
|
||||
const isLowConfidence = item.confidence < 0.85;
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
style={{
|
||||
backgroundColor: isLowConfidence ? "#FFFBEB" : "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: isLowConfidence ? "#FDE68A" : "#F4F4F5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{isLowConfidence ? (
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#FEF3C7",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
marginBottom: 6,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="warning-outline" size={12} color="#B45309" />
|
||||
<Text style={{ color: "#B45309", fontSize: 11, fontWeight: "700" }}>
|
||||
Fiyatı / İsmi Kontrol Edin (%{Math.round(item.confidence * 100)} Güven)
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center", marginBottom: 6 }}>
|
||||
<TextInput
|
||||
value={item.name}
|
||||
onChangeText={(v) => updateItem(category.id, item.id, "name", v)}
|
||||
placeholder="Ürün Adı"
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: "#1C1917",
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<TextInput
|
||||
value={String(item.price)}
|
||||
onChangeText={(v) => updateItem(category.id, item.id, "price", v)}
|
||||
keyboardType="numeric"
|
||||
placeholder="Fiyat"
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: "800",
|
||||
color: "#C8A96B",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
minWidth: 64,
|
||||
textAlign: "right",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 14, fontWeight: "700", color: "#1C1917" }}>₺</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => deleteItem(category.id, item.id)}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="close-circle-outline" size={18} color="#9CA3AF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={item.description ?? ""}
|
||||
onChangeText={(v) => updateItem(category.id, item.id, "description", v)}
|
||||
placeholder="Açıklama (opsiyonel)"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#78716C",
|
||||
paddingVertical: 2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
<Pressable
|
||||
onPress={() => addItem(category.id)}
|
||||
style={{
|
||||
paddingVertical: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderStyle: "dashed",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={16} color="#78716C" />
|
||||
<Text style={{ color: "#78716C", fontSize: 13, fontWeight: "600" }}>
|
||||
Bu Kategoriye Ürün Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add New Category */}
|
||||
{showAddCat ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#C8A96B",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15, fontWeight: "700", color: "#1C1917", marginBottom: 8 }}>
|
||||
Yeni Kategori Ekle
|
||||
</Text>
|
||||
<TextInput
|
||||
value={newCatName}
|
||||
onChangeText={setNewCatName}
|
||||
placeholder="Örn: Başlangıçlar"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
<View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end" }}>
|
||||
<Pressable
|
||||
onPress={() => setShowAddCat(false)}
|
||||
style={{ paddingVertical: 8, paddingHorizontal: 14, borderRadius: 8 }}
|
||||
>
|
||||
<Text style={{ color: "#78716C", fontWeight: "600" }}>Vazgeç</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={addCategory}
|
||||
style={{
|
||||
backgroundColor: "#1C1917",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "700" }}>Ekle</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => setShowAddCat(true)}
|
||||
style={{
|
||||
paddingVertical: 14,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E7E5E4",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={18} color="#1C1917" />
|
||||
<Text style={{ color: "#1C1917", fontSize: 14, fontWeight: "700" }}>
|
||||
Yeni Kategori Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Bottom Action Bar */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#E7E5E4",
|
||||
padding: 16,
|
||||
paddingBottom: 28,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onSaveAndContinue}
|
||||
disabled={saving}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 14,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: pressed || saving ? 0.85 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
})}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle-outline" size={20} color="#FFFFFF" />
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 16, fontWeight: "700" }}>
|
||||
Menüyü Onayla ve Kaydet ({totalItems} Ürün)
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { setActiveRestaurant } from "@/lib/active-restaurant";
|
||||
|
||||
interface CreateRestaurantResponse {
|
||||
restaurant: { id: string; slug: string };
|
||||
location: { id: string };
|
||||
menu: { id: string };
|
||||
}
|
||||
|
||||
export default function OnboardingRestaurantStep() {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { restaurant, location, menu } = await api.post<CreateRestaurantResponse>("/restaurants", {
|
||||
name,
|
||||
phone: phone || undefined,
|
||||
address: address || undefined,
|
||||
});
|
||||
|
||||
await setActiveRestaurant({
|
||||
restaurantId: restaurant.id,
|
||||
locationId: location.id,
|
||||
menuId: menu.id,
|
||||
slug: restaurant.slug,
|
||||
});
|
||||
|
||||
router.replace("/(onboarding)/scan");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Bir hata oluştu");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 440, width: "100%", alignSelf: "center" }}>
|
||||
{/* Step Badge */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
ADIM 1 / 4 • RESTORAN BİLGİLERİ
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", marginBottom: 6 }}>
|
||||
Restoran Bilgilerini Ekle
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#78716C", marginBottom: 28, lineHeight: 20 }}>
|
||||
Sadece isim zorunludur. Telefon ve adres bilgilerinizi daha sonra da düzenleyebilirsiniz.
|
||||
</Text>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FCA5A5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||
<Text style={{ color: "#DC2626", fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Restaurant Name */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="storefront-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Restoran adı (Örn: Kebapçı Ahmet)"
|
||||
placeholderTextColor="#A8A29E"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Phone */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="call-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Telefon numarası (opsiyonel)"
|
||||
placeholderTextColor="#A8A29E"
|
||||
keyboardType="phone-pad"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Address */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="location-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Adres / Şehir (opsiyonel)"
|
||||
placeholderTextColor="#A8A29E"
|
||||
value={address}
|
||||
onChangeText={setAddress}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Submit */}
|
||||
<Pressable
|
||||
onPress={onSubmit}
|
||||
disabled={loading || !name.trim()}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: loading || !name.trim() ? 0.6 : pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ color: "#fff", fontWeight: "700", fontSize: 15 }}>Devam Et</Text>
|
||||
<Ionicons name="arrow-forward" size={16} color="#fff" />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { aiStore } from "@/lib/ai-store";
|
||||
import type { AiImportResponse } from "@menulio/shared";
|
||||
|
||||
export default function OnboardingScanStep() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [currentStage, setCurrentStage] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const stages = [
|
||||
{ title: "Fotoğraf okunuyor...", icon: "document-text-outline" as const },
|
||||
{ title: "Kategoriler ve başlıklar bulunuyor...", icon: "list-outline" as const },
|
||||
{ title: "Yemekler ve açıklamalar çıkarılıyor...", icon: "restaurant-outline" as const },
|
||||
{ title: "Fiyatlar ve para birimi algılanıyor...", icon: "pricetag-outline" as const },
|
||||
{ title: "Güven skorları hesaplanıyor...", icon: "shield-checkmark-outline" as const },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (analyzing) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentStage((prev) => (prev < stages.length - 1 ? prev + 1 : prev));
|
||||
}, 1200);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [analyzing, stages.length]);
|
||||
|
||||
async function processImage(base64: string) {
|
||||
setError(null);
|
||||
setAnalyzing(true);
|
||||
setCurrentStage(0);
|
||||
|
||||
try {
|
||||
const active = await getActiveRestaurant();
|
||||
if (!active?.menuId) {
|
||||
throw new Error("Aktif restoran veya menü bulunamadı.");
|
||||
}
|
||||
|
||||
const formattedImage = base64.startsWith("data:")
|
||||
? base64
|
||||
: `data:image/jpeg;base64,${base64}`;
|
||||
|
||||
const response = await api.post<AiImportResponse>(`/menus/${active.menuId}/import-ai`, {
|
||||
image: formattedImage,
|
||||
});
|
||||
|
||||
aiStore.setImportData(response);
|
||||
router.push("/(onboarding)/ai-review");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fotoğraf analiz edilirken bir hata oluştu.");
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function takePhoto() {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Menü fotoğrafı çekebilmek için kamera erişimine izin vermelisiniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.8,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]?.base64) {
|
||||
await processImage(result.assets[0].base64);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Kamera açılamadı.");
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFromGallery() {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Menü görseli seçebilmek için galeri erişimine izin vermelisiniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.8,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]?.base64) {
|
||||
await processImage(result.assets[0].base64);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Galeri açılamadı.");
|
||||
}
|
||||
}
|
||||
|
||||
if (analyzing) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", justifyContent: "center", alignItems: "center", padding: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
maxWidth: 360,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: "#FDF4E6",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="sparkles" size={32} color="#C8A96B" />
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 20, fontWeight: "700", color: "#1C1917", marginBottom: 6, textAlign: "center" }}>
|
||||
Menünüz Analiz Ediliyor
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", textAlign: "center", marginBottom: 24 }}>
|
||||
Menulio AI menünüzü dijitalleştiriyor...
|
||||
</Text>
|
||||
|
||||
<View style={{ width: "100%", gap: 14 }}>
|
||||
{stages.map((stage, idx) => {
|
||||
const isDone = idx < currentStage;
|
||||
const isCurrent = idx === currentStage;
|
||||
return (
|
||||
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: isDone ? "#ECFDF5" : isCurrent ? "#FDF4E6" : "#F5F5F4",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<Ionicons name="checkmark-circle" size={18} color="#10B981" />
|
||||
) : isCurrent ? (
|
||||
<ActivityIndicator size="small" color="#C8A96B" />
|
||||
) : (
|
||||
<Ionicons name={stage.icon} size={15} color="#A8A29E" />
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: isCurrent ? "700" : "500",
|
||||
color: isDone ? "#10B981" : isCurrent ? "#C8A96B" : "#A8A29E",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{stage.title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 440, width: "100%", alignSelf: "center" }}>
|
||||
{/* Step Badge */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
ADIM 2 / 4 • AI MENU SCANNER
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 28, fontWeight: "800", color: "#1C1917", marginBottom: 8, letterSpacing: -0.5 }}>
|
||||
Menü Fotoğrafını Yükleyin
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#78716C", lineHeight: 22, marginBottom: 32 }}>
|
||||
Basılı menünüzün fotoğrafını çekin veya galeriden seçin. Menulio AI saniyeler içinde tüm yemekleri, kategorileri ve fiyatları dijitalleştirsin.
|
||||
</Text>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FCA5A5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 20,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||
<Text style={{ color: "#DC2626", fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Primary CTA: Take Photo */}
|
||||
<Pressable
|
||||
onPress={takePhoto}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 16,
|
||||
padding: 18,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={22} color="#FFFFFF" />
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 16, fontWeight: "700" }}>
|
||||
Menü Fotoğrafı Çek
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Secondary CTA: Pick from Gallery */}
|
||||
<Pressable
|
||||
onPress={pickFromGallery}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
marginBottom: 24,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#1C1917" />
|
||||
<Text style={{ color: "#1C1917", fontSize: 15, fontWeight: "600" }}>
|
||||
Galeriden Seç
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Tertiary: Skip to manual */}
|
||||
<Pressable
|
||||
onPress={() => router.replace("/menu")}
|
||||
style={{ padding: 12, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 6 }}
|
||||
>
|
||||
<Ionicons name="create-outline" size={16} color="#78716C" />
|
||||
<Text style={{ color: "#78716C", fontSize: 14, fontWeight: "600", textDecorationLine: "underline" }}>
|
||||
Fotoğrafım yok, menüyü elle oluşturacağım →
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { Stack } from "expo-router";
|
||||
import { useFonts } from "expo-font";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
|
||||
SplashScreen.preventAutoHideAsync().catch(() => {});
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
Ionicons: require("@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Ionicons.ttf"),
|
||||
ionicons: require("@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/Ionicons.ttf"),
|
||||
...(Ionicons.font || {}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || error) {
|
||||
SplashScreen.hideAsync().catch(() => {});
|
||||
}
|
||||
}, [loaded, error]);
|
||||
|
||||
if (!loaded && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { api } from "@/lib/api";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getActiveRestaurant, clearActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { getPublicMenuUrl, getPublicMenuDisplayUrl } from "@/lib/urls";
|
||||
|
||||
interface RestaurantDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo_url: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
interface DomainRow {
|
||||
id: string;
|
||||
hostname: string;
|
||||
is_custom: boolean;
|
||||
status: "pending" | "verified" | "failed";
|
||||
verified_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ key: "elegant", name: "Elegant Gold", color: "#C8A96B", desc: "Lüks altın & ipeksi krem" },
|
||||
{ key: "modern", name: "Modern Sapphire", color: "#2563EB", desc: "Kraliyet safiri & beyaz" },
|
||||
{ key: "dark", name: "Luxury Dark", color: "#F59E0B", desc: "Kehribar & gece siyahı" },
|
||||
{ key: "minimal", name: "Nordic Minimal", color: "#18181B", desc: "Sade grafit & kar beyazı" },
|
||||
{ key: "classic", name: "Classic Bistro", color: "#8B1E1E", desc: "Toskana bordo & rustik" },
|
||||
];
|
||||
|
||||
export default function AccountScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [active, setActive] = useState<ActiveRestaurant | null>(null);
|
||||
const [userEmail, setUserEmail] = useState<string>("");
|
||||
const [userId, setUserId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Restaurant details state
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [slug, setSlug] = useState("");
|
||||
const [savingRest, setSavingRest] = useState(false);
|
||||
|
||||
// Theme state
|
||||
const [selectedTheme, setSelectedTheme] = useState("elegant");
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
// Custom Domain state
|
||||
const [domains, setDomains] = useState<DomainRow[]>([]);
|
||||
const [newDomainInput, setNewDomainInput] = useState("");
|
||||
const [cnameTarget, setCnameTarget] = useState("cname.menul.io");
|
||||
const [addingDomain, setAddingDomain] = useState(false);
|
||||
const [verifyingDomainId, setVerifyingDomainId] = useState<string | null>(null);
|
||||
|
||||
const [copiedSlug, setCopiedSlug] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
if (authData.user) {
|
||||
setUserEmail(authData.user.email ?? "");
|
||||
setUserId(authData.user.id);
|
||||
}
|
||||
|
||||
const cached = await getActiveRestaurant();
|
||||
if (!cached) {
|
||||
router.replace("/(onboarding)/restaurant");
|
||||
return;
|
||||
}
|
||||
setActive(cached);
|
||||
|
||||
// Fetch restaurant details
|
||||
const rest = await api.get<RestaurantDetail>(`/restaurants/${cached.restaurantId}`);
|
||||
setName(rest.name);
|
||||
setPhone(rest.phone ?? "");
|
||||
setAddress(rest.address ?? "");
|
||||
setLogoUrl(rest.logo_url ?? null);
|
||||
setSlug(rest.slug);
|
||||
|
||||
// Fetch theme
|
||||
const themeRes = await api.get<{ themeKey: string }>(`/restaurants/${cached.restaurantId}/theme`).catch(() => ({ themeKey: "elegant" }));
|
||||
setSelectedTheme(themeRes.themeKey || "elegant");
|
||||
|
||||
// Fetch custom domains
|
||||
const domainRes = await api.get<{ domains: DomainRow[]; cnameTarget: string }>(`/restaurants/${cached.restaurantId}/domains`).catch(() => null);
|
||||
if (domainRes?.domains) {
|
||||
setDomains(domainRes.domains);
|
||||
if (domainRes.cnameTarget) setCnameTarget(domainRes.cnameTarget);
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Hesap bilgileri yüklenemedi.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData]),
|
||||
);
|
||||
|
||||
async function handlePickLogo() {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Restoran logosu seçebilmek için fotoğraf galerisi izni gereklidir.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.6,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
const newLogo = asset.base64 ? `data:image/jpeg;base64,${asset.base64}` : asset.uri;
|
||||
setLogoUrl(newLogo);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRestaurant() {
|
||||
if (!active || !name.trim()) return;
|
||||
setSavingRest(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
try {
|
||||
await api.patch(`/restaurants/${active.restaurantId}`, {
|
||||
name: name.trim(),
|
||||
phone: phone.trim() || null,
|
||||
address: address.trim() || null,
|
||||
logoUrl: logoUrl || null,
|
||||
});
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
Alert.alert("Başarılı 🎉", "Restoran bilgileri ve logo güncellendi.");
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Güncellenemedi.");
|
||||
} finally {
|
||||
setSavingRest(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectTheme(themeKey: string) {
|
||||
if (!active) return;
|
||||
setSelectedTheme(themeKey);
|
||||
setSavingTheme(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
try {
|
||||
await api.put(`/restaurants/${active.restaurantId}/theme`, { themeKey });
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Tema güncellenemedi.");
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
}
|
||||
|
||||
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)");
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingDomain(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
try {
|
||||
const res = await api.post<{ domain: DomainRow }>(`/restaurants/${active.restaurantId}/domains`, {
|
||||
hostname: newDomainInput.trim(),
|
||||
});
|
||||
setDomains((prev) => [res.domain, ...prev]);
|
||||
setNewDomainInput("");
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
Alert.alert(
|
||||
"Alan Adı Eklendi 🎉",
|
||||
`Lütfen DNS sağlayıcınızda (GoDaddy, Cloudflare vb.) ${res.domain.hostname} için CNAME kaydını ${cnameTarget} adresine yönlendirin.`,
|
||||
);
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Alan adı eklenemedi.");
|
||||
} finally {
|
||||
setAddingDomain(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifyDomain(domainId: string) {
|
||||
if (!active) return;
|
||||
setVerifyingDomainId(domainId);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
try {
|
||||
const res = await api.post<{ verified: boolean; domain: DomainRow; message: string }>(
|
||||
`/restaurants/${active.restaurantId}/domains/${domainId}/verify`,
|
||||
);
|
||||
if (res.verified) {
|
||||
setDomains((prev) => prev.map((d) => (d.id === domainId ? res.domain : d)));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
Alert.alert("Tebrikler! 🎉", res.message);
|
||||
} else {
|
||||
setDomains((prev) => prev.map((d) => (d.id === domainId ? res.domain : d)));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {});
|
||||
Alert.alert("Doğrulanamadı", res.message);
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Doğrulama başarısız.");
|
||||
} finally {
|
||||
setVerifyingDomainId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDomain(domainId: string, hostname: string) {
|
||||
if (!active) return;
|
||||
Alert.alert(
|
||||
"Alan Adını Kaldır",
|
||||
`"${hostname}" alan adı restoranınızdan kaldırılacak. Emin misiniz?`,
|
||||
[
|
||||
{ text: "Vazgeç", style: "cancel" },
|
||||
{
|
||||
text: "Kaldır",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await api.delete(`/restaurants/${active.restaurantId}/domains/${domainId}`);
|
||||
setDomains((prev) => prev.filter((d) => d.id !== domainId));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Kaldırılamadı.");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCopyUrl() {
|
||||
const url = getPublicMenuUrl(slug);
|
||||
await Clipboard.setStringAsync(url);
|
||||
setCopiedSlug(true);
|
||||
setTimeout(() => setCopiedSlug(false), 2000);
|
||||
}
|
||||
|
||||
function handleOpenMenu() {
|
||||
const webUrl = getPublicMenuUrl(slug);
|
||||
Linking.openURL(webUrl).catch(() => {
|
||||
Alert.alert("Bilgi", `Menü adresi: ${webUrl}`);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSignOut() {
|
||||
Alert.alert(
|
||||
"Çıkış Yap",
|
||||
"Hesabınızdan çıkış yapmak istediğinize emin misiniz?",
|
||||
[
|
||||
{ text: "Vazgeç", style: "cancel" },
|
||||
{
|
||||
text: "Çıkış Yap",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await clearActiveRestaurant();
|
||||
await supabase.auth.signOut();
|
||||
router.replace("/(auth)/login");
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", alignItems: "center", justifyContent: "center" }}>
|
||||
<ActivityIndicator size="large" color="#C8A96B" />
|
||||
<Text style={{ marginTop: 12, color: "#78716C", fontSize: 14, fontWeight: "600" }}>
|
||||
Hesap bilgileri yükleniyor...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5" }}>
|
||||
{/* Top App Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingTop: Math.max(insets.top, 16),
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 14,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E7E5E4",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#FAF8F5",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={18} color="#1C1917" />
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 20, fontWeight: "800", color: "#1C1917" }}>
|
||||
Hesabım & Ayarlar
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSignOut}
|
||||
style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#FEE2E2",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#DC2626", fontSize: 12, fontWeight: "700" }}>Çıkış Yap</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 60, gap: 24 }}>
|
||||
{/* Restaurant Profile Card with Logo Picker */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
elevation: 2,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
|
||||
{/* Logo Avatar Picker */}
|
||||
<Pressable
|
||||
onPress={handlePickLogo}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "#1C1917",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
borderWidth: 2,
|
||||
borderColor: "#C8A96B",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{logoUrl ? (
|
||||
<Image source={{ uri: logoUrl }} style={{ width: "100%", height: "100%" }} resizeMode="cover" />
|
||||
) : (
|
||||
<Text style={{ color: "#C8A96B", fontSize: 24, fontWeight: "800" }}>
|
||||
{name.charAt(0).toUpperCase() || userEmail.charAt(0).toUpperCase() || "M"}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
alignItems: "center",
|
||||
paddingVertical: 2,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="camera" size={12} color="#FFFFFF" />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 17, fontWeight: "800", color: "#1C1917" }}>
|
||||
{name || "Restoran Yöneticisi"}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", marginTop: 2 }}>{userEmail}</Text>
|
||||
<Pressable
|
||||
onPress={handlePickLogo}
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#FDF8F0",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
marginTop: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.4)",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 11, fontWeight: "700" }}>
|
||||
📷 Logo Değiştir
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Live Menu URL & Quick Actions */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
|
||||
Dijital Menü Linki & Kısayollar
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="globe-outline" size={18} color="#C8A96B" style={{ marginRight: 8 }} />
|
||||
<Text numberOfLines={1} style={{ flex: 1, fontSize: 13, fontWeight: "600", color: "#1C1917" }}>
|
||||
{getPublicMenuDisplayUrl(slug)}
|
||||
</Text>
|
||||
<Pressable onPress={handleCopyUrl} style={{ paddingHorizontal: 8, paddingVertical: 4 }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: copiedSlug ? "#10B981" : "#C8A96B" }}>
|
||||
{copiedSlug ? "Kopyalandı!" : "Kopyala"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 10 }}>
|
||||
<Pressable
|
||||
onPress={() => router.push("/menu/qr")}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={16} color="#1C1917" />
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#1C1917" }}>QR Kod</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleOpenMenu}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="open-outline" size={16} color="#1C1917" />
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#1C1917" }}>Menüyü Aç</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Custom Domain 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="link-outline" size={20} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
|
||||
Özel Alan Adı (Custom Domain)
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", lineHeight: 18 }}>
|
||||
Menünüzü kendi web sitenizin alt alan adında (Örn: <Text style={{ fontWeight: "700" }}>menu.restoraniniz.com</Text>) yayınlayın.
|
||||
</Text>
|
||||
|
||||
{/* Add Domain Input */}
|
||||
<View style={{ gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
value={newDomainInput}
|
||||
onChangeText={setNewDomainInput}
|
||||
placeholder="Örn: menu.kebapciahmet.com"
|
||||
placeholderTextColor="#A8A29E"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#1C1917",
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleAddCustomDomain}
|
||||
disabled={addingDomain || !newDomainInput.trim()}
|
||||
style={{
|
||||
backgroundColor: !newDomainInput.trim() ? "#D6D3D1" : "#1C1917",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 12, fontWeight: "700" }}>
|
||||
{addingDomain ? "Ekleniyor..." : "Bağla"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Existing Domains List */}
|
||||
{domains.length > 0 ? (
|
||||
<View style={{ gap: 10, marginTop: 4 }}>
|
||||
{domains.map((dom) => {
|
||||
const isVerified = dom.status === "verified";
|
||||
const isChecking = verifyingDomainId === dom.id;
|
||||
return (
|
||||
<View
|
||||
key={dom.id}
|
||||
style={{
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: isVerified ? "#BBF7D0" : "#FDE68A",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "700", color: "#1C1917" }}>
|
||||
{dom.hostname}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, marginTop: 4 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: isVerified ? "#10B981" : "#F59E0B",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
color: isVerified ? "#059669" : "#D97706",
|
||||
}}
|
||||
>
|
||||
{isVerified ? "Yayında & Doğrulandı ✓" : "DNS Doğrulaması Bekleniyor"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 6 }}>
|
||||
{!isVerified ? (
|
||||
<Pressable
|
||||
onPress={() => handleVerifyDomain(dom.id)}
|
||||
disabled={isChecking}
|
||||
style={{
|
||||
backgroundColor: "#1C1917",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 11, fontWeight: "700" }}>
|
||||
{isChecking ? "Kontrol..." : "Doğrula"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleDeleteDomain(dom.id, dom.hostname)}
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={14} color="#DC2626" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CNAME instructions if pending */}
|
||||
{!isVerified ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: 10,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, fontWeight: "700", color: "#44403C" }}>
|
||||
DNS Kaydı Talimatı:
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, color: "#78716C", marginTop: 2 }}>
|
||||
Tür: <Text style={{ fontWeight: "700", color: "#1C1917" }}>CNAME</Text> | Hedef:{" "}
|
||||
<Text style={{ fontWeight: "700", color: "#C8A96B" }}>{cnameTarget}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Restaurant Details Form */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Ionicons name="business-outline" size={20} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
|
||||
Restoran Bilgileri
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Restaurant Name */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
|
||||
Restoran Adı
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Restoran Adı"
|
||||
style={{
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
color: "#1C1917",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Phone */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
|
||||
Telefon Numarası
|
||||
</Text>
|
||||
<TextInput
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholder="05xx xxx xx xx"
|
||||
keyboardType="phone-pad"
|
||||
style={{
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
color: "#1C1917",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Address */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
|
||||
Adres / Lokasyon
|
||||
</Text>
|
||||
<TextInput
|
||||
value={address}
|
||||
onChangeText={setAddress}
|
||||
placeholder="Restoran adresi"
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
color: "#1C1917",
|
||||
minHeight: 64,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSaveRestaurant}
|
||||
disabled={savingRest}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
opacity: pressed || savingRest ? 0.85 : 1,
|
||||
marginTop: 4,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 14, fontWeight: "700" }}>
|
||||
{savingRest ? "Kaydediliyor..." : "Restoran Bilgilerini Güncelle"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Theme Selector */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Ionicons name="color-palette-outline" size={20} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
|
||||
Menü Şablonu (Tema)
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, color: "#78716C" }}>
|
||||
Restoranınızın konseptine uygun tasarımı seçin. Seçtiğiniz tema QR menünüzde anında aktif olur.
|
||||
</Text>
|
||||
|
||||
<View style={{ gap: 10 }}>
|
||||
{THEME_OPTIONS.map((theme) => {
|
||||
const isSelected = selectedTheme === theme.key;
|
||||
return (
|
||||
<Pressable
|
||||
key={theme.key}
|
||||
onPress={() => handleSelectTheme(theme.key)}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: isSelected ? "#FAF8F5" : "#FAFAFA",
|
||||
borderWidth: 2,
|
||||
borderColor: isSelected ? theme.color : "#E7E5E4",
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.color,
|
||||
}}
|
||||
/>
|
||||
<View>
|
||||
<Text style={{ fontSize: 14, fontWeight: "700", color: "#1C1917" }}>
|
||||
{theme.name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, color: "#78716C", marginTop: 2 }}>
|
||||
{theme.desc}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
borderColor: isSelected ? theme.color : "#D6D3D1",
|
||||
backgroundColor: isSelected ? theme.color : "transparent",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isSelected ? <Ionicons name="checkmark" size={14} color="#FFFFFF" /> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant, setActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
interface MeRestaurantResponse {
|
||||
restaurant: {
|
||||
id: string;
|
||||
slug: string;
|
||||
locations: { id: string; menus: { id: string }[] }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function resolve() {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (!data.session) {
|
||||
router.replace("/(auth)/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await getActiveRestaurant();
|
||||
if (cached) {
|
||||
router.replace("/menu");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { restaurant } = await api.get<MeRestaurantResponse>("/me/restaurant");
|
||||
const location = restaurant?.locations[0];
|
||||
const menu = location?.menus[0];
|
||||
|
||||
if (restaurant && location && menu) {
|
||||
await setActiveRestaurant({
|
||||
restaurantId: restaurant.id,
|
||||
locationId: location.id,
|
||||
menuId: menu.id,
|
||||
slug: restaurant.slug,
|
||||
});
|
||||
if (!cancelled) router.replace("/menu");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to onboarding
|
||||
}
|
||||
|
||||
if (!cancelled) router.replace("/(onboarding)/restaurant");
|
||||
}
|
||||
|
||||
resolve().finally(() => {
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
||||
{checking ? <ActivityIndicator /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { getPublicMenuUrl, getPublicMenuDisplayUrl } from "@/lib/urls";
|
||||
import { CategoryPillBar } from "@/components/menu/CategoryPillBar";
|
||||
import { MenuItemCard, type MenuItemData } from "@/components/menu/MenuItemCard";
|
||||
import { ItemDetailSheet } from "@/components/menu/ItemDetailSheet";
|
||||
import { AddItemModal } from "@/components/menu/AddItemModal";
|
||||
import { AddCategoryModal } from "@/components/menu/AddCategoryModal";
|
||||
import { PublishActionBar } from "@/components/menu/PublishActionBar";
|
||||
import { ThemeSelectorSheet } from "@/components/menu/ThemeSelectorSheet";
|
||||
|
||||
interface MenuCategoryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
menu_items: MenuItemData[];
|
||||
}
|
||||
|
||||
interface MenuResponse {
|
||||
menu: { id: string; name: string; is_published: boolean };
|
||||
categories: MenuCategoryRow[];
|
||||
}
|
||||
|
||||
export default function MenuEditorScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const categoryPositionsRef = useRef<Record<string, number>>({});
|
||||
|
||||
const [active, setActive] = useState<ActiveRestaurant | null>(null);
|
||||
const [menu, setMenu] = useState<MenuResponse["menu"] | null>(null);
|
||||
const [categories, setCategories] = useState<MenuCategoryRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
|
||||
// Modals state
|
||||
const [editingItem, setEditingItem] = useState<MenuItemData | null>(null);
|
||||
const [addItemCategoryId, setAddItemCategoryId] = useState<string | null>(null);
|
||||
const [addCategoryVisible, setAddCategoryVisible] = useState(false);
|
||||
const [categoryToRename, setCategoryToRename] = useState<MenuCategoryRow | null>(null);
|
||||
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true);
|
||||
try {
|
||||
const cached = await getActiveRestaurant();
|
||||
if (!cached) {
|
||||
router.replace("/(onboarding)/restaurant");
|
||||
return;
|
||||
}
|
||||
setActive(cached);
|
||||
|
||||
const data = await api.get<MenuResponse>(`/menus/${cached.menuId}`);
|
||||
setMenu(data.menu);
|
||||
const fetchedCats = (data.categories || []).map((c) => ({
|
||||
...c,
|
||||
menu_items: (c.menu_items || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description ?? null,
|
||||
price: item.price,
|
||||
is_active: item.is_active ?? true,
|
||||
})),
|
||||
}));
|
||||
setCategories(fetchedCats);
|
||||
if (fetchedCats.length > 0 && !activeCategoryId) {
|
||||
setActiveCategoryId(fetchedCats[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Menü yüklenemedi.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [activeCategoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
const totalItems = categories.reduce((acc, c) => acc + c.menu_items.length, 0);
|
||||
|
||||
// Category selection & scroll jumping
|
||||
function scrollToCategory(categoryId: string) {
|
||||
setActiveCategoryId(categoryId);
|
||||
const posY = categoryPositionsRef.current[categoryId];
|
||||
if (posY !== undefined && scrollViewRef.current) {
|
||||
scrollViewRef.current.scrollTo({ y: Math.max(0, posY - 70), animated: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Add Category
|
||||
async function handleAddCategory(name: string) {
|
||||
if (!active) return;
|
||||
const category = await api.post<MenuCategoryRow>(`/menus/${active.menuId}/categories`, {
|
||||
name,
|
||||
sortOrder: categories.length,
|
||||
});
|
||||
setCategories((prev) => [...prev, { ...category, menu_items: [] }]);
|
||||
setActiveCategoryId(category.id);
|
||||
}
|
||||
|
||||
// Rename Category
|
||||
async function handleRenameCategory(name: string) {
|
||||
if (!categoryToRename) return;
|
||||
await api.patch(`/menu-categories/${categoryToRename.id}`, { name });
|
||||
setCategories((prev) =>
|
||||
prev.map((c) => (c.id === categoryToRename.id ? { ...c, name } : c)),
|
||||
);
|
||||
setCategoryToRename(null);
|
||||
}
|
||||
|
||||
// Delete Category
|
||||
function confirmDeleteCategory(category: MenuCategoryRow) {
|
||||
Alert.alert(
|
||||
"Kategoriyi Sil",
|
||||
`"${category.name}" kategorisi ve içindeki tüm ürünler silinecek. Emin misiniz?`,
|
||||
[
|
||||
{ text: "Vazgeç", style: "cancel" },
|
||||
{
|
||||
text: "Sil",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await api.delete(`/menu-categories/${category.id}`);
|
||||
setCategories((prev) => prev.filter((c) => c.id !== category.id));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {});
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Kategori silinemedi.");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Add Item
|
||||
async function handleAddItem(
|
||||
categoryId: string,
|
||||
name: string,
|
||||
price: number,
|
||||
description?: string,
|
||||
imageUrl?: string,
|
||||
) {
|
||||
const newItem = await api.post<MenuItemData>(`/menu-categories/${categoryId}/items`, {
|
||||
name,
|
||||
price,
|
||||
description: description || null,
|
||||
imageUrl: imageUrl || null,
|
||||
});
|
||||
setCategories((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === categoryId
|
||||
? { ...c, menu_items: [...c.menu_items, { ...newItem, is_active: true }] }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle Item Availability (86 / In Stock)
|
||||
async function handleToggleItemActive(categoryId: string, itemId: string, isActive: boolean) {
|
||||
// Optimistic update
|
||||
setCategories((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === categoryId
|
||||
? {
|
||||
...c,
|
||||
menu_items: c.menu_items.map((i) =>
|
||||
i.id === itemId ? { ...i, is_active: isActive } : i,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await api.patch(`/menu-items/${itemId}`, { isActive });
|
||||
} catch (err) {
|
||||
// Revert if error
|
||||
setCategories((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === categoryId
|
||||
? {
|
||||
...c,
|
||||
menu_items: c.menu_items.map((i) =>
|
||||
i.id === itemId ? { ...i, is_active: !isActive } : i,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
Alert.alert("Hata", "Ürün durumu güncellenemedi.");
|
||||
}
|
||||
}
|
||||
|
||||
// Save Item Details
|
||||
async function handleSaveItemDetails(updatedItem: MenuItemData) {
|
||||
await api.patch(`/menu-items/${updatedItem.id}`, {
|
||||
name: updatedItem.name,
|
||||
price: updatedItem.price,
|
||||
description: updatedItem.description || null,
|
||||
imageUrl: updatedItem.image_url ?? null,
|
||||
isActive: updatedItem.is_active,
|
||||
});
|
||||
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => ({
|
||||
...cat,
|
||||
menu_items: cat.menu_items.map((i) =>
|
||||
i.id === updatedItem.id ? updatedItem : i,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete Item
|
||||
async function handleDeleteItem(itemId: string) {
|
||||
await api.delete(`/menu-items/${itemId}`);
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => ({
|
||||
...cat,
|
||||
menu_items: cat.menu_items.filter((i) => i.id !== itemId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Publish
|
||||
async function onPublish() {
|
||||
if (!active) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
await api.post(`/menus/${active.menuId}/publish`);
|
||||
setMenu((prev) => (prev ? { ...prev, is_published: true } : prev));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
Alert.alert(
|
||||
"Menünüz Yayında! 🎉",
|
||||
`Menünüz ${getPublicMenuUrl(active.slug)} adresinde güncellendi.`,
|
||||
[
|
||||
{ text: "Kapat", style: "cancel" },
|
||||
{ text: "QR Kodu Görüntüle", onPress: () => router.push("/menu/qr") },
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Yayınlanamadı.");
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", alignItems: "center", justifyContent: "center" }}>
|
||||
<ActivityIndicator size="large" color="#C8A96B" />
|
||||
<Text style={{ marginTop: 12, color: "#78716C", fontSize: 14, fontWeight: "600" }}>
|
||||
Menü yükleniyor...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isPublished = menu?.is_published ?? false;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5" }}>
|
||||
{/* Top Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingTop: Math.max(insets.top, 16),
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<View style={{ flex: 1, marginRight: 12 }}>
|
||||
<Text numberOfLines={1} style={{ fontSize: 22, fontWeight: "800", color: "#1C1917" }}>
|
||||
{menu?.name ?? (active?.slug ? `${active.slug} Menüsü` : "Menü")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
{active?.slug ? getPublicMenuDisplayUrl(active.slug) : "Menü Yönetimi"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Status Badge & QR shortcut */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
backgroundColor: isPublished ? "#ECFDF5" : "#F5F5F4",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: isPublished ? "#A7F3D0" : "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
backgroundColor: isPublished ? "#059669" : "#A8A29E",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
color: isPublished ? "#059669" : "#78716C",
|
||||
}}
|
||||
>
|
||||
{isPublished ? "Yayında" : "Taslak"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setThemeSheetVisible(true)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#FDF8F0",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.5)",
|
||||
}}
|
||||
accessibilityLabel="Şablon & Tema Seç"
|
||||
>
|
||||
<Ionicons name="color-palette-outline" size={18} color="#926E27" />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.push("/menu/qr")}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#F5F5F4",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
accessibilityLabel="QR Kod"
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={18} color="#1C1917" />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.push("/account")}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#FAF8F5",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.4)",
|
||||
}}
|
||||
accessibilityLabel="Hesabım & Ayarlar"
|
||||
>
|
||||
<Ionicons name="person-outline" size={18} color="#C8A96B" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Category Pill Bar */}
|
||||
<CategoryPillBar
|
||||
categories={categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
count: c.menu_items.length,
|
||||
}))}
|
||||
activeCategoryId={activeCategoryId}
|
||||
onSelectCategory={scrollToCategory}
|
||||
onAddCategoryPress={() => setAddCategoryVisible(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 110 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* AI Scanner Banner */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/(onboarding)/scan");
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#FBF7EE",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "rgba(200, 169, 107, 0.4)",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
opacity: pressed ? 0.88 : 1,
|
||||
shadowColor: "#C8A96B",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
})}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#F3EDE2",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="sparkles" size={22} color="#926E27" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 15, fontWeight: "700", color: "#1C1917" }}>
|
||||
AI ile Menü Fotoğrafı Tara
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
Fotoğraftan otomatik ürün & kategori ekle
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="arrow-forward" size={18} color="#C8A96B" />
|
||||
</Pressable>
|
||||
|
||||
{/* Empty State when no categories exist */}
|
||||
{categories.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 32,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "#F5F5F4",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="restaurant-outline" size={30} color="#78716C" />
|
||||
</View>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#1C1917", marginBottom: 6 }}>
|
||||
Menünüz Henüz Boş
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", textAlign: "center", marginBottom: 20 }}>
|
||||
Menü fotoğrafınızı tarayarak saniyeler içinde otomatik doldurabilir veya manuel olarak kategori ekleyebilirsiniz.
|
||||
</Text>
|
||||
|
||||
<View style={{ width: "100%", gap: 10 }}>
|
||||
<Pressable
|
||||
onPress={() => router.push("/(onboarding)/scan")}
|
||||
style={{
|
||||
backgroundColor: "#1C1917",
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "700", fontSize: 14 }}>
|
||||
✨ Fotoğraf Çek / Tara
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setAddCategoryVisible(true)}
|
||||
style={{
|
||||
backgroundColor: "#F5F5F4",
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#1C1917", fontWeight: "700", fontSize: 14 }}>
|
||||
+ Manuel Kategori Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Categories and Items */}
|
||||
{categories.map((category) => (
|
||||
<View
|
||||
key={category.id}
|
||||
onLayout={(event) => {
|
||||
const layout = event.nativeEvent.layout;
|
||||
categoryPositionsRef.current[category.id] = layout.y;
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
marginBottom: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
}}
|
||||
>
|
||||
{/* Category Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F5F5F4",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "800", color: "#1C1917" }}>
|
||||
{category.name}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#F5F5F4",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C" }}>
|
||||
{category.menu_items.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Category Actions */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setAddItemCategoryId(category.id);
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="add" size={16} color="#926E27" />
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#926E27" }}>
|
||||
Ürün Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setCategoryToRename(category)}
|
||||
style={{ padding: 6, borderRadius: 6 }}
|
||||
>
|
||||
<Ionicons name="pencil-outline" size={16} color="#78716C" />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => confirmDeleteCategory(category)}
|
||||
style={{ padding: 6, borderRadius: 6 }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color="#DC2626" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Category Items */}
|
||||
{category.menu_items.length === 0 ? (
|
||||
<Pressable
|
||||
onPress={() => setAddItemCategoryId(category.id)}
|
||||
style={{
|
||||
paddingVertical: 20,
|
||||
alignItems: "center",
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "#FAFAFA",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={24} color="#A8A29E" />
|
||||
<Text style={{ color: "#78716C", fontSize: 13, fontWeight: "600", marginTop: 4 }}>
|
||||
Bu kategoriye ilk ürünü ekleyin
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
category.menu_items.map((item) => (
|
||||
<MenuItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPress={() => setEditingItem(item)}
|
||||
onToggleActive={(isActive) =>
|
||||
handleToggleItemActive(category.id, item.id, isActive)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Another Category Button */}
|
||||
{categories.length > 0 ? (
|
||||
<Pressable
|
||||
onPress={() => setAddCategoryVisible(true)}
|
||||
style={({ pressed }) => ({
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
borderRadius: 14,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E7E5E4",
|
||||
borderStyle: "dashed",
|
||||
marginBottom: 16,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: "#1C1917", fontSize: 14, fontWeight: "700" }}>
|
||||
+ Yeni Kategori Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Bottom Action Bar */}
|
||||
<PublishActionBar
|
||||
isPublished={isPublished}
|
||||
totalItems={totalItems}
|
||||
totalCategories={categories.length}
|
||||
publishing={publishing}
|
||||
onPublish={onPublish}
|
||||
onPreview={() => {
|
||||
if (active?.slug) {
|
||||
const url = getPublicMenuUrl(active.slug);
|
||||
Linking.openURL(url).catch(() => {
|
||||
Alert.alert("Önizleme Linki", url);
|
||||
});
|
||||
} else {
|
||||
router.push("/menu/qr");
|
||||
}
|
||||
}}
|
||||
onShowQr={() => router.push("/menu/qr")}
|
||||
/>
|
||||
|
||||
{/* Item Detail Sheet */}
|
||||
<ItemDetailSheet
|
||||
item={editingItem}
|
||||
visible={!!editingItem}
|
||||
onClose={() => setEditingItem(null)}
|
||||
onSave={handleSaveItemDetails}
|
||||
onDelete={handleDeleteItem}
|
||||
/>
|
||||
|
||||
{/* Add Item Modal */}
|
||||
{addItemCategoryId ? (
|
||||
<AddItemModal
|
||||
visible={!!addItemCategoryId}
|
||||
categoryId={addItemCategoryId}
|
||||
categoryName={
|
||||
categories.find((c) => c.id === addItemCategoryId)?.name ?? "Kategori"
|
||||
}
|
||||
onClose={() => setAddItemCategoryId(null)}
|
||||
onAdd={handleAddItem}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Add Category Modal */}
|
||||
<AddCategoryModal
|
||||
visible={addCategoryVisible}
|
||||
onClose={() => setAddCategoryVisible(false)}
|
||||
onSubmit={handleAddCategory}
|
||||
/>
|
||||
|
||||
{/* Rename Category Modal */}
|
||||
{categoryToRename ? (
|
||||
<AddCategoryModal
|
||||
visible={!!categoryToRename}
|
||||
initialName={categoryToRename.name}
|
||||
isEditing
|
||||
onClose={() => setCategoryToRename(null)}
|
||||
onSubmit={handleRenameCategory}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Theme Selector Sheet */}
|
||||
<ThemeSelectorSheet
|
||||
visible={themeSheetVisible}
|
||||
restaurantId={active?.restaurantId ?? ""}
|
||||
restaurantSlug={active?.slug}
|
||||
onClose={() => setThemeSheetVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Share,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant";
|
||||
|
||||
interface QrResponse {
|
||||
id: string;
|
||||
redirectUrl: string;
|
||||
targetUrl: string;
|
||||
pngBase64: string;
|
||||
}
|
||||
|
||||
export default function QrScreen() {
|
||||
const [active, setActive] = useState<ActiveRestaurant | null>(null);
|
||||
const [qr, setQr] = useState<QrResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const activeRest = await getActiveRestaurant();
|
||||
if (!activeRest) return;
|
||||
setActive(activeRest);
|
||||
|
||||
try {
|
||||
const data = await api.post<QrResponse>(`/restaurants/${activeRest.restaurantId}/qr`);
|
||||
setQr(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "QR oluşturulamadı");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!qr?.targetUrl) return;
|
||||
await Clipboard.setStringAsync(qr.targetUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
if (!qr?.targetUrl) return;
|
||||
await Share.share({
|
||||
title: "Dijital QR Menü",
|
||||
message: `${qr.targetUrl}\nMenümüzü incelemek için QR kodu taratın veya linke tıklayın.`,
|
||||
url: qr.targetUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", alignItems: "center", justifyContent: "center" }}>
|
||||
<ActivityIndicator size="large" color="#C8A96B" />
|
||||
<Text style={{ marginTop: 12, color: "#78716C", fontSize: 14 }}>QR Kod Hazırlanıyor...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !qr) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||||
<Ionicons name="alert-circle-outline" size={48} color="#DC2626" style={{ marginBottom: 12 }} />
|
||||
<Text style={{ color: "#DC2626", fontSize: 16, fontWeight: "600", textAlign: "center" }}>
|
||||
{error ?? "QR kod yüklenemedi"}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
style={{ marginTop: 20, backgroundColor: "#1C1917", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 }}
|
||||
>
|
||||
<Text style={{ color: "#FFF", fontWeight: "600" }}>Geri Dön</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 440, width: "100%", alignSelf: "center", alignItems: "center" }}>
|
||||
{/* Header Badge */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={15} color="#926E27" />
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
DİJİTAL RESTORAN QR KODU
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", marginBottom: 6, textAlign: "center" }}>
|
||||
Masanıza Özel QR Kod
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#78716C", textAlign: "center", marginBottom: 28, lineHeight: 20 }}>
|
||||
Müşterileriniz bu QR kodu telefon kameralarıyla taratarak saniyeler içinde menünüzü inceleyebilir.
|
||||
</Text>
|
||||
|
||||
{/* QR Code Container Card */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.25)",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: `data:image/png;base64,${qr.pngBase64}` }}
|
||||
style={{ width: 220, height: 220, borderRadius: 12 }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 13, fontWeight: "600", color: "#1C1917" }}>
|
||||
{qr.targetUrl}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={{ width: "100%", gap: 12 }}>
|
||||
{/* Share Button */}
|
||||
<Pressable
|
||||
onPress={handleShare}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={18} color="#FFFFFF" />
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
|
||||
QR Kodu ve Linki Paylaş
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Copy Link Button */}
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 14,
|
||||
padding: 15,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons
|
||||
name={copied ? "checkmark-circle-outline" : "copy-outline"}
|
||||
size={18}
|
||||
color={copied ? "#10B981" : "#1C1917"}
|
||||
/>
|
||||
<Text style={{ color: copied ? "#10B981" : "#1C1917", fontSize: 14, fontWeight: "600" }}>
|
||||
{copied ? "Link Kopyalandı!" : "Menü Linkini Kopyala"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Back to Menu Editor */}
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
style={{ padding: 12, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 6 }}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={16} color="#78716C" />
|
||||
<Text style={{ color: "#78716C", fontSize: 14, fontWeight: "600" }}>
|
||||
Menü Editörüne Dön
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface AddCategoryModalProps {
|
||||
visible: boolean;
|
||||
initialName?: string;
|
||||
isEditing?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AddCategoryModal({
|
||||
visible,
|
||||
initialName = "",
|
||||
isEditing = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddCategoryModalProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(initialName);
|
||||
}, [initialName, visible]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Eksik Bilgi", "Lütfen kategori adını giriniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(name.trim());
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "İşlem başarısız oldu.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#1C1917" }}>
|
||||
{isEditing ? "Kategoriyi Yeniden Adlandır" : "Yeni Kategori Ekle"}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 4 }}>
|
||||
<Ionicons name="close" size={22} color="#78716C" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Örn: Tatlılar, Başlangıçlar, Sıcak İçecekler"
|
||||
placeholderTextColor="#A8A29E"
|
||||
autoFocus
|
||||
style={{
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#1C1917",
|
||||
marginBottom: 18,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 10, justifyContent: "flex-end" }}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#F5F5F4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#78716C", fontWeight: "600", fontSize: 14 }}>Vazgeç</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSubmit}
|
||||
disabled={submitting || !name.trim()}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: !name.trim() ? "#D6D3D1" : "#1C1917",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 10,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "700", fontSize: 14 }}>
|
||||
{submitting ? "Kaydediliyor..." : isEditing ? "Güncelle" : "Ekle"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
|
||||
interface AddItemModalProps {
|
||||
visible: boolean;
|
||||
categoryName: string;
|
||||
categoryId: string;
|
||||
onClose: () => void;
|
||||
onAdd: (categoryId: string, name: string, price: number, description?: string, imageUrl?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AddItemModal({
|
||||
visible,
|
||||
categoryName,
|
||||
categoryId,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: AddItemModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
async function handlePickImage() {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Ürün görseli seçebilmek için fotoğraf galerisi erişim izni gereklidir.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.6,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
if (asset.base64) {
|
||||
setImageUrl(`data:image/jpeg;base64,${asset.base64}`);
|
||||
} else {
|
||||
setImageUrl(asset.uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const parsedPrice = Number(price.replace(",", "."));
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Eksik Bilgi", "Lütfen ürün adını giriniz.");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(parsedPrice) || parsedPrice < 0) {
|
||||
Alert.alert("Geçersiz Fiyat", "Lütfen geçerli bir fiyat giriniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
try {
|
||||
await onAdd(
|
||||
categoryId,
|
||||
name.trim(),
|
||||
parsedPrice,
|
||||
description.trim() || undefined,
|
||||
imageUrl || undefined,
|
||||
);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
setName("");
|
||||
setPrice("");
|
||||
setDescription("");
|
||||
setImageUrl(null);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Ürün eklenemedi.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" }}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "88%",
|
||||
paddingBottom: Platform.OS === "ios" ? 34 : 20,
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: "center", paddingTop: 10, paddingBottom: 6 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "#E7E5E4",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F5F5F4",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#1C1917" }}>
|
||||
Yeni Ürün Ekle
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
Kategori: <Text style={{ fontWeight: "700", color: "#C8A96B" }}>{categoryName}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#F5F5F4",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="#78716C" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
{/* Image Picker */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 8 }}>
|
||||
Ürün Fotoğrafı (Opsiyonel)
|
||||
</Text>
|
||||
{imageUrl ? (
|
||||
<View style={{ position: "relative", width: "100%", height: 160, borderRadius: 14, overflow: "hidden", borderWidth: 1, borderColor: "#E7E5E4" }}>
|
||||
<Image source={{ uri: imageUrl }} style={{ width: "100%", height: 160 }} resizeMode="cover" />
|
||||
<Pressable
|
||||
onPress={() => setImageUrl(null)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
backgroundColor: "rgba(0,0,0,0.65)",
|
||||
borderRadius: 14,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={handlePickImage}
|
||||
style={({ pressed }) => ({
|
||||
height: 100,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1.5,
|
||||
borderStyle: "dashed",
|
||||
borderColor: "#D6D3D1",
|
||||
backgroundColor: "#FAF8F5",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={26} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 13, fontWeight: "600", color: "#78716C" }}>
|
||||
Galeriden Fotoğraf Seç
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 6 }}>
|
||||
Ürün Adı *
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Örn: Mercimek Çorbası"
|
||||
placeholderTextColor="#A8A29E"
|
||||
style={{
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#1C1917",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 6 }}>
|
||||
Fiyat (₺) *
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
value={price}
|
||||
onChangeText={setPrice}
|
||||
placeholder="120"
|
||||
placeholderTextColor="#A8A29E"
|
||||
keyboardType="decimal-pad"
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: "#C8A96B",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 16, fontWeight: "700", color: "#1C1917" }}>₺</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 6 }}>
|
||||
Açıklama (Opsiyonel)
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Kısa içerik veya porsiyon açıklaması..."
|
||||
placeholderTextColor="#A8A29E"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={{
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 14,
|
||||
color: "#1C1917",
|
||||
minHeight: 70,
|
||||
textAlignVertical: "top",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleAdd}
|
||||
disabled={adding || !name.trim() || !price.trim()}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: !name.trim() || !price.trim() ? "#D6D3D1" : "#1C1917",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
marginTop: 8,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
|
||||
{adding ? "Ekleniyor..." : "Ürünü Menüye Ekle"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useRef } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface CategoryPill {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface CategoryPillBarProps {
|
||||
categories: CategoryPill[];
|
||||
activeCategoryId: string | null;
|
||||
onSelectCategory: (id: string) => void;
|
||||
onAddCategoryPress: () => void;
|
||||
}
|
||||
|
||||
export function CategoryPillBar({
|
||||
categories,
|
||||
activeCategoryId,
|
||||
onSelectCategory,
|
||||
onAddCategoryPress,
|
||||
}: CategoryPillBarProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: "#FAF8F5", paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: "#E7E5E4" }}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, gap: 8, alignItems: "center" }}
|
||||
>
|
||||
{categories.map((cat) => {
|
||||
const isActive = cat.id === activeCategoryId;
|
||||
return (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
onPress={() => {
|
||||
Haptics.selectionAsync();
|
||||
onSelectCategory(cat.id);
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
backgroundColor: isActive ? "#1C1917" : "#FFFFFF",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: isActive ? "#1C1917" : "#E7E5E4",
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
minHeight: 38,
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? "700" : "600",
|
||||
color: isActive ? "#FFFFFF" : "#44403C",
|
||||
}}
|
||||
>
|
||||
{cat.name}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isActive ? "rgba(255, 255, 255, 0.2)" : "#F5F5F4",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
color: isActive ? "#FFFFFF" : "#78716C",
|
||||
}}
|
||||
>
|
||||
{cat.count}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onAddCategoryPress();
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.4)",
|
||||
borderStyle: "dashed",
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
minHeight: 38,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="add" size={16} color="#926E27" />
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#926E27" }}>
|
||||
Kategori
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import type { MenuItemData } from "./MenuItemCard";
|
||||
|
||||
interface ItemDetailSheetProps {
|
||||
item: MenuItemData | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (updatedItem: MenuItemData) => Promise<void>;
|
||||
onDelete: (itemId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ItemDetailSheet({
|
||||
item,
|
||||
visible,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: ItemDetailSheetProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setName(item.name);
|
||||
setPrice(String(item.price));
|
||||
setDescription(item.description ?? "");
|
||||
setImageUrl(item.image_url ?? null);
|
||||
setIsActive(item.is_active);
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
async function handlePickImage() {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Ürün görseli seçebilmek için fotoğraf galerisi erişim izni gereklidir.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.6,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
if (asset.base64) {
|
||||
setImageUrl(`data:image/jpeg;base64,${asset.base64}`);
|
||||
} else {
|
||||
setImageUrl(asset.uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const parsedPrice = Number(price.replace(",", "."));
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Eksik Bilgi", "Lütfen ürün adını giriniz.");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(parsedPrice) || parsedPrice < 0) {
|
||||
Alert.alert("Geçersiz Fiyat", "Lütfen geçerli bir fiyat giriniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
...item!,
|
||||
name: name.trim(),
|
||||
price: parsedPrice,
|
||||
description: description.trim() || null,
|
||||
image_url: imageUrl || null,
|
||||
is_active: isActive,
|
||||
});
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Kaydedilemedi.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
Alert.alert(
|
||||
"Ürünü Sil",
|
||||
`"${item?.name}" menüden silinecek. Emin misiniz?`,
|
||||
[
|
||||
{ text: "Vazgeç", style: "cancel" },
|
||||
{
|
||||
text: "Sil",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(item!.id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Silinemedi.");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" }}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "90%",
|
||||
paddingBottom: Platform.OS === "ios" ? 34 : 20,
|
||||
}}
|
||||
>
|
||||
{/* Header Handle & Bar */}
|
||||
<View style={{ alignItems: "center", paddingTop: 10, paddingBottom: 6 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "#E7E5E4",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F5F5F4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#1C1917" }}>
|
||||
Ürün Detayları
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#F5F5F4",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="#78716C" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
{/* Availability Toggle Box */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: isActive ? "#F0FDF4" : "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: isActive ? "#BBF7D0" : "#FECACA",
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: isActive ? "#15803D" : "#B91C1C",
|
||||
}}
|
||||
>
|
||||
{isActive ? "Menüde Aktif & Siparişe Açık" : "Tükendi / Menüde Gizli"}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
{isActive
|
||||
? "Müşteriler bu ürünü QR menüde görebilir."
|
||||
: "Müşteriler ürünü Tükendi olarak görür."}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isActive}
|
||||
onValueChange={(val) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
setIsActive(val);
|
||||
}}
|
||||
trackColor={{ false: "#D6D3D1", true: "#059669" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Image Picker / Preview */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 8 }}>
|
||||
Ürün Fotoğrafı
|
||||
</Text>
|
||||
{imageUrl ? (
|
||||
<View style={{ position: "relative", width: "100%", height: 160, borderRadius: 14, overflow: "hidden", borderWidth: 1, borderColor: "#E7E5E4" }}>
|
||||
<Image source={{ uri: imageUrl }} style={{ width: "100%", height: 160 }} resizeMode="cover" />
|
||||
<Pressable
|
||||
onPress={() => setImageUrl(null)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
backgroundColor: "rgba(0,0,0,0.65)",
|
||||
borderRadius: 14,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handlePickImage}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={14} color="#FFFFFF" />
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 11, fontWeight: "700" }}>Değiştir</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={handlePickImage}
|
||||
style={({ pressed }) => ({
|
||||
height: 100,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1.5,
|
||||
borderStyle: "dashed",
|
||||
borderColor: "#D6D3D1",
|
||||
backgroundColor: "#FAF8F5",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={26} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 13, fontWeight: "600", color: "#78716C" }}>
|
||||
Galeriden Fotoğraf Seç
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Name Input */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 6 }}>
|
||||
Ürün Adı *
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Örn: Izgara Levrek"
|
||||
placeholderTextColor="#A8A29E"
|
||||
style={{
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#1C1917",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Price Input */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 6 }}>
|
||||
Fiyat (₺) *
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
value={price}
|
||||
onChangeText={setPrice}
|
||||
placeholder="0.00"
|
||||
placeholderTextColor="#A8A29E"
|
||||
keyboardType="decimal-pad"
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: "#C8A96B",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 16, fontWeight: "700", color: "#1C1917" }}>₺</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description Input */}
|
||||
<View>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: "#44403C", marginBottom: 6 }}>
|
||||
Açıklama / Malzemeler
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="İçindekiler, porsiyon bilgisi, pişirme tarzı..."
|
||||
placeholderTextColor="#A8A29E"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={{
|
||||
backgroundColor: "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 14,
|
||||
color: "#1C1917",
|
||||
minHeight: 80,
|
||||
textAlignVertical: "top",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={{ gap: 10, marginTop: 10 }}>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={saving || deleting}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
opacity: pressed || saving ? 0.85 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
|
||||
{saving ? "Kaydediliyor..." : "Değişiklikleri Kaydet"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={confirmDelete}
|
||||
disabled={saving || deleting}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FEE2E2",
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
opacity: pressed || deleting ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: "#DC2626", fontSize: 14, fontWeight: "700" }}>
|
||||
{deleting ? "Siliniyor..." : "Bu Ürünü Menüden Sil"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Image, Pressable, Switch, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
export interface MenuItemData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
is_active: boolean;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
interface MenuItemCardProps {
|
||||
item: MenuItemData;
|
||||
onPress: () => void;
|
||||
onToggleActive: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export function MenuItemCard({ item, onPress, onToggleActive }: MenuItemCardProps) {
|
||||
const isAvailable = item.is_active;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
onPress();
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: isAvailable ? "#FFFFFF" : "#F5F5F4",
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: isAvailable ? "#E7E5E4" : "#D6D3D1",
|
||||
opacity: pressed ? 0.88 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: isAvailable ? 0.03 : 0,
|
||||
shadowRadius: 4,
|
||||
elevation: isAvailable ? 1 : 0,
|
||||
})}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
{/* Item Image Thumbnail if available */}
|
||||
{item.image_url ? (
|
||||
<Image
|
||||
source={{ uri: item.image_url }}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 12,
|
||||
marginRight: 12,
|
||||
backgroundColor: "#F5F5F4",
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Item Info */}
|
||||
<View style={{ flex: 1, marginRight: 10 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: isAvailable ? "#1C1917" : "#78716C",
|
||||
textDecorationLine: isAvailable ? "none" : "line-through",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
|
||||
{!isAvailable ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEE2E2",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 10, fontWeight: "700", color: "#DC2626" }}>
|
||||
Tükendi (86)
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{item.description ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isAvailable ? "#78716C" : "#A8A29E",
|
||||
marginTop: 2,
|
||||
lineHeight: 16,
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Price */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: "800",
|
||||
color: isAvailable ? "#C8A96B" : "#A8A29E",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{item.price} ₺
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Action Column: Stock Switch & Details Chevron */}
|
||||
<View style={{ alignItems: "flex-end", gap: 6 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
color: isAvailable ? "#059669" : "#78716C",
|
||||
}}
|
||||
>
|
||||
{isAvailable ? "Aktif" : "Kapalı"}
|
||||
</Text>
|
||||
<Switch
|
||||
value={isAvailable}
|
||||
onValueChange={(val) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
onToggleActive(val);
|
||||
}}
|
||||
trackColor={{ false: "#D6D3D1", true: "#059669" }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, color: "#A8A29E", fontWeight: "600" }}>Düzenle</Text>
|
||||
<Ionicons name="chevron-forward" size={13} color="#A8A29E" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
interface PublishActionBarProps {
|
||||
isPublished: boolean;
|
||||
totalItems: number;
|
||||
totalCategories: number;
|
||||
publishing: boolean;
|
||||
onPublish: () => void;
|
||||
onPreview: () => void;
|
||||
onShowQr: () => void;
|
||||
}
|
||||
|
||||
export function PublishActionBar({
|
||||
isPublished,
|
||||
totalItems,
|
||||
totalCategories,
|
||||
publishing,
|
||||
onPublish,
|
||||
onPreview,
|
||||
onShowQr,
|
||||
}: PublishActionBarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#E7E5E4",
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: Math.max(insets.bottom, 14),
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: -3 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", gap: 10, alignItems: "center" }}>
|
||||
{/* Permanent Preview Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
onPreview();
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "rgba(200, 169, 107, 0.4)",
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
minHeight: 50,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="eye-outline" size={18} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 14, fontWeight: "700", color: "#1C1917" }}>
|
||||
Önizle
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Publish Action Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
onPublish();
|
||||
}}
|
||||
disabled={publishing || totalItems === 0}
|
||||
style={({ pressed }) => ({
|
||||
flex: 2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
backgroundColor: totalItems === 0 ? "#D6D3D1" : "#1C1917",
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
opacity: pressed || publishing ? 0.85 : 1,
|
||||
minHeight: 50,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
})}
|
||||
>
|
||||
{publishing ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name={isPublished ? "sync" : "cloud-upload-outline"}
|
||||
size={18}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>
|
||||
{isPublished ? "Değişiklikleri Yayınla" : "Menüyü Yayınla"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Share,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { api } from "@/lib/api";
|
||||
import { getPublicMenuUrl } from "@/lib/urls";
|
||||
|
||||
export interface ThemeOption {
|
||||
key: string;
|
||||
name: string;
|
||||
category: string;
|
||||
color: string;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
bgPreview: string;
|
||||
desc: string;
|
||||
suitableFor: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{
|
||||
key: "elegant",
|
||||
name: "Elegant Gold",
|
||||
category: "Fine Dining & Lüks",
|
||||
color: "#C8A96B",
|
||||
badgeBg: "#FDF8F0",
|
||||
badgeText: "#926E27",
|
||||
bgPreview: "#FAF8F5",
|
||||
accent: "Altın & Fildişi",
|
||||
desc: "Zarif altın ve ipeksi krem tonlarında, yüksek prestijli serif tipografi.",
|
||||
suitableFor: "Fine dining, şarap evleri, gurme steakhouse",
|
||||
},
|
||||
{
|
||||
key: "modern",
|
||||
name: "Modern Sapphire",
|
||||
category: "Kafe & Fast Casual",
|
||||
color: "#2563EB",
|
||||
badgeBg: "#EFF6FF",
|
||||
badgeText: "#1D4ED8",
|
||||
bgPreview: "#F8FAFC",
|
||||
accent: "Kraliyet Safiri & Beyaz",
|
||||
desc: "Canlı mavi safir ve beyaz tonlarında, hızlı gezinti odaklı modern ızgara mizanpajı.",
|
||||
suitableFor: "Kafeler, burgerciler, yeni nesil bistrolar",
|
||||
},
|
||||
{
|
||||
key: "dark",
|
||||
name: "Luxury Dark",
|
||||
category: "Gece Kulübü & Bar",
|
||||
color: "#F59E0B",
|
||||
badgeBg: "#27272A",
|
||||
badgeText: "#FBBF24",
|
||||
bgPreview: "#0F0F12",
|
||||
accent: "Obsidian & Kehribar",
|
||||
desc: "Koyu obsidian siyahı ve kehribar parıltılı, loş ortamlara özel şık gece modu.",
|
||||
suitableFor: "Kokteyl barlar, lounge, gece kulüpleri",
|
||||
},
|
||||
{
|
||||
key: "minimal",
|
||||
name: "Nordic Minimal",
|
||||
category: "Butik Fırın & Kahveci",
|
||||
color: "#18181B",
|
||||
badgeBg: "#F4F4F5",
|
||||
badgeText: "#27272A",
|
||||
bgPreview: "#FAFAFA",
|
||||
accent: "Monokrom & Grafit",
|
||||
desc: "Ferah negatif alanlar, sakin monokrom renkler ve sade liste düzeni.",
|
||||
suitableFor: "3. nesil kahveciler, butik fırınlar, tatlıcılar",
|
||||
},
|
||||
{
|
||||
key: "classic",
|
||||
name: "Classic Bistro",
|
||||
category: "Geleneksel & Rustik",
|
||||
color: "#8B1E1E",
|
||||
badgeBg: "#FEF2F2",
|
||||
badgeText: "#991B1B",
|
||||
bgPreview: "#FFF9F2",
|
||||
accent: "Toskana Bordo & Sıcak Ahşap",
|
||||
desc: "Toskana bordo ve sıcak rustik dokularla geleneksel menü panosu hissi.",
|
||||
suitableFor: "Meyhaneler, geleneksel lokantalar, trattoria'lar",
|
||||
},
|
||||
];
|
||||
|
||||
interface ThemeSelectorSheetProps {
|
||||
visible: boolean;
|
||||
restaurantId: string;
|
||||
restaurantSlug?: string;
|
||||
onClose: () => void;
|
||||
onThemeChanged?: (themeKey: string) => void;
|
||||
}
|
||||
|
||||
export function ThemeSelectorSheet({
|
||||
visible,
|
||||
restaurantId,
|
||||
restaurantSlug,
|
||||
onClose,
|
||||
onThemeChanged,
|
||||
}: ThemeSelectorSheetProps) {
|
||||
const [selectedKey, setSelectedKey] = useState("elegant");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
|
||||
const WEB_BASE_URL = process.env.EXPO_PUBLIC_WEB_URL || "http://localhost:3000";
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !restaurantId) return;
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ themeKey: string }>(`/restaurants/${restaurantId}/theme`);
|
||||
if (res?.themeKey) {
|
||||
setSelectedKey(res.themeKey);
|
||||
}
|
||||
} catch {
|
||||
setSelectedKey("elegant");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [visible, restaurantId]);
|
||||
|
||||
async function handleApplyTheme(key: string) {
|
||||
setSelectedKey(key);
|
||||
setSavingKey(key);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
try {
|
||||
await api.put(`/restaurants/${restaurantId}/theme`, { themeKey: key });
|
||||
onThemeChanged?.(key);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
Alert.alert("Başarılı 🎉", "Menü şablonunuz güncellendi! Müşterileriniz artık bu temayı görecek.");
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Şablon uygulanamadı.");
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenDemo(key: string) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
const demoUrl = `${WEB_BASE_URL}/demo?theme=${key}`;
|
||||
Linking.openURL(demoUrl).catch(() => {
|
||||
Alert.alert("Demo Linki", demoUrl);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleShareDemo(theme: ThemeOption) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
const demoUrl = `${WEB_BASE_URL}/demo?theme=${theme.key}`;
|
||||
try {
|
||||
await Share.share({
|
||||
title: `${theme.name} — menul.io Canlı Demo`,
|
||||
message: `menul.io "${theme.name}" restoran menü şablonunu canlı olarak inceleyin:\n${demoUrl}`,
|
||||
});
|
||||
} catch {
|
||||
await Clipboard.setStringAsync(demoUrl);
|
||||
Alert.alert("Kopyalandı", "Demo linki panoya kopyalandı.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenMyMenuPreview(key: string) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
if (restaurantSlug) {
|
||||
const myMenuUrl = `${getPublicMenuUrl(restaurantSlug)}?theme=${key}`;
|
||||
Linking.openURL(myMenuUrl).catch(() => {
|
||||
Alert.alert("Önizleme Linki", myMenuUrl);
|
||||
});
|
||||
} else {
|
||||
handleOpenDemo(key);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<View style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.55)", justifyContent: "flex-end" }}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
maxHeight: "92%",
|
||||
paddingBottom: Platform.OS === "ios" ? 34 : 20,
|
||||
}}
|
||||
>
|
||||
{/* Grab Bar */}
|
||||
<View style={{ alignItems: "center", paddingTop: 10, paddingBottom: 6 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "#E7E5E4",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F5F5F4",
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, marginRight: 10 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "800", color: "#1C1917" }}>
|
||||
Menü Şablonları & Canlı Önizleme
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
5 lüks şablonu inceleyin, demolarını paylaşın veya menünüze uygulayın
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={onClose} style={{ padding: 4 }}>
|
||||
<Ionicons name="close-circle-outline" size={26} color="#9CA3AF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={{ padding: 50, alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" color="#C8A96B" />
|
||||
<Text style={{ marginTop: 12, color: "#78716C", fontSize: 13, fontWeight: "600" }}>
|
||||
Şablonlar yükleniyor...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
{THEME_OPTIONS.map((theme) => {
|
||||
const isSelected = selectedKey === theme.key;
|
||||
const isApplying = savingKey === theme.key;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={theme.key}
|
||||
style={{
|
||||
backgroundColor: isSelected ? "#FAF8F5" : "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
borderWidth: 2,
|
||||
borderColor: isSelected ? theme.color : "#E7E5E4",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: isSelected ? 0.08 : 0.03,
|
||||
shadowRadius: 10,
|
||||
elevation: isSelected ? 3 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Top Row: Color Pip, Name, Category Badge & Active Check */}
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: theme.color,
|
||||
borderWidth: 2,
|
||||
borderColor: "#FFFFFF",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
}}
|
||||
/>
|
||||
<View>
|
||||
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
|
||||
{theme.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.badgeBg,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, fontWeight: "700", color: theme.badgeText }}>
|
||||
{theme.category}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={{ fontSize: 13, color: "#57534E", lineHeight: 18, marginBottom: 8 }}>
|
||||
{theme.desc}
|
||||
</Text>
|
||||
|
||||
{/* Meta details */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 14 }}>
|
||||
<Ionicons name="sparkles" size={13} color={theme.color} />
|
||||
<Text style={{ fontSize: 11, color: "#78716C", fontWeight: "600" }}>
|
||||
Renk: <Text style={{ color: "#1C1917", fontWeight: "700" }}>{theme.accent}</Text> • {theme.suitableFor}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Action Bar: Canlı Demo, Paylaş, Uygula */}
|
||||
<View style={{ flexDirection: "row", gap: 8, borderTopWidth: 1, borderTopColor: "#F5F5F4", paddingTop: 12 }}>
|
||||
{/* Canlı Demo Aç */}
|
||||
<Pressable
|
||||
onPress={() => handleOpenDemo(theme.key)}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
backgroundColor: "#F5F5F4",
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="eye-outline" size={15} color="#1C1917" />
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#1C1917" }}>
|
||||
Demo Menü
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Demo Linkini Gönder / Paylaş */}
|
||||
<Pressable
|
||||
onPress={() => handleShareDemo(theme)}
|
||||
style={({ pressed }) => ({
|
||||
width: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#FAF8F5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="share-outline" size={16} color="#78716C" />
|
||||
</Pressable>
|
||||
|
||||
{/* Bu Şablonu Uygula / Aktif Rozeti */}
|
||||
<Pressable
|
||||
onPress={() => handleApplyTheme(theme.key)}
|
||||
disabled={isSelected || isApplying}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1.3,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
backgroundColor: isSelected ? "#ECFDF5" : theme.color,
|
||||
borderWidth: isSelected ? 1 : 0,
|
||||
borderColor: "#A7F3D0",
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
{isApplying ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={16} color="#059669" />
|
||||
<Text style={{ fontSize: 12, fontWeight: "800", color: "#059669" }}>
|
||||
Aktif Şablon
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark" size={15} color="#FFFFFF" />
|
||||
<Text style={{ fontSize: 12, fontWeight: "800", color: "#FFFFFF" }}>
|
||||
Şablonu Seç
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* Footer Close */}
|
||||
<View style={{ paddingHorizontal: 20, paddingTop: 10 }}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "700" }}>Kapat</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const KEY = "menulio.active-restaurant";
|
||||
|
||||
export interface ActiveRestaurant {
|
||||
restaurantId: string;
|
||||
locationId: string;
|
||||
menuId: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export async function getActiveRestaurant(): Promise<ActiveRestaurant | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as ActiveRestaurant) : null;
|
||||
}
|
||||
|
||||
export async function setActiveRestaurant(value: ActiveRestaurant): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function clearActiveRestaurant(): Promise<void> {
|
||||
await AsyncStorage.removeItem(KEY);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AiImportResponse, AiExtractedCategory } from "@menulio/shared";
|
||||
|
||||
let currentImportData: AiImportResponse | null = null;
|
||||
|
||||
export const aiStore = {
|
||||
setImportData: (data: AiImportResponse) => {
|
||||
currentImportData = data;
|
||||
},
|
||||
getImportData: (): AiImportResponse | null => {
|
||||
return currentImportData;
|
||||
},
|
||||
updateCategories: (categories: AiExtractedCategory[]) => {
|
||||
if (currentImportData) {
|
||||
currentImportData = {
|
||||
...currentImportData,
|
||||
categories,
|
||||
};
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
currentImportData = null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { supabase } from "./supabase";
|
||||
|
||||
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data.session?.access_token;
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = await authHeaders();
|
||||
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(body.message ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import "react-native-url-polyfill/auto";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const url = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!url || !anonKey) {
|
||||
throw new Error("EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_ANON_KEY missing");
|
||||
}
|
||||
|
||||
export const supabase = createClient(url, anonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const WEB_BASE_URL = process.env.EXPO_PUBLIC_WEB_URL || "http://localhost:3000";
|
||||
|
||||
export function getPublicMenuUrl(slug: string): string {
|
||||
if (WEB_BASE_URL.includes("menul.io")) {
|
||||
return `https://${slug}.menul.io`;
|
||||
}
|
||||
return `${WEB_BASE_URL}/menu/${slug}`;
|
||||
}
|
||||
|
||||
export function getPublicMenuDisplayUrl(slug: string): string {
|
||||
if (WEB_BASE_URL.includes("menul.io")) {
|
||||
return `${slug}.menul.io`;
|
||||
}
|
||||
return `${WEB_BASE_URL.replace(/^https?:\/\//, "")}/menu/${slug}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
ROOT_DOMAIN=menulio.app
|
||||
@@ -0,0 +1,61 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# 1. Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace configurations
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
|
||||
# Install dependencies for web & shared workspace
|
||||
RUN pnpm install --frozen-lockfile --filter @menulio/web...
|
||||
|
||||
# 2. Build the Next.js application
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app ./
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/web ./apps/web
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Next.js Public envs (Coolify build args can be passed here)
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG ROOT_DOMAIN=menul.io
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV ROOT_DOMAIN=$ROOT_DOMAIN
|
||||
|
||||
RUN pnpm --filter @menulio/web build
|
||||
|
||||
# 3. Production runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output from builder
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@menulio/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@menulio/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"next": "^15.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PublicMenuClient } from "@/components/PublicMenuClient";
|
||||
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gusto & Co. Brasserie | Menulio Canlı Demo Menü",
|
||||
description: "Menulio dijital QR menü altyapısı ile çalışan örnek canlı restoran menüsü.",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ theme?: string; table?: string }>;
|
||||
};
|
||||
|
||||
export default async function DemoMenuPage({ searchParams }: PageProps) {
|
||||
const resolved = searchParams ? await searchParams : {};
|
||||
const theme = resolved.theme || "elegant";
|
||||
|
||||
return (
|
||||
<PublicMenuClient
|
||||
restaurant={DEMO_RESTAURANT}
|
||||
categories={DEMO_CATEGORIES}
|
||||
initialThemeKey={theme}
|
||||
allowThemeSwitching={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;0,700;1,600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-heading: 'Outfit', 'Plus Jakarta Sans', sans-serif;
|
||||
--font-serif: 'Playfair Display', Georgia, serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Category Tabs */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Menulio | AI Destekli Yeni Nesil QR Menü",
|
||||
description: "Restoranınız için yapay zeka destekli, modern dijital QR menü platformu.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="tr" suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { PublicMenuClient, type MenuCategory, type MenuItem, type RestaurantData } from "@/components/PublicMenuClient";
|
||||
import type { ThemeConfig } from "@menulio/shared";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams?: Promise<{ theme?: string }>;
|
||||
};
|
||||
|
||||
interface MenuItemRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
image_url: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface MenuCategoryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
menu_items: MenuItemRow[];
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
let restaurant: any = null;
|
||||
const { data: bySlug } = await supabase
|
||||
.from("restaurants")
|
||||
.select("name, logo_url")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (bySlug) {
|
||||
restaurant = bySlug;
|
||||
} else {
|
||||
const { data: domainRow } = await supabase
|
||||
.from("domains")
|
||||
.select("restaurants(name, logo_url)")
|
||||
.eq("hostname", slug)
|
||||
.maybeSingle();
|
||||
if (domainRow?.restaurants) {
|
||||
restaurant = domainRow.restaurants;
|
||||
}
|
||||
}
|
||||
|
||||
if (!restaurant) {
|
||||
return {
|
||||
title: "Menü Bulunamadı | Menulio",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${restaurant.name} | QR Menü`,
|
||||
description: `${restaurant.name} restoranının güncel dijital QR menüsü ve fiyatları.`,
|
||||
openGraph: {
|
||||
title: `${restaurant.name} QR Menü`,
|
||||
description: `${restaurant.name} dijital menüsünü inceleyin.`,
|
||||
images: restaurant.logo_url ? [restaurant.logo_url] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicMenuPage({ params, searchParams }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const resolvedSearchParams = searchParams ? await searchParams : {};
|
||||
const themeParam = resolvedSearchParams.theme;
|
||||
|
||||
// 1. Try finding restaurant by slug
|
||||
let restaurant: any = null;
|
||||
const { data: bySlug } = await supabase
|
||||
.from("restaurants")
|
||||
.select("id, name, slug, logo_url, phone, address")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (bySlug) {
|
||||
restaurant = bySlug;
|
||||
} else {
|
||||
// 2. Try finding restaurant by custom domain hostname
|
||||
const { data: domainRow } = await supabase
|
||||
.from("domains")
|
||||
.select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address)")
|
||||
.eq("hostname", slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (domainRow?.restaurants) {
|
||||
restaurant = domainRow.restaurants;
|
||||
}
|
||||
}
|
||||
|
||||
if (!restaurant) notFound();
|
||||
|
||||
const { data: menu } = await supabase
|
||||
.from("menus")
|
||||
.select("id, name, locations!inner(restaurant_id)")
|
||||
.eq("locations.restaurant_id", restaurant.id)
|
||||
.eq("is_published", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (!menu) notFound();
|
||||
|
||||
const { data: rawCategories } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("id, name, description, is_active, menu_items(id, name, description, price, image_url, is_active)")
|
||||
.eq("menu_id", menu.id)
|
||||
.eq("is_active", true)
|
||||
.order("sort_order", { ascending: true })
|
||||
.returns<MenuCategoryRow[]>();
|
||||
|
||||
const { data: themeRow } = await supabase
|
||||
.from("restaurant_themes")
|
||||
.select("overrides, themes(key, config)")
|
||||
.eq("restaurant_id", restaurant.id)
|
||||
.maybeSingle();
|
||||
|
||||
const themeRelation = themeRow?.themes as unknown as { key?: string; config?: Partial<ThemeConfig> } | null;
|
||||
const themeKey = themeParam || themeRelation?.key || "elegant";
|
||||
const customConfig = (themeRow?.overrides as Partial<ThemeConfig>) || themeRelation?.config;
|
||||
|
||||
const categories: MenuCategory[] = (rawCategories ?? []).map((cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
is_active: cat.is_active,
|
||||
menu_items: (cat.menu_items ?? [])
|
||||
.filter((item) => item.is_active)
|
||||
.map((item): MenuItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
price: item.price,
|
||||
image_url: item.image_url,
|
||||
is_active: item.is_active,
|
||||
})),
|
||||
}));
|
||||
|
||||
const restaurantData: RestaurantData = {
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
slug: restaurant.slug,
|
||||
logo_url: restaurant.logo_url,
|
||||
phone: restaurant.phone,
|
||||
address: restaurant.address,
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicMenuClient
|
||||
restaurant={restaurantData}
|
||||
categories={categories}
|
||||
initialThemeKey={themeKey}
|
||||
customThemeConfig={customConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { THEME_PRESETS } from "@/lib/theme";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Menulio | AI Destekli Yeni Nesil QR Menü Platformu",
|
||||
description:
|
||||
"Restoran menünüzün fotoğrafını çekin, yapay zeka saniyeler içinde dijitalleştirsin. 5 lüks şablon, anında açılan mobil web menüsü ve dinamik QR kodlar.",
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-stone-100 selection:bg-amber-500 selection:text-stone-950 font-sans">
|
||||
{/* Navigation */}
|
||||
<header className="border-b border-stone-800/80 bg-stone-950/75 backdrop-blur-xl sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-amber-400 via-amber-600 to-amber-800 flex items-center justify-center font-black text-xl text-stone-950 shadow-lg shadow-amber-500/20">
|
||||
M
|
||||
</div>
|
||||
<span className="font-extrabold text-2xl tracking-tight bg-gradient-to-r from-amber-200 via-amber-400 to-amber-500 bg-clip-text text-transparent">
|
||||
MENULIO
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8 text-sm font-medium text-stone-300">
|
||||
<Link href="#features" className="hover:text-amber-400 transition-colors">
|
||||
Özellikler
|
||||
</Link>
|
||||
<Link href="#templates" className="hover:text-amber-400 transition-colors">
|
||||
Şablonlar (5 Tema)
|
||||
</Link>
|
||||
<Link href="#how-it-works" className="hover:text-amber-400 transition-colors">
|
||||
Nasıl Çalışır?
|
||||
</Link>
|
||||
<Link href="/templates" className="hover:text-amber-400 transition-colors">
|
||||
Canlı Demo
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/demo"
|
||||
className="px-5 py-2.5 rounded-xl text-xs font-bold text-stone-200 bg-stone-900 hover:bg-stone-800 border border-stone-800 transition-all active:scale-95"
|
||||
>
|
||||
Menü Demosu Gör
|
||||
</Link>
|
||||
<Link
|
||||
href="/templates"
|
||||
className="px-5 py-2.5 rounded-xl text-xs font-bold text-stone-950 bg-gradient-to-r from-amber-400 to-amber-500 hover:from-amber-300 hover:to-amber-400 transition-all shadow-lg shadow-amber-500/25 active:scale-95"
|
||||
>
|
||||
Şablonları İncele →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-24 pb-32 overflow-hidden px-6">
|
||||
{/* Glow Effects */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-amber-500/10 rounded-full blur-[140px] pointer-events-none" />
|
||||
<div className="absolute top-1/3 left-1/4 w-[400px] h-[400px] bg-blue-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
|
||||
<div className="max-w-5xl mx-auto text-center space-y-8 relative z-10">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-amber-500/10 border border-amber-500/25 text-amber-400 text-xs font-bold tracking-wide animate-fade-in">
|
||||
<span>✨</span> YAPAY ZEKA DESTEKLİ DİJİTAL QR MENÜ SAAS
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] text-stone-100">
|
||||
Menünüzün Fotoğrafını Çekin,{" "}
|
||||
<span className="bg-gradient-to-r from-amber-300 via-amber-400 to-amber-600 bg-clip-text text-transparent">
|
||||
AI Saniyeler İçinde Dijitalleştirsin.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl text-stone-400 max-w-3xl mx-auto leading-relaxed">
|
||||
Menulio ile restoranınızın basılı menüsünü cep telefonunuzdan fotoğraflayın. Vision AI tüm
|
||||
kategorileri, ürünleri ve fiyatları anında tanısın; 5 lüks şablondan birini seçip hemen
|
||||
masalarınıza QR koyun.
|
||||
</p>
|
||||
|
||||
{/* Call to Actions */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
|
||||
<Link
|
||||
href="/templates"
|
||||
className="w-full sm:w-auto px-8 py-4 rounded-2xl font-bold text-stone-950 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 hover:opacity-95 transition-all shadow-xl shadow-amber-500/25 text-base flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>🌟</span> Canlı Şablonları Test Edin
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/demo"
|
||||
className="w-full sm:w-auto px-8 py-4 rounded-2xl font-bold text-stone-200 bg-stone-900/90 hover:bg-stone-800 border border-stone-800 transition-all text-base flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>📱</span> Canlı Demo Menüyü Aç
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Social Proof Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 pt-16 border-t border-stone-800/80 max-w-4xl mx-auto text-left sm:text-center">
|
||||
<div>
|
||||
<div className="text-3xl sm:text-4xl font-black text-amber-400">10 sn</div>
|
||||
<div className="text-xs text-stone-400 mt-1">AI ile Menü Çıkarma</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl sm:text-4xl font-black text-white">5 Adet</div>
|
||||
<div className="text-xs text-stone-400 mt-1">Özel Tasarım Şablonu</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl sm:text-4xl font-black text-amber-400">%100</div>
|
||||
<div className="text-xs text-stone-400 mt-1">Dinamik QR (Yeniden Basılmaz)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl sm:text-4xl font-black text-white"><0.5 sn</div>
|
||||
<div className="text-xs text-stone-400 mt-1">Mobil Açılış Hızı</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5 Menu Templates Showcase Section */}
|
||||
<section id="templates" className="py-24 bg-stone-900/50 border-y border-stone-800/80 px-6">
|
||||
<div className="max-w-7xl mx-auto space-y-16">
|
||||
<div className="text-center max-w-3xl mx-auto space-y-4">
|
||||
<span className="text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
TEMALAR & TASARIM ŞABLONLARI
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-5xl font-extrabold tracking-tight">
|
||||
Her Restoran Konseptine Özel 5 Şablon
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm sm:text-base">
|
||||
Fine dining'den gurme burgerciye, gece kulübünden butik kahveciye kadar tek tıkla şablon
|
||||
değiştirin. Menü verileriniz bozulmadan sunum katmanı anında güncellenir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 5 Template Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Object.values(THEME_PRESETS).map((preset) => (
|
||||
<div
|
||||
key={preset.key}
|
||||
className="rounded-3xl border border-stone-800 bg-stone-900/80 p-6 flex flex-col justify-between hover:border-amber-500/50 transition-all group shadow-xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Theme Header with Accent Color Dot */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full shadow-md"
|
||||
style={{ backgroundColor: preset.config.primaryColor }}
|
||||
/>
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-amber-300 transition-colors">
|
||||
{preset.name}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold px-2.5 py-1 rounded-full bg-stone-800 text-stone-300 border border-stone-700">
|
||||
{preset.fontFamily.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Theme Description */}
|
||||
<p className="text-xs text-stone-400 leading-relaxed">
|
||||
{preset.key === "elegant" && "Fine dining restoranlar, şarap evleri ve lüks mekanlar için altın & krem tonlarında zarif mizanpaj."}
|
||||
{preset.key === "modern" && "Kafeler, burgerciler ve fast-casual mekanlar için canlı mavi safir tonları ve hızlı arama odaklı ızgara düzeni."}
|
||||
{preset.key === "dark" && "Gece kulüpleri, kokteyl barlar ve steakhouse'lar için obsidian siyahı ve kehribar parıltılı lüks mod."}
|
||||
{preset.key === "minimal" && "Butik kahveciler, fırınlar ve üçüncü nesil mekanlar için bol boşluklu sakin Nordic tipografi."}
|
||||
{preset.key === "classic" && "Geleneksel brasserie'ler, meyhaneler ve trattoria'lar için Toskana bordo ve sıcak rustik doku."}
|
||||
</p>
|
||||
|
||||
{/* Visual Preview Box */}
|
||||
<div
|
||||
className="h-32 rounded-2xl p-4 flex flex-col justify-between border shadow-inner relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: preset.config.background,
|
||||
borderColor: preset.cardBorder,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-3 w-20 rounded bg-stone-400/40" />
|
||||
<div
|
||||
className="h-4 w-12 rounded-full"
|
||||
style={{ backgroundColor: preset.config.primaryColor }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="p-2.5 rounded-xl border flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: preset.cardBg,
|
||||
borderColor: preset.cardBorder,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="h-2.5 w-24 rounded font-bold text-[10px]"
|
||||
style={{ color: preset.textPrimary }}
|
||||
>
|
||||
Trüflü Burrata
|
||||
</div>
|
||||
<div className="h-2 w-16 rounded bg-stone-300/30" />
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-black"
|
||||
style={{ color: preset.config.primaryColor }}
|
||||
>
|
||||
₺420
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 mt-4 border-t border-stone-800/80">
|
||||
<Link
|
||||
href={`/templates?theme=${preset.key}`}
|
||||
className="w-full py-2.5 rounded-xl text-xs font-bold text-center block bg-stone-800 hover:bg-stone-700 text-stone-100 transition-all border border-stone-700"
|
||||
>
|
||||
{preset.name} Önizle →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<Link
|
||||
href="/templates"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-2xl font-bold text-stone-950 bg-amber-400 hover:bg-amber-300 shadow-xl shadow-amber-400/20 text-sm transition-all"
|
||||
>
|
||||
<span>📱</span> Tüm Şablonları Cihaz Simülatöründe Canlı Dene
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section id="how-it-works" className="py-24 px-6 max-w-7xl mx-auto space-y-16">
|
||||
<div className="text-center max-w-2xl mx-auto space-y-4">
|
||||
<span className="text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
KOLAY ENTEGRASYON
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-5xl font-extrabold tracking-tight">
|
||||
3 Kolay Adımda Masanızda
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm sm:text-base">
|
||||
Saatlerce menü girmeye son. Tek yapmanız gereken cep telefonunuzla fotoğraf çekmek.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Step 1 */}
|
||||
<div className="rounded-3xl border border-stone-800 bg-stone-900/60 p-8 space-y-4 relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center font-black text-xl">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">Menü Fotoğrafını Çekin</h3>
|
||||
<p className="text-stone-400 text-sm leading-relaxed">
|
||||
Mevcut basılı menünüzün, broşürünüzün veya tahtanızın fotoğrafını Menulio mobil uygulamasıyla çekin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="rounded-3xl border border-stone-800 bg-stone-900/60 p-8 space-y-4 relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center font-black text-xl">
|
||||
2
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">AI Ayıklasın & Onaylayın</h3>
|
||||
<p className="text-stone-400 text-sm leading-relaxed">
|
||||
Vision AI tüm kategorileri, ürünleri ve fiyatları saniyeler içinde çıkarır. İnceleyin, istediğiniz temayı seçin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="rounded-3xl border border-stone-800 bg-stone-900/60 p-8 space-y-4 relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center font-black text-xl">
|
||||
3
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">QR Kodunuzu Masaya Koyun</h3>
|
||||
<p className="text-stone-400 text-sm leading-relaxed">
|
||||
Müşterileriniz uygulama indirmeden saniyeler içinde menüyü açsın. Fiyat değiştiğinde asla yeni QR basmayın.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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="flex items-center gap-3">
|
||||
<span className="font-bold text-stone-300 text-sm tracking-wider">MENULIO</span>
|
||||
<span>•</span>
|
||||
<span>© {new Date().getFullYear()} Tüm hakları saklıdır.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/templates" className="hover:text-stone-300 transition-colors">
|
||||
Şablonlar
|
||||
</Link>
|
||||
<Link href="/demo" className="hover:text-stone-300 transition-colors">
|
||||
Canlı Demo
|
||||
</Link>
|
||||
<Link href="/demo?theme=dark" className="hover:text-stone-300 transition-colors">
|
||||
Dark Tema
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
type RouteParams = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
// Stable QR redirect target (PRD §12) — printed QR codes encode this URL and
|
||||
// never change; only the row's target_url is updated when domain/slug changes.
|
||||
export async function GET(_req: NextRequest, { params }: RouteParams) {
|
||||
const { id } = await params;
|
||||
|
||||
const { data } = await supabase.from("qr_codes").select("target_url").eq("id", id).maybeSingle();
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ message: "not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(data.target_url, { status: 302 });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TemplateGalleryClient } from "@/components/TemplateGalleryClient";
|
||||
import { DEMO_RESTAURANT, DEMO_CATEGORIES } from "@/lib/demo-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Menü Şablonları & Canlı Demo | Menulio",
|
||||
description: "Menulio'nun 5 farklı lüks restoran şablonunu cihaz simülatöründe canlı olarak deneyimleyin.",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ theme?: string }>;
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams }: PageProps) {
|
||||
const resolvedSearchParams = searchParams ? await searchParams : {};
|
||||
const currentTheme = resolvedSearchParams.theme || "elegant";
|
||||
|
||||
return (
|
||||
<TemplateGalleryClient
|
||||
restaurant={DEMO_RESTAURANT}
|
||||
categories={DEMO_CATEGORIES}
|
||||
initialThemeKey={currentTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import type { ThemeConfig } from "@menulio/shared";
|
||||
import { THEME_PRESETS, resolveThemePreset, type ThemePreset } from "@/lib/theme";
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
image_url: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
menu_items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface RestaurantData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo_url: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
}
|
||||
|
||||
interface PublicMenuClientProps {
|
||||
restaurant: RestaurantData;
|
||||
categories: MenuCategory[];
|
||||
initialThemeKey?: string;
|
||||
customThemeConfig?: Partial<ThemeConfig>;
|
||||
allowThemeSwitching?: boolean;
|
||||
}
|
||||
|
||||
export function PublicMenuClient({
|
||||
restaurant,
|
||||
categories,
|
||||
initialThemeKey = "elegant",
|
||||
customThemeConfig,
|
||||
allowThemeSwitching = true,
|
||||
}: PublicMenuClientProps) {
|
||||
const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string>(categories[0]?.id ?? "");
|
||||
const [selectedItem, setSelectedItem] = useState<MenuItem | null>(null);
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
const [showThemePicker, setShowThemePicker] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [tableNumber, setTableNumber] = useState<string | null>(null);
|
||||
const [serviceActionToast, setServiceActionToast] = useState<string | null>(null);
|
||||
|
||||
const theme: ThemePreset = useMemo(() => {
|
||||
return resolveThemePreset(selectedThemeKey, customThemeConfig);
|
||||
}, [selectedThemeKey, customThemeConfig]);
|
||||
|
||||
// Extract table number from URL (?table=X)
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const table = params.get("table") || params.get("masa");
|
||||
if (table) setTableNumber(table);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track scroll position for active category & back-to-top button
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowBackToTop(window.scrollY > 300);
|
||||
|
||||
const categoryElements = categories.map((c) => ({
|
||||
id: c.id,
|
||||
el: document.getElementById(`category-${c.id}`),
|
||||
}));
|
||||
|
||||
const scrollPos = window.scrollY + 140;
|
||||
for (let i = categoryElements.length - 1; i >= 0; i--) {
|
||||
const item = categoryElements[i];
|
||||
if (item && item.el && item.el.offsetTop <= scrollPos) {
|
||||
setActiveCategoryId(item.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [categories]);
|
||||
|
||||
// Filter categories and items based on search
|
||||
const filteredCategories = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return categories;
|
||||
|
||||
return categories
|
||||
.map((cat) => ({
|
||||
...cat,
|
||||
menu_items: cat.menu_items.filter(
|
||||
(item) =>
|
||||
item.is_active &&
|
||||
(item.name.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q))),
|
||||
),
|
||||
}))
|
||||
.filter((cat) => cat.menu_items.length > 0);
|
||||
}, [categories, searchQuery]);
|
||||
|
||||
const totalItemsCount = useMemo(() => {
|
||||
return categories.reduce((acc, cat) => acc + cat.menu_items.filter((i) => i.is_active).length, 0);
|
||||
}, [categories]);
|
||||
|
||||
const scrollToCategory = (categoryId: string) => {
|
||||
setActiveCategoryId(categoryId);
|
||||
const el = document.getElementById(`category-${categoryId}`);
|
||||
if (el) {
|
||||
const yOffset = -85;
|
||||
const y = el.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||
window.scrollTo({ top: y, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const url = window.location.href;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `${restaurant.name} - Dijital Menü`,
|
||||
text: `${restaurant.name} dijital menüsünü inceleyin!`,
|
||||
url,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2500);
|
||||
};
|
||||
|
||||
const triggerServiceAction = (msg: string) => {
|
||||
setServiceActionToast(msg);
|
||||
setTimeout(() => setServiceActionToast(null), 3500);
|
||||
};
|
||||
|
||||
const fontClass =
|
||||
theme.fontFamily === "serif" ? "font-serif" : theme.fontFamily === "heading" ? "font-heading" : "font-sans";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen ${fontClass} transition-colors duration-300`}
|
||||
style={{
|
||||
backgroundColor: theme.config.background,
|
||||
color: theme.textPrimary,
|
||||
}}
|
||||
>
|
||||
{/* Toast Notification */}
|
||||
{serviceActionToast && (
|
||||
<div className="fixed top-5 left-1/2 -translate-x-1/2 z-50 animate-bounce-in max-w-sm w-full px-4">
|
||||
<div className="bg-stone-900 text-white px-5 py-3.5 rounded-2xl shadow-2xl flex items-center gap-3 border border-stone-700">
|
||||
<span className="text-xl">🔔</span>
|
||||
<p className="text-sm font-medium flex-1">{serviceActionToast}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Container - Optimized for Mobile First & Desktop Centered */}
|
||||
<div className="w-full max-w-2xl mx-auto min-h-screen flex flex-col shadow-2xl relative">
|
||||
{/* Header Hero Banner */}
|
||||
<header
|
||||
style={{ background: theme.headerGradient }}
|
||||
className="relative text-white px-4 sm:px-6 pt-8 sm:pt-10 pb-7 sm:pb-8 rounded-b-3xl shadow-lg overflow-hidden"
|
||||
>
|
||||
{/* Subtle Ambient Glow */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-64 h-64 rounded-full blur-3xl opacity-20 pointer-events-none"
|
||||
style={{ backgroundColor: theme.config.primaryColor }}
|
||||
/>
|
||||
|
||||
{/* Top Bar: Table & Action Buttons */}
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 relative z-10">
|
||||
{tableNumber ? (
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 backdrop-blur-md border border-white/15 text-xs font-semibold tracking-wide">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Masa {tableNumber}
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 backdrop-blur-md border border-white/15 text-xs font-medium tracking-wide">
|
||||
<span>✨</span> Dijital QR Menü
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
{/* 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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share Trigger */}
|
||||
<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"
|
||||
aria-label="Menüyü Paylaş"
|
||||
>
|
||||
{copiedLink ? (
|
||||
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Live Theme Switcher Trigger */}
|
||||
{allowThemeSwitching && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowThemePicker(!showThemePicker)}
|
||||
className="px-2.5 py-1 sm:py-1.5 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center gap-1.5 border border-white/15 backdrop-blur-md text-[11px] sm:text-xs font-semibold"
|
||||
aria-label="Tema Değiştir"
|
||||
>
|
||||
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full" style={{ backgroundColor: theme.config.primaryColor }} />
|
||||
<span>Tema</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restaurant Identity */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 relative z-10">
|
||||
{restaurant.logo_url ? (
|
||||
<img
|
||||
src={restaurant.logo_url}
|
||||
alt={restaurant.name}
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl object-cover border-2 shadow-md flex-shrink-0"
|
||||
style={{ borderColor: theme.config.primaryColor }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center font-bold text-xl sm:text-2xl flex-shrink-0 shadow-inner border border-white/20"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.12)",
|
||||
color: theme.config.primaryColor,
|
||||
}}
|
||||
>
|
||||
{restaurant.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold tracking-tight leading-snug break-words">
|
||||
{restaurant.name}
|
||||
</h1>
|
||||
<p className="text-[11px] sm:text-xs text-white/70 mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<span>{totalItemsCount} Özel Lezzet</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{theme.name} Şablonu</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Search Bar */}
|
||||
<div className="mt-6 relative z-10">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Yemek, içecek veya tatlı ara..."
|
||||
value={searchQuery}
|
||||
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"
|
||||
style={{
|
||||
outlineColor: theme.config.primaryColor,
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="w-5 h-5 absolute left-3.5 text-stone-400 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3.5 w-5 h-5 rounded-full bg-stone-200 text-stone-600 flex items-center justify-center text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Live Theme Switcher Drawer */}
|
||||
{showThemePicker && allowThemeSwitching && (
|
||||
<div className="bg-stone-900 text-white px-5 py-4 border-b border-stone-800 animate-slide-up">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-stone-400">
|
||||
Canlı Tema Önizleme (5 Şablon)
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowThemePicker(false)}
|
||||
className="text-stone-400 hover:text-white text-xs"
|
||||
>
|
||||
Kapat ✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Object.values(THEME_PRESETS).map((preset) => {
|
||||
const isSelected = selectedThemeKey === preset.key;
|
||||
return (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedThemeKey(preset.key)}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-xl border text-center transition-all ${
|
||||
isSelected
|
||||
? "bg-white/15 border-white shadow-md scale-105"
|
||||
: "bg-white/5 border-white/10 opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-5 h-5 rounded-full border border-white/30"
|
||||
style={{ backgroundColor: preset.config.primaryColor }}
|
||||
/>
|
||||
<span className="text-[10px] font-medium leading-tight truncate w-full">
|
||||
{preset.name.split(" ")[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky Category Navigation Bar */}
|
||||
{categories.length > 0 && !searchQuery && (
|
||||
<nav
|
||||
className="sticky top-0 z-30 px-4 py-3 backdrop-blur-md border-b transition-colors"
|
||||
style={{
|
||||
backgroundColor: `${theme.config.background}E6`,
|
||||
borderColor: theme.cardBorder,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth py-0.5">
|
||||
{categories.map((cat) => {
|
||||
const isActive = activeCategoryId === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => scrollToCategory(cat.id)}
|
||||
className={`px-4 py-2 rounded-full text-xs font-bold whitespace-nowrap transition-all flex items-center gap-1.5 ${
|
||||
isActive
|
||||
? "text-white shadow-md scale-105"
|
||||
: "hover:bg-black/5 active:scale-95"
|
||||
}`}
|
||||
style={
|
||||
isActive
|
||||
? { backgroundColor: theme.config.primaryColor }
|
||||
: {
|
||||
backgroundColor: theme.accentBg,
|
||||
color: theme.textSecondary,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span>{cat.name}</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.2 rounded-full ${
|
||||
isActive ? "bg-black/20 text-white" : "bg-black/5"
|
||||
}`}
|
||||
>
|
||||
{cat.menu_items.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Menu Content Area */}
|
||||
<main className="flex-1 px-4 py-6 space-y-8">
|
||||
{filteredCategories.length === 0 ? (
|
||||
<div className="text-center py-16 px-4">
|
||||
<div className="w-16 h-16 rounded-full bg-stone-100 dark:bg-stone-800 flex items-center justify-center text-3xl mx-auto mb-4">
|
||||
🔍
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-800 dark:text-stone-200">
|
||||
Aramanıza Uygun Lezzet Bulunamadı
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 mt-1 max-w-xs mx-auto">
|
||||
"{searchQuery}" için sonuç yok. Lütfen farklı bir arama kelimesi deneyin.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="mt-4 px-4 py-2 rounded-xl text-xs font-bold text-white shadow"
|
||||
style={{ backgroundColor: theme.config.primaryColor }}
|
||||
>
|
||||
Tüm Menüyü Göster
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filteredCategories.map((category) => (
|
||||
<section
|
||||
key={category.id}
|
||||
id={`category-${category.id}`}
|
||||
className="scroll-mt-24 space-y-3.5"
|
||||
>
|
||||
{/* Category Heading Banner */}
|
||||
<div className="flex items-center justify-between border-b pb-2" style={{ borderColor: theme.cardBorder }}>
|
||||
<div>
|
||||
<h2
|
||||
className="text-lg font-bold tracking-tight"
|
||||
style={{ color: theme.config.primaryColor }}
|
||||
>
|
||||
{category.name}
|
||||
</h2>
|
||||
{category.description && (
|
||||
<p className="text-xs mt-0.5" style={{ color: theme.textSecondary }}>
|
||||
{category.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: theme.badgeBg, color: theme.badgeText }}
|
||||
>
|
||||
{category.menu_items.length} Ürün
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items Container: Grid / Card / List Layout based on Theme */}
|
||||
<div
|
||||
className={
|
||||
theme.config.productLayout === "list"
|
||||
? "space-y-3"
|
||||
: "grid grid-cols-1 gap-3.5"
|
||||
}
|
||||
>
|
||||
{category.menu_items.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`group relative rounded-2xl p-4 transition-all duration-200 cursor-pointer border hover:shadow-lg active:scale-[0.99] flex gap-3.5 items-center ${
|
||||
theme.config.productLayout === "list" ? "justify-between" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: theme.cardBg,
|
||||
borderColor: theme.cardBorder,
|
||||
}}
|
||||
>
|
||||
{/* Item Details */}
|
||||
<div className="flex-1 min-w-0 pr-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-bold text-sm tracking-tight leading-snug group-hover:text-amber-600 transition-colors">
|
||||
{item.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p
|
||||
className="text-xs mt-1 leading-relaxed line-clamp-2"
|
||||
style={{ color: theme.textSecondary }}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2.5 flex items-center justify-between">
|
||||
<span
|
||||
className="text-base font-extrabold tracking-tight"
|
||||
style={{ color: theme.config.primaryColor }}
|
||||
>
|
||||
₺{item.price.toFixed(0)}
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-md bg-black/5 dark:bg-white/10 group-hover:bg-amber-100 dark:group-hover:bg-amber-900/40 transition-colors">
|
||||
İncele →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Thumbnail Image */}
|
||||
{item.image_url && (
|
||||
<div className="relative w-20 h-20 rounded-xl overflow-hidden flex-shrink-0 bg-stone-100 dark:bg-stone-800 shadow-sm">
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer
|
||||
className="px-6 py-10 text-center border-t mt-auto text-xs space-y-4"
|
||||
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">
|
||||
MENULIO
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] opacity-70">
|
||||
© {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
{/* Floating Quick Action Bar (Garson Çağır / Hesap İste / Başa Dön) */}
|
||||
<aside aria-label="Masa Servis İşlemleri" className="fixed bottom-5 left-1/2 -translate-x-1/2 z-40 max-w-sm w-full px-4 flex items-center justify-between gap-2 pointer-events-none">
|
||||
<div className="flex items-center gap-2 pointer-events-auto shadow-2xl rounded-full p-1 bg-stone-900/90 backdrop-blur-lg border border-stone-700 text-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerServiceAction(
|
||||
tableNumber
|
||||
? `Masa ${tableNumber} için Garson Çağrıldı! Garsonunuz en kısa sürede masanızda olacaktır.`
|
||||
: "Garson çağrıldı! Garsonunuz hemen masanızda olacaktır.",
|
||||
)
|
||||
}
|
||||
className="px-3.5 py-2 rounded-full hover:bg-white/15 active:scale-95 transition-all text-xs font-bold flex items-center gap-1.5"
|
||||
>
|
||||
<span>👋</span> Garson Çağır
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerServiceAction(
|
||||
tableNumber
|
||||
? `Masa ${tableNumber} için Hesap İsteği iletildi!`
|
||||
: "Hesap isteği iletildi!",
|
||||
)
|
||||
}
|
||||
className="px-3.5 py-2 rounded-full hover:bg-white/15 active:scale-95 transition-all text-xs font-bold flex items-center gap-1.5"
|
||||
>
|
||||
<span>💳</span> Hesap İste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBackToTop && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
className="w-11 h-11 rounded-full bg-stone-900 text-white shadow-2xl flex items-center justify-center pointer-events-auto active:scale-90 transition-all border border-stone-700"
|
||||
aria-label="Başa Dön"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Item Detail Modal */}
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
|
||||
<div
|
||||
className="w-full max-w-lg rounded-t-3xl sm:rounded-3xl overflow-hidden shadow-2xl animate-slide-up flex flex-col max-h-[85vh]"
|
||||
style={{ backgroundColor: theme.cardBg, color: theme.textPrimary }}
|
||||
>
|
||||
{/* Modal Image */}
|
||||
{selectedItem.image_url ? (
|
||||
<div className="relative h-56 w-full bg-stone-900">
|
||||
<img
|
||||
src={selectedItem.image_url}
|
||||
alt={selectedItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="absolute top-4 right-4 w-9 h-9 rounded-full bg-black/60 text-white flex items-center justify-center backdrop-blur-md text-sm hover:bg-black/80"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="w-8 h-8 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-600 dark:text-stone-300 flex items-center justify-center text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h3 className="text-xl font-bold tracking-tight leading-snug">
|
||||
{selectedItem.name}
|
||||
</h3>
|
||||
<span
|
||||
className="text-xl font-black whitespace-nowrap"
|
||||
style={{ color: theme.config.primaryColor }}
|
||||
>
|
||||
₺{selectedItem.price.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedItem.description && (
|
||||
<p className="text-sm leading-relaxed" style={{ color: theme.textSecondary }}>
|
||||
{selectedItem.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Quality Badges */}
|
||||
<div className="pt-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-3 py-1 rounded-full bg-emerald-50 text-emerald-700 font-semibold border border-emerald-200">
|
||||
🌱 Taze & Günlük
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full bg-amber-50 text-amber-700 font-semibold border border-amber-200">
|
||||
⭐ Şefin İmzası
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t" style={{ borderColor: theme.cardBorder }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedItem(null);
|
||||
triggerServiceAction(`"${selectedItem.name}" sipariş tercihleriniz garsona iletildi!`);
|
||||
}}
|
||||
className="w-full py-3.5 rounded-2xl font-bold text-white shadow-lg active:scale-98 transition-all flex items-center justify-center gap-2 text-sm"
|
||||
style={{ backgroundColor: theme.config.primaryColor }}
|
||||
>
|
||||
<span>➕</span> Garsona Sipariş Olarak Bildir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restaurant Info & Contact Modal */}
|
||||
{showInfoModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
|
||||
<div
|
||||
className="w-full max-w-md rounded-t-3xl sm:rounded-3xl overflow-hidden shadow-2xl p-6 space-y-5 animate-slide-up"
|
||||
style={{ backgroundColor: theme.cardBg, color: theme.textPrimary }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">Restoran Bilgileri</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInfoModal(false)}
|
||||
className="w-8 h-8 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-600 dark:text-stone-300 flex items-center justify-center text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
{restaurant.phone && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-stone-50 dark:bg-stone-800/50">
|
||||
<span className="text-lg">📞</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-stone-400">Telefon</p>
|
||||
<a href={`tel:${restaurant.phone}`} className="font-semibold hover:underline">
|
||||
{restaurant.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restaurant.address && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-stone-50 dark:bg-stone-800/50">
|
||||
<span className="text-lg">📍</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-stone-400">Adres</p>
|
||||
<p className="font-medium text-xs">{restaurant.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-stone-50 dark:bg-stone-800/50">
|
||||
<span className="text-lg">🕒</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-stone-400">Çalışma Saatleri</p>
|
||||
<p className="font-semibold text-xs">Hergün 10:00 - 00:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInfoModal(false)}
|
||||
className="w-full py-3 rounded-xl font-bold bg-stone-900 text-white text-sm"
|
||||
>
|
||||
Kapat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PublicMenuClient, type MenuCategory, type RestaurantData } from "@/components/PublicMenuClient";
|
||||
import { THEME_PRESETS, type ThemePreset } from "@/lib/theme";
|
||||
|
||||
interface TemplateGalleryClientProps {
|
||||
restaurant: RestaurantData;
|
||||
categories: MenuCategory[];
|
||||
initialThemeKey?: string;
|
||||
}
|
||||
|
||||
export function TemplateGalleryClient({
|
||||
restaurant,
|
||||
categories,
|
||||
initialThemeKey = "elegant",
|
||||
}: TemplateGalleryClientProps) {
|
||||
const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey);
|
||||
const [viewMode, setViewMode] = useState<"phone" | "fullscreen">("phone");
|
||||
|
||||
const currentPreset: ThemePreset = THEME_PRESETS[selectedThemeKey] || THEME_PRESETS.elegant!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-stone-100 flex flex-col font-sans">
|
||||
{/* Top Navbar */}
|
||||
<header className="border-b border-stone-800/80 bg-stone-900/90 backdrop-blur-xl sticky top-0 z-50 px-4 sm:px-6 py-3.5">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-3">
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="font-black text-xl tracking-tight text-amber-400">
|
||||
MENULIO
|
||||
</Link>
|
||||
<span className="text-stone-600 hidden sm:inline">/</span>
|
||||
<span className="text-xs font-semibold text-stone-400 hidden sm:inline">Şablon Galerisi</span>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle (Visible on desktop/tablet) */}
|
||||
<div className="flex items-center bg-stone-800/80 p-1 rounded-xl border border-stone-700 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("phone")}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||
viewMode === "phone" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400"
|
||||
}`}
|
||||
>
|
||||
📱 Mobil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("fullscreen")}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||
viewMode === "fullscreen" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400"
|
||||
}`}
|
||||
>
|
||||
🖥️ Tam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5 Theme Pills - Smooth Horizontal Scroll */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full md:w-auto pb-1 md:pb-0 no-scrollbar">
|
||||
{Object.values(THEME_PRESETS).map((preset) => {
|
||||
const isActive = selectedThemeKey === preset.key;
|
||||
return (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedThemeKey(preset.key)}
|
||||
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-2 whitespace-nowrap border flex-shrink-0 ${
|
||||
isActive
|
||||
? "bg-white text-stone-950 border-white shadow-lg scale-105"
|
||||
: "bg-stone-800/90 text-stone-300 border-stone-700 hover:border-stone-500 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: preset.config.primaryColor }}
|
||||
/>
|
||||
<span>{preset.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{/* View Mode Toggle (Desktop) */}
|
||||
<div className="flex items-center bg-stone-800/80 p-1 rounded-xl border border-stone-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("phone")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||
viewMode === "phone" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
📱 Telefon
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("fullscreen")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||
viewMode === "fullscreen" ? "bg-amber-400 text-stone-950 shadow" : "text-stone-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
🖥️ Tam Ekran
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
Uygulamayı İndir →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 w-full flex flex-col items-center justify-center p-0 sm:p-6 lg:p-8">
|
||||
{viewMode === "fullscreen" ? (
|
||||
/* Fullscreen Fluid View */
|
||||
<div className="w-full flex-1 min-h-[85vh]">
|
||||
<PublicMenuClient
|
||||
restaurant={restaurant}
|
||||
categories={categories}
|
||||
initialThemeKey={selectedThemeKey}
|
||||
allowThemeSwitching={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Responsive Device Simulator / Side-by-Side on Desktop */
|
||||
<div className="w-full max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 items-center justify-center py-4">
|
||||
{/* Left Info Panel (Visible on Desktop) */}
|
||||
<div className="hidden lg:flex lg:col-span-5 flex-col space-y-6 pr-4">
|
||||
<div className="space-y-2">
|
||||
<span className="text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
ŞABLON DETAYI
|
||||
</span>
|
||||
<h2 className="text-3xl font-extrabold text-white">
|
||||
{currentPreset.name}
|
||||
</h2>
|
||||
<p className="text-sm text-stone-400 leading-relaxed">
|
||||
{currentPreset.key === "elegant" && "Fine dining restoranlar, şarap evleri ve lüks steakhouse mekanlar için altın & krem tonlarında yüksek prestijli tasarım."}
|
||||
{currentPreset.key === "modern" && "Kafeler, burgerciler ve dinamik mekanlar için canlı mavi safir tonları ve hızlı arama odaklı ızgara düzeni."}
|
||||
{currentPreset.key === "dark" && "Gece kulüpleri, kokteyl barlar ve lounge mekanlar için obsidian siyahı ve kehribar parıltılı lüks mod."}
|
||||
{currentPreset.key === "minimal" && "Butik kahveciler, fırınlar ve üçüncü nesil mekanlar için ferah ve monokromatik Nordic mizanpaj."}
|
||||
{currentPreset.key === "classic" && "Geleneksel brasserie'ler, meyhaneler ve trattoria'lar için Toskana bordo ve sıcak rustik doku."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Theme Specs */}
|
||||
<div className="p-5 rounded-2xl bg-stone-900 border border-stone-800 space-y-3.5 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-stone-400">Vurgu Rengi</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: currentPreset.config.primaryColor }}
|
||||
/>
|
||||
<span className="font-mono text-stone-200">{currentPreset.config.primaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-stone-400">Tipografi Ailesi</span>
|
||||
<span className="font-semibold text-stone-200 uppercase">{currentPreset.fontFamily}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-stone-400">Ürün Mizanpajı</span>
|
||||
<span className="font-semibold text-stone-200 uppercase">{currentPreset.config.productLayout}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-stone-400">Kategori Gezintisi</span>
|
||||
<span className="font-semibold text-stone-200 uppercase">{currentPreset.config.categoryLayout}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direct Demo Link */}
|
||||
<Link
|
||||
href={`/demo?theme=${selectedThemeKey}`}
|
||||
target="_blank"
|
||||
className="w-full py-3.5 rounded-xl font-bold text-center bg-stone-800 hover:bg-stone-700 text-stone-200 border border-stone-700 transition-all text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>↗</span> Yeni Sekmede Canlı Menüyü Aç
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right Phone Mockup Container */}
|
||||
<div className="lg:col-span-7 flex justify-center w-full">
|
||||
<div className="w-full max-w-[420px] rounded-[36px] sm:rounded-[44px] overflow-hidden shadow-2xl border-0 sm:border-[8px] border-stone-800 bg-stone-900 relative">
|
||||
{/* Dynamic Island (Desktop only) */}
|
||||
<div className="hidden sm:block w-24 h-4 bg-stone-800 rounded-full mx-auto mt-2 mb-1" />
|
||||
|
||||
{/* Simulated Screen Body */}
|
||||
<div className="overflow-y-auto max-h-[85vh] sm:max-h-[780px] rounded-none sm:rounded-[32px] no-scrollbar">
|
||||
<PublicMenuClient
|
||||
restaurant={restaurant}
|
||||
categories={categories}
|
||||
initialThemeKey={selectedThemeKey}
|
||||
allowThemeSwitching={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { MenuCategory, RestaurantData } from "@/components/PublicMenuClient";
|
||||
|
||||
export const DEMO_RESTAURANT: RestaurantData = {
|
||||
id: "demo-restaurant-1",
|
||||
name: "Gusto & Co. Brasserie",
|
||||
slug: "gusto-brasserie",
|
||||
logo_url: null,
|
||||
phone: "+90 (212) 555 0192",
|
||||
address: "Nişantaşı, Abdi İpekçi Cad. No: 42, İstanbul",
|
||||
};
|
||||
|
||||
export const DEMO_CATEGORIES: MenuCategory[] = [
|
||||
{
|
||||
id: "cat-starters",
|
||||
name: "Başlangıçlar & Meze",
|
||||
description: "Özenle seçilmiş taze malzemeler ve paylaşımlık tabaklar",
|
||||
is_active: true,
|
||||
menu_items: [
|
||||
{
|
||||
id: "item-1",
|
||||
name: "Truffle Burrata & Çeri Domates",
|
||||
description: "Manda burrata, trüf yağı, fırınlanmış karamelize çeri domates ve fesleğen pesto sosu ile.",
|
||||
price: 420,
|
||||
image_url: "https://images.unsplash.com/photo-1592417817098-8f3d69102353?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-2",
|
||||
name: "Dana Carpaccio",
|
||||
description: "İnce dilimlenmiş marine bonfile, roka, parmesan talaşı, kapari ve trüflü balzamik glaze.",
|
||||
price: 490,
|
||||
image_url: "https://images.unsplash.com/photo-1544025162-d76694265947?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-3",
|
||||
name: "Çıtır Kalamar & Aioli",
|
||||
description: "Ege kalamarı, hafif mısır unlu kaplama, köz biberli ev yapımı aioli sos ve taze limon dilimleri.",
|
||||
price: 380,
|
||||
image_url: "https://images.unsplash.com/photo-1599488615731-7e5c2823ff28?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-mains",
|
||||
name: "Ana Yemekler & Izgaralar",
|
||||
description: "Kömür ateşinde dinlendirilmiş etler ve şefin özel tarifleri",
|
||||
is_active: true,
|
||||
menu_items: [
|
||||
{
|
||||
id: "item-4",
|
||||
name: "Dry-Aged Ribeye Steak (300g)",
|
||||
description: "28 gün kuru dinlendirilmiş antrikot, trüflü patates püresi, ızgara kuşkonmaz ve biberiye sosu.",
|
||||
price: 890,
|
||||
image_url: "https://images.unsplash.com/photo-1600891964599-f61ba0e24092?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-5",
|
||||
name: "Trüflü Ev Yapımı Pappardelle",
|
||||
description: "Taze el açması makarna, yaban mantarları kreması, taze trüf mantarı rendesi ve 24 aylık parmesan.",
|
||||
price: 520,
|
||||
image_url: "https://images.unsplash.com/photo-1621996346565-e3d5d6281691?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-6",
|
||||
name: "Fırınlanmış Norveç Somonu",
|
||||
description: "Közlenmiş rezene, fırın tatlı patates, taze otlar ve narenciye beurre blanc sosu eşliğinde.",
|
||||
price: 680,
|
||||
image_url: "https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-7",
|
||||
name: "Gusto Signature Smash Burger",
|
||||
description: "İkili 100g kuru dinlendirilmiş köfte, duble cheddar peyniri, karamelize soğan, trüf mayonez ve brioche ekmeği.",
|
||||
price: 440,
|
||||
image_url: "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-cocktails",
|
||||
name: "İmza Kokteyller & İçecekler",
|
||||
description: "Miksolojistlerimiz tarafından hazırlanan taze meyveli kokteyller",
|
||||
is_active: true,
|
||||
menu_items: [
|
||||
{
|
||||
id: "item-8",
|
||||
name: "Smoked Rosemary Old Fashioned",
|
||||
description: "Meşe fıçıda dinlendirilmiş burbon, Angostura bitter, tütsülenmiş taze biberiye ve portakal kabuğu.",
|
||||
price: 410,
|
||||
image_url: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-9",
|
||||
name: "Passionfruit & Chili Margarita",
|
||||
description: "Tekila reposado, çarkıfelek meyvesi püresi, taze misket limonu suyu, agave ve acı biberli tuz çemberi.",
|
||||
price: 390,
|
||||
image_url: "https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-10",
|
||||
name: "Artisan Soğuk Demleme Kahve (Cold Brew)",
|
||||
description: "18 saat soğuk demlenmiş tek kökenli Etiyopya Yirgacheffe çekirdekleri, buz ile servis edilir.",
|
||||
price: 160,
|
||||
image_url: "https://images.unsplash.com/photo-1517701550927-30cf4ba1dba5?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-desserts",
|
||||
name: "Tatlılar",
|
||||
description: "Günün tatlı sonu için taze pastacılık lezzetleri",
|
||||
is_active: true,
|
||||
menu_items: [
|
||||
{
|
||||
id: "item-11",
|
||||
name: "San Sebastián Cheesecake & Belçika Çikolatası",
|
||||
description: "Akışkan fırınlanmış Bask cheesecake, eritilmiş sıcak Callebaut bitter çikolatası ile.",
|
||||
price: 280,
|
||||
image_url: "https://images.unsplash.com/photo-1533134242443-d4fd215305ad?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: "item-12",
|
||||
name: "Sıcak Çikolatalı Fondan & Vanilyalı Dondurma",
|
||||
description: "Akışkan lav kek, Madagaskar vanilyalı dondurma ve çıtır fındık krokant.",
|
||||
price: 310,
|
||||
image_url: "https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=600&auto=format&fit=crop&q=80",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!url || !anonKey) {
|
||||
throw new Error("NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY missing");
|
||||
}
|
||||
|
||||
// Anon key only — RLS enforces that only published-menu data is readable here.
|
||||
export const supabase = createClient(url, anonKey);
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ThemeConfig } from "@menulio/shared";
|
||||
|
||||
export interface ThemePreset {
|
||||
key: string;
|
||||
name: string;
|
||||
fontFamily: "serif" | "sans" | "heading";
|
||||
config: ThemeConfig;
|
||||
cardBg: string;
|
||||
cardBorder: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
accentBg: string;
|
||||
headerGradient: string;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
}
|
||||
|
||||
export const THEME_PRESETS: Record<string, ThemePreset> = {
|
||||
elegant: {
|
||||
key: "elegant",
|
||||
name: "Elegant Gold",
|
||||
fontFamily: "serif",
|
||||
config: {
|
||||
primaryColor: "#C8A96B",
|
||||
background: "#FAF8F5",
|
||||
productLayout: "card",
|
||||
categoryLayout: "accordion",
|
||||
},
|
||||
cardBg: "#FFFFFF",
|
||||
cardBorder: "rgba(200, 169, 107, 0.18)",
|
||||
textPrimary: "#1C1917",
|
||||
textSecondary: "#78716C",
|
||||
accentBg: "rgba(200, 169, 107, 0.12)",
|
||||
headerGradient: "linear-gradient(180deg, #1C1917 0%, #292524 100%)",
|
||||
badgeBg: "#F5F0E6",
|
||||
badgeText: "#926E27",
|
||||
},
|
||||
modern: {
|
||||
key: "modern",
|
||||
name: "Modern Sapphire",
|
||||
fontFamily: "sans",
|
||||
config: {
|
||||
primaryColor: "#2563EB",
|
||||
background: "#F8FAFC",
|
||||
productLayout: "card",
|
||||
categoryLayout: "tabs",
|
||||
},
|
||||
cardBg: "#FFFFFF",
|
||||
cardBorder: "rgba(226, 232, 240, 0.9)",
|
||||
textPrimary: "#0F172A",
|
||||
textSecondary: "#64748B",
|
||||
accentBg: "rgba(37, 99, 235, 0.08)",
|
||||
headerGradient: "linear-gradient(135deg, #1E293B 0%, #0F172A 100%)",
|
||||
badgeBg: "#EFF6FF",
|
||||
badgeText: "#1D4ED8",
|
||||
},
|
||||
dark: {
|
||||
key: "dark",
|
||||
name: "Luxury Dark",
|
||||
fontFamily: "heading",
|
||||
config: {
|
||||
primaryColor: "#F59E0B",
|
||||
background: "#0F0F12",
|
||||
productLayout: "card",
|
||||
categoryLayout: "accordion",
|
||||
},
|
||||
cardBg: "#18181D",
|
||||
cardBorder: "rgba(255, 255, 255, 0.07)",
|
||||
textPrimary: "#F8FAFC",
|
||||
textSecondary: "#94A3B8",
|
||||
accentBg: "rgba(245, 158, 11, 0.12)",
|
||||
headerGradient: "linear-gradient(180deg, #09090B 0%, #18181B 100%)",
|
||||
badgeBg: "#27272A",
|
||||
badgeText: "#FBBF24",
|
||||
},
|
||||
minimal: {
|
||||
key: "minimal",
|
||||
name: "Nordic Minimal",
|
||||
fontFamily: "sans",
|
||||
config: {
|
||||
primaryColor: "#18181B",
|
||||
background: "#FAFAFA",
|
||||
productLayout: "list",
|
||||
categoryLayout: "flat",
|
||||
},
|
||||
cardBg: "#FFFFFF",
|
||||
cardBorder: "#E4E4E7",
|
||||
textPrimary: "#18181B",
|
||||
textSecondary: "#71717A",
|
||||
accentBg: "#F4F4F5",
|
||||
headerGradient: "linear-gradient(180deg, #27272A 0%, #18181B 100%)",
|
||||
badgeBg: "#F4F4F5",
|
||||
badgeText: "#27272A",
|
||||
},
|
||||
classic: {
|
||||
key: "classic",
|
||||
name: "Classic Bistro",
|
||||
fontFamily: "serif",
|
||||
config: {
|
||||
primaryColor: "#8B1E1E",
|
||||
background: "#FFF9F2",
|
||||
productLayout: "list",
|
||||
categoryLayout: "accordion",
|
||||
},
|
||||
cardBg: "#FFFFFF",
|
||||
cardBorder: "rgba(139, 30, 30, 0.15)",
|
||||
textPrimary: "#2D1B18",
|
||||
textSecondary: "#7A6966",
|
||||
accentBg: "rgba(139, 30, 30, 0.08)",
|
||||
headerGradient: "linear-gradient(180deg, #38120F 0%, #240B0A 100%)",
|
||||
badgeBg: "#FDF2F0",
|
||||
badgeText: "#8B1E1E",
|
||||
},
|
||||
};
|
||||
|
||||
const ELEGANT_PRESET: ThemePreset = THEME_PRESETS.elegant!;
|
||||
|
||||
export const DEFAULT_THEME: ThemeConfig = ELEGANT_PRESET.config;
|
||||
|
||||
export function resolveThemePreset(themeKey?: string, customConfig?: Partial<ThemeConfig>): ThemePreset {
|
||||
const base = (themeKey ? THEME_PRESETS[themeKey] : undefined) ?? ELEGANT_PRESET;
|
||||
if (!customConfig) return base;
|
||||
|
||||
return {
|
||||
...base,
|
||||
config: {
|
||||
...base.config,
|
||||
...customConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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 host = req.headers.get("host") ?? "";
|
||||
const hostname = host.split(":")[0] ?? host;
|
||||
|
||||
const isRootDomain = hostname === ROOT_DOMAIN || hostname === `www.${ROOT_DOMAIN}`;
|
||||
if (isRootDomain || hostname === "localhost") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const subdomain = hostname.endsWith(`.${ROOT_DOMAIN}`)
|
||||
? hostname.replace(`.${ROOT_DOMAIN}`, "")
|
||||
: hostname; // custom domain — resolved to a slug via domains table at render time
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/menu/${subdomain}${req.nextUrl.pathname}`;
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", "system-ui", "sans-serif"],
|
||||
heading: ["var(--font-heading)", "sans-serif"],
|
||||
serif: ["var(--font-serif)", "Georgia", "serif"],
|
||||
},
|
||||
colors: {
|
||||
amber: {
|
||||
450: "#F5A524",
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards",
|
||||
"slide-up": "slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0", transform: "translateY(8px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { opacity: "0", transform: "translateY(16px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "src", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# menul.io — UI/UX Tasarım Promptları (Stitch & AI UI Engine) 🎨
|
||||
|
||||
Bu dokümanda **menul.io** platformunun tüm mobil ve web ekranları için **Stitch (Google Stitch)**, Figma AI, Midjourney UI ve UI Generation araçlarında kullanılabilecek **kapsamlı ve detaylı tasarım promptları** yer almaktadır.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Tasarım Dili & Tasarım Sistemi Standartları (Tüm Ekranlar İçin Ortak)
|
||||
* **Marka:** `menul.io` (AI Destekli Yeni Nesil QR Menü SaaS)
|
||||
* **Renk Paleti:**
|
||||
* Ana Renk: Şampanya Altını (`#C8A96B` / `#D4AF37`) & Amber (`#F59E0B`)
|
||||
* Arka Plan: Koyu Lüks Mod (`#0F0F12` / `#18181B`) ve İpeksi Krem Açık Mod (`#FAF8F5`)
|
||||
* Vurgu & Durum: Canlı Zümrüt Yeşili (`#10B981` / Yayında), Canlı Kırmızı (`#EF4444` / Tükendi - 86)
|
||||
* **Tipografi:** Modern Serif başlıklar (Playfair Display / Serif), gövde metinleri için kristal netliğinde sans-serif (Inter / Plus Jakarta Sans).
|
||||
* **Tasarım Hissi:** Apple HIG ergonomisi, yumuşak cam efektleri (Glassmorphism), derin gölgeler (Soft ambient shadows), 16-24px rounded border radius.
|
||||
|
||||
---
|
||||
|
||||
## 1. 📱 Mobil Menü Editörü Ana Ekranı (Mobile Menu Management Screen)
|
||||
> **Kullanım:** Restoran sahibinin cebinden menüyü, kategorileri, stok durumunu (86) ve fiyatları yönettiği ana kontrol merkezi.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
Design a luxury mobile app screen for "menul.io" restaurant menu editor in iOS 18 style.
|
||||
Header: Modern white navigation bar with restaurant title "Gusto & Co. Brasserie", live subdomain indicator "localhost:3000/menu/gusto", active green status badge "Yayında", a golden palette icon for template selection, a QR icon, and a profile avatar.
|
||||
Subheader: Floating horizontal category pill bar with rounded chips ("Başlangıçlar", "Ana Yemekler", "Makarnalar", "Tatlılar", "İçecekler"), active pill highlighted in champagne gold (#C8A96B) with item count badge.
|
||||
Body: Scrollable list of sleek gourmet menu item cards. Each card features:
|
||||
- A rounded 64x64 food thumbnail image (e.g. Truffle Burrata, Wagyu Burger, Ribeye Steak)
|
||||
- Dish title in bold charcoal typography, description in muted stone gray, and golden price tag "380 ₺"
|
||||
- Quick stock availability toggle switch (Active green vs Tükendi red badge)
|
||||
- "Düzenle" chevron button.
|
||||
Floating Actions:
|
||||
- Prominent dashed card at the top "✨ AI ile Menü Tara (Fotoğraf Çek)".
|
||||
Bottom Bar: Pinned sticky floating action bar with blurred glassmorphism:
|
||||
- Left: "👁️ Önizle" button in champagne gold border.
|
||||
- Right: Large primary dark button "Değişiklikleri Yayınla" with a subtle cloud-sync icon.
|
||||
Clean, intuitive mobile ergonomics, luxury hospitality SaaS aesthetics.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 📸 AI Menü Tarayıcı & Canlı OCR Analiz Ekranı (AI Menu Scanner & Live Processing)
|
||||
> **Kullanım:** Restoran sahibinin basılı kağıt menünün fotoğrafını çektiği ve AI'ın görseli analiz ettiği sihirli an.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
Mobile camera viewfinder and AI extraction screen for "menul.io" on iPhone 17 Pro frame.
|
||||
Screen State: Full-screen camera interface scanning an open luxury restaurant paper menu laying on a dark wooden table.
|
||||
Overlays & Augmented Reality Effects:
|
||||
- Glowing laser scanning beam sweeping vertically across the menu with glowing golden particle effects.
|
||||
- Dynamic bounding boxes snapping around detected menu categories ("SOĞUK BAŞLANGIÇLAR", "ÖZEL ETLER") and prices ("240 ₺", "680 ₺") with confidence tags (98%, 95%).
|
||||
Bottom Sheet: Translucent frosted glass card sliding up with an animated step-by-step progress checklist:
|
||||
1. "Görsel işleniyor ve kontrast optimize ediliyor" (Completed with green checkmark)
|
||||
2. "Menulio Vision AI kategorileri ve lezzetleri ayrıştırıyor" (Active with spinning golden loader)
|
||||
3. "Fiyatlar ve porsiyon notları dijitalleştiriliyor" (Pending gray)
|
||||
Controls: Large circular shutter button with pulsing golden ring, gallery upload shortcut, and flash toggle.
|
||||
Futuristic food-tech UI, sleek, ultra-modern, high-tech AI feel.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. ✨ AI Review & Akıllı Onaylama Ekranı (AI Extracted Review & Bulk Approval)
|
||||
> **Kullanım:** AI tarafından çıkarılan ürünlerin listelendiği, düşük güven skorlu öğelerin uyarıldığı ve tek tıkla menüye aktarıldığı ekran.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
Mobile UI review screen for AI-extracted menu items before publishing on "menul.io".
|
||||
Header: "AI Taraması Tamamlandı 🎉", subtext "24 Ürün ve 4 Kategori Başarıyla Ayrıştırıldı", confidence meter showing 96% accuracy.
|
||||
Body Content:
|
||||
- Category group accordion card "Ana Yemekler (8 Ürün)".
|
||||
- Editable row items with inline price and title fields.
|
||||
- Special attention card for low-confidence item: highlighted with soft amber background and caution badge "⚠️ Düşük Güven (Fiyatı Kontrol Edin: 180 ₺ mi 280 ₺ mi?)".
|
||||
- Action to re-take photo or add missing item.
|
||||
Bottom Sticky Bar:
|
||||
- Secondary button: "Yeniden Tara".
|
||||
- Primary large button: "✓ Menüyü Onayla ve Menüme Ekle" in deep charcoal with golden shimmer border.
|
||||
Professional, trust-inspiring fintech/foodtech review workflow layout.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 🎨 5 Farklı Menü Şablonu Seçici Çekmecesi (Theme Selector Bottom Sheet)
|
||||
> **Kullanım:** Restoran sahibinin menü konseptine en uygun 5 özel temayı canlı inceleyip tek dokunuşla seçtiği çekmece.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
iOS modal bottom sheet showcasing 5 distinct restaurant theme presets for "menul.io".
|
||||
Header: "Menü Şablonunu Seçin" with drag handle bar and close button.
|
||||
Theme Cards (Vertical scrollable stack with radio selectors and live mini color swatches):
|
||||
1. "🌟 Elegant Gold" - Fine dining & lüks restoranlar (Altın & Krem paleti, serif tipografi) - Selected active state with glowing border.
|
||||
2. "💎 Modern Sapphire" - Kafe & Fast Casual (Mavi safir & beyaz, 2'li grid layout).
|
||||
3. "🌙 Luxury Dark" - Gece kulübü, Kokteyl bar & Lounge (Obsidian siyahı & Kehribar vurgu).
|
||||
4. "❄️ Nordic Minimal" - Butik kahveci & Fırın (Sade monokrom liste, ferah negatif alan).
|
||||
5. "🍷 Classic Bistro" - Meyhane & Trattoria (Toskana bordo & rustik doku).
|
||||
Each card includes theme name, recommended cuisine badge, one-line atmosphere description, and active checkmark.
|
||||
Bottom Button: "Şablonu Menüme Uygula".
|
||||
Rich UI, card shadows, tactile micro-interactions.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 🍽️ Müşteri Tarafı Canlı Web QR Menüsü (5 Theme Public Web Menu)
|
||||
> **Kullanım:** Müşterinin masadaki QR kodu okuttuğunda telefon tarayıcısında açılan ultra hızlı ve lüks web menüsü.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
Responsive mobile web application interface for a public digital QR restaurant menu ("Gusto & Co. Brasserie").
|
||||
Header:
|
||||
- High-resolution restaurant circular logo, restaurant name in serif font, table badge "Masa #12", language selector (TR / EN).
|
||||
- Live interactive search bar "Yemek, içecek veya tatlı ara...".
|
||||
Navigation: Sticky horizontal category navigation bar with smooth active pill indicator.
|
||||
Menu Content Grid:
|
||||
- Category Banner: "Şefin İmzaları" with Italian subtitle.
|
||||
- Large gourmet food cards with full-width mouth-watering food photography, dietary badges (🌱 Vegan, 🌾 Glutensiz, 🌶️ Acı), item name, detailed ingredients list, and price in bold primary theme color.
|
||||
Floating Sticky Footer:
|
||||
- Floating pill widget: "🔔 Garson Çağır" and "💳 Hesap İste (Nakit / Kredi Kartı)" buttons.
|
||||
- "Powered by menul.io" subtle luxury footer credit.
|
||||
Ultra clean mobile-first web interface, blazing fast feel, Michelin-guide aesthetics.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 🖨️ Masa QR Standı & Yüksek Çözünürlük Baskı Üretici (QR Stand Print Generator)
|
||||
> **Kullanım:** Restoran sahibinin masalara koyacağı ahşap/pleksi QR standı veya A4/A5 masa kartını PDF/PNG olarak indirdiği ekran.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
Mobile screen for generating table QR codes and print-ready tent cards in "menul.io".
|
||||
Visual Centerpiece: A realistic 3D mockup of a luxury acrylic table tent card with a dark wood base standing on a restaurant marble tabletop.
|
||||
The card displays:
|
||||
- Gold engraved restaurant logo at top.
|
||||
- "Masadan Temassız Menüyü İnceleyin" heading.
|
||||
- Crisp vector QR code in the center pointing to "menu.restoran.com".
|
||||
- "Kameranızı QR koda tutmanız yeterlidir" micro-copy.
|
||||
- "Masa No: 08" customizer box.
|
||||
Controls below:
|
||||
- Size switcher pills: "Masa Pleksi (10x15cm)", "A5 Menü Kartı", "A4 Stand".
|
||||
- Color style presets: "Lüks Altın & Siyah", "Klasik Beyaz & Ahşap", "Minimalist Şeffaf".
|
||||
- Download Buttons: "📥 PDF Baskı Dosyasını İndir (300 DPI Vector)" and "📤 Paylaş".
|
||||
Print-ready SaaS export screen, high fidelity 3D product mockup.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 🌐 Özel Alan Adı (Custom Domain: `menu.restoran.com`) Yönetim Ekranı
|
||||
> **Kullanım:** Restoranın menüsünü kendi web sitesinin alt alan adına (`menu.kebapciahmet.com`) bağladığı ve DNS CNAME durumunu kontrol ettiği ayarlar ekranı.
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
Settings screen for "Custom Domain / Özel Alan Adı" inside "menul.io" mobile app.
|
||||
Card 1: "Aktif Menü Adresiniz": Shows default link "localhost:3000/menu/kebapci-ahmet" with a "Kopyala" and "Tarayıcıda Aç" button.
|
||||
Card 2: "Kendi Alan Adınızı Bağlayın (Custom Domain)":
|
||||
- Input textfield with placeholder "menu.restoraniniz.com".
|
||||
- "Bağla" button.
|
||||
- Domain status card: `menu.kebapciahmet.com` with yellow pulsing status badge "DNS Doğrulaması Bekleniyor".
|
||||
- Step-by-step DNS instruction box:
|
||||
- Kayıt Türü: CNAME
|
||||
- Host / Alt Alan: menu
|
||||
- Yönlendirilecek Hedef: cname.menul.io
|
||||
- "⚡ DNS Kaydını Doğrula" primary button.
|
||||
- Help tooltip: "Cloudflare, GoDaddy, Natro vb. panellerinizden kolayca ekleyebilirsiniz."
|
||||
Clean developer-grade yet accessible business SaaS interface.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 🚀 menul.io SaaS Landing Page (Desktop & Mobile Web)
|
||||
> **Kullanım:** menul.io ana pazarlama ve satış sayfası (Hero, canlı simülatör, özellikler, şablon galerisi).
|
||||
|
||||
```text
|
||||
Stitch prompt:
|
||||
High-converting, ultra-premium dark mode SaaS landing page for "menul.io" (AI Restaurant QR Menu Platform).
|
||||
Hero Section:
|
||||
- Top badge: "✨ Yapay Zeka Destekli Yeni Nesil QR Menü Platformu".
|
||||
- Headline: "Basılı Menünüzü 3 Dakikada Lüks Dijital QR Menüye Dönüştürün".
|
||||
- Subheadline: "Menünüzün fotoğrafını çekin; AI saniyeler içinde tüm lezzetleri, fiyatları ve kategorileri dijitalleştirsin. PDF QR kodlara ve pahalı ajanslara son verin."
|
||||
- CTA Group: Large glowing champagne gold button "Hemen Ücretsiz Başla (7 Gün Deneme)" + "📱 Canlı Menü Demosunu İncele".
|
||||
Visual Feature:
|
||||
- Split screen interactive mockup: On the left, a mobile phone taking a photo of a restaurant paper menu with neon AI laser grid; on the right, the resulting stunning 3D phone displaying the live interactive gold gourmet menu.
|
||||
Features Bento Grid:
|
||||
1. "Fotoğraftan Menüye (Vision AI Engine)"
|
||||
2. "5 Lüks Tasarım Şablonu"
|
||||
3. "Kendi Alan Adınız (menu.restoraniniz.com)"
|
||||
4. "Masa QR & Canlı Stok (Tükendi / 86 Modu)"
|
||||
5. "Canlı Arama & Garson Çağır Butonu"
|
||||
Testimonials & Michelin-partner restaurant logos ribbon at bottom.
|
||||
Sleek dark obsidian glassmorphism, Framer/Linear award-winning design taste.
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# Progress Log
|
||||
|
||||
Faz tanımları için [ROADMAP.md](./ROADMAP.md). Bu dosya ne yapıldığını, ne yapılmadığını ve bilinen sınırlamaları takip eder — faz atlarken buraya bakıp neyin gerçek neyin stub olduğunu hatırlamak için.
|
||||
|
||||
## Faz 0 — Altyapı ✅ (2026-08-19)
|
||||
|
||||
- pnpm monorepo: `apps/api` (Fastify), `apps/web` (Next.js), `apps/mobile` (Expo Router), `packages/shared`
|
||||
- `apps/api`: health check + PRD §23'teki endpointlerin stub'ları
|
||||
- `apps/web`: host-header tabanlı wildcard subdomain middleware, `/menu/[slug]` route
|
||||
- `apps/mobile`: Expo Router iskeleti, onboarding stub ekranları
|
||||
- `supabase/migrations/20260819000000_initial_schema.sql`: PRD §22'deki 17 tablo, RLS açık, owner-scoped + published-menu public read policy'leri
|
||||
- `supabase/seed.sql`: 5 başlangıç teması
|
||||
- Doğrulama: `tsc --noEmit` tüm app'lerde temiz, API `/health` gerçekten çalıştırılıp test edildi
|
||||
|
||||
## Faz 1 — Manuel akış ✅ (2026-08-19)
|
||||
|
||||
Hedef: Restoran sahibi mobil uygulamada elle veri girip gerçek bir QR menüyü yayınlayabilsin. **Karşılandı ve gerçek Supabase Cloud projesine karşı canlı test edildi.**
|
||||
|
||||
- [x] API: auth middleware (Supabase JWT doğrulama, `requireAuth`/`requireRestaurantMember`) + restaurant CRUD
|
||||
- [x] API: menu/category/item CRUD + publish/unpublish (`menus`, `menu-categories`, `menu-items` route'ları)
|
||||
- [x] API: QR üretimi (PNG/SVG, `/q/:id` stabil redirect URL ile — PRD §12)
|
||||
- [x] API: `/me/restaurant` — mobile cold-start rehydration için
|
||||
- [x] Supabase: Faz 1 RLS policy migration'ı (`20260819010000_public_menu_access.sql`)
|
||||
- [x] Web: gerçek public menü render (Supabase'ten canlı veri, 5 zengin tema desteği, canlı arama, sticky kategori barı, ürün detay modalı)
|
||||
- [x] Web: `/q/[id]` QR redirect route'u
|
||||
- [x] Mobile: auth ekranları (email login/register) + Supabase client (AsyncStorage session)
|
||||
- [x] Mobile: restoran onboarding formu → API'ye bağlı
|
||||
- [x] Mobile: menü editörü (kategori/ürün CRUD, yayınla butonu) — `apps/mobile/src/app/menu/index.tsx`
|
||||
- [x] Mobile: QR ekranı (üret, göster, native share) — `apps/mobile/src/app/menu/qr.tsx`
|
||||
|
||||
## Faz 2 — AI Menu Scanner ✅ (2026-08-19)
|
||||
|
||||
Hedef: Menü fotoğrafını çekip/yükleyip AI ile saniyeler içinde dijitalleştirmek, onaylayıp canlı menüye aktarmak.
|
||||
|
||||
- [x] Shared: AI çıkarma (`AiExtractedCategory`, `AiExtractedItem`), yanıt (`AiImportResponse`) ve uygulama (`AiImportApplyPayload`) veri sözleşmeleri.
|
||||
- [x] API: `apps/api/src/lib/ai-scanner.ts` — Gemini 1.5 Flash Vision, OpenAI GPT-4o Vision ve akıllı mutfak sezgisel fallback motoru.
|
||||
- [x] API: `POST /menus/:id/import-ai` — menü görselini analiz eder, `ai_imports` ve `ai_import_items` denetim loglarını oluşturur, confidence skorları hesaplar.
|
||||
- [x] API: `GET /ai-imports/:id` — kaydedilmiş import durumunu döner.
|
||||
- [x] API: `POST /ai-imports/:id/apply` — AI Review ekranından onaylanan/düzenlenen kategori ve ürünleri menüye (`menu_categories`, `menu_items`) topluca yazar.
|
||||
- [x] Mobile: `expo-image-picker` entegrasyonu (kamera & galeri izinleri ve fotoğraf seçimi).
|
||||
- [x] Mobile: `apps/mobile/src/app/(onboarding)/scan.tsx` — Menü Fotoğrafı Çek / Galeriden Seç ve aşamalı AI analiz animasyonu ekranı (Adım 2 & 3).
|
||||
- [x] Mobile: `apps/mobile/src/app/(onboarding)/ai-review.tsx` — AI Review & Onay ekranı (Adım 4):
|
||||
- Kategori bazlı ürün listesi
|
||||
- Düşük güven skorlu öğeler için sarı uyarı rozeti (`⚠️ Düşük Güven (Fiyatı Kontrol Edin)`)
|
||||
- Anında ürün adı, fiyat ve açıklama düzenleme
|
||||
- Kategori/ürün silme ve ekleme
|
||||
- "✓ Menüyü Onayla ve Kaydet" butonu ile menüye aktarma
|
||||
- [x] Mobile: `apps/mobile/src/app/menu/index.tsx` içerisine "✨ AI ile Menü Fotoğrafı Tara" tetikleyicisi.
|
||||
- [x] Mobile: `apps/mobile/src/app/account/index.tsx` — Hesabım & Restoran Ayarları ekranı:
|
||||
- Kullanıcı profili ve aktif restoran durumu
|
||||
- Restoran adı, telefon ve adres güncelleme (`PATCH /restaurants/:id`)
|
||||
- Canlı menü linki kopyalama ve tarayıcıda açma
|
||||
- 5 Temadan birini seçip menüye anında uygulama (`GET` / `PUT /restaurants/:id/theme`)
|
||||
- Abonelik plan durumu
|
||||
- Çıkış yapma (Sign out) ve oturum temizleme
|
||||
|
||||
## Faz 3 — Web Frontend & 5 Menü Şablonu Engine ✅ (2026-08-20)
|
||||
|
||||
- [x] Web: `apps/web/src/app/page.tsx` — Lüks Menulio SaaS Landing Page (Hero, 5 Şablon vitrini, Nasıl Çalışır, İstatistikler, Canlı Demo linkleri).
|
||||
- [x] Web: `apps/web/src/lib/theme.ts` — 5 Ayrı Tasarım Şablonu Tanımları:
|
||||
1. 🌟 **Elegant Gold** (Fine dining & lüks restoranlar - Altın & Krem, serif tipografi)
|
||||
2. 💎 **Modern Sapphire** (Kafe & Fast-Casual - Mavi safir & beyaz, 2'li grid layout)
|
||||
3. 🌙 **Luxury Dark** (Gece kulübü & Kokteyl bar - Obsidian & Kehribar lüks mod)
|
||||
4. ❄️ **Nordic Minimal** (Butik kahveci & fırın - Sade monokrom liste mizanpajı)
|
||||
5. 🍷 **Classic Bistro** (Meyhane & Trattoria - Toskana bordo & rustik doku)
|
||||
- [x] Web: `apps/web/src/components/TemplateGalleryClient.tsx` & `/templates` — İnteraktif şablon galerisi ve canlı telefon simülatörü.
|
||||
- [x] Web: `apps/web/src/app/demo/page.tsx` — Tam ekran interaktif demo menü.
|
||||
- [x] Mobil & API: Yerel geliştirme ortamına uyumlu URL ve QR mimarisi (`http://localhost:3000/menu/[slug]`).
|
||||
- [x] Mobil: `ThemeSelectorSheet.tsx` — Menü editöründen tek tıkla 5 şablondan birini seçme ve anında aktifleştirme.
|
||||
- [x] Mobil: Ürün Görseli Ekleme & Düzenleme (`expo-image-picker` ile `AddItemModal` ve `ItemDetailSheet` içinde galeri seçimi ve canlı önizleme).
|
||||
- [x] Mobil: `MenuItemCard.tsx` — Menü ürün kartlarında görsel küçük resim (thumbnail) desteği.
|
||||
- [x] Mobil: `AccountScreen.tsx` — Restoran Logosu yükleme/değiştirme ve detaylı restoran bilgileri güncelleme.
|
||||
- [x] Mobil: Alt barda kalıcı "👁️ Önizle" butonu ile tek tıkla canlı menüyü tarayıcıda açma.
|
||||
- [x] Web: `apps/web/src/components/PublicMenuClient.tsx` — Anında arama, masa numarası parametresi (`?table=X`), garson çağır / hesap iste floating barı, ürün detay modalı ve canlı tema değiştirici çekmecesi.
|
||||
|
||||
**Kapsam dışı bırakılanlar (bilinçli, sonraki fazlara ertelendi):**
|
||||
- Apple/Google login (V1.5+) — sadece email auth
|
||||
- Custom domain (V1.5) — sadece otomatik subdomain
|
||||
- Abonelik/RevenueCat (Faz 5) — bu fazda erişim kısıtı yok
|
||||
- Logo yükleme (media/storage akışı V1.5)
|
||||
@@ -0,0 +1,106 @@
|
||||
# AI QR Menü SaaS — Ürün Roadmap & Faz Planı
|
||||
|
||||
> Bu doküman [AI_QR_Menu_SaaS_PRD.md](./AI_QR_Menu_SaaS_PRD.md) temel ürün dokümanının üzerine inşa edilmiştir. PRD "ne inşa ediyoruz"u tanımlar, bu doküman "hangi sırayla ve ne zaman" inşa ediyoruz'u tanımlar. Ne tamamlandığının detayı için [PROGRESS.md](./PROGRESS.md).
|
||||
>
|
||||
> **Durum:** Faz 0 ✅ · Faz 1 ✅ (2026-08-19) · Faz 2'ye başlanmadı.
|
||||
|
||||
## Yaklaşım
|
||||
|
||||
MVP önce, sonra kademeli genişleme. MVP içinde bile önce **manuel akış** (AI'sız) kurulur — amaç mobile↔API↔Supabase↔public web↔domain↔QR zincirinin uçtan uca çalıştığını en erken kanıtlamaktır. AI, bu iskelet üzerine eklenir; iskeleti riske atmaz.
|
||||
|
||||
---
|
||||
|
||||
## MVP (V1)
|
||||
|
||||
Hedef: Restoran sahibi mobil uygulamayla hesap açabilsin, menü fotoğrafını çekebilsin, AI dijitalleştirsin, tema seçip yayınlayabilsin, QR alabilsin, abone olabilsin.
|
||||
|
||||
### Faz 0 — Altyapı
|
||||
- Monorepo yapısı (mobile / api / public-web)
|
||||
- Supabase local + production şema: users, restaurants, restaurant_members, locations, menus, menu_categories, menu_items, menu_item_options, themes, restaurant_themes, domains, qr_codes, ai_imports, ai_import_items, subscriptions, analytics_events, media
|
||||
- Auth: email, Apple, Google
|
||||
- Node.js API iskeleti (auth middleware, error handling, temel endpointler)
|
||||
|
||||
### Faz 1 — Manuel akış (iskelet doğrulama)
|
||||
- Restoran oluştur / düzenle (isim, logo, telefon, adres)
|
||||
- Kategori/ürün manuel CRUD (mobil)
|
||||
- Otomatik subdomain ataması (slug normalize, benzersizlik)
|
||||
- Public web: 1 varsayılan tema ile menü render
|
||||
- QR kod üretimi (PNG/SVG)
|
||||
- **Çıkış kriteri:** Bir restoran sahibi elle veri girip gerçek bir QR menüyü yayınlayabiliyor.
|
||||
|
||||
### Faz 2 — AI Menu Scanner
|
||||
- Kamera / galeri / çoklu fotoğraf yükleme
|
||||
- AI extraction pipeline (kategori/ürün/fiyat + confidence skoru)
|
||||
- AI Review ekranı — sonuç asla direkt yayınlanmaz, kullanıcı onaylar/düzeltir
|
||||
- `ai_imports` / `ai_import_items` kayıt altına alma (hata analizi, model değişimi için)
|
||||
- Düşük confidence uyarısı ("⚠ Fiyatı kontrol edin")
|
||||
|
||||
### Faz 3 — Theme Engine (minimal)
|
||||
- 2-3 başlangıç teması (örn. Elegant, Modern, Minimal)
|
||||
- Theme config şeması (JSON), data/presentation ayrımı
|
||||
- Mobilden preview
|
||||
|
||||
### Faz 4 — QR & Otomatik Domain
|
||||
- QR: PNG/SVG export, paylaş, yüksek çözünürlük baskı
|
||||
- QR dinamik URL mantığı — menü/domain değişse bile QR bozulmaz
|
||||
- Otomatik subdomain (custom domain V1.5'e ertelendi)
|
||||
|
||||
### Faz 5 — Abonelik
|
||||
- RevenueCat entegrasyonu (`menu_pro_monthly`, `menu_pro_yearly`)
|
||||
- 7 gün free trial
|
||||
- Entitlement + webhook doğrulama (backend authoritative, mobil local state güvenilmez)
|
||||
- Subscription lifecycle: ACTIVE → GRACE PERIOD → SUSPENDED (veri silinmez, public menü geçici kapanır)
|
||||
|
||||
### MVP'ye ek düşük maliyetli özellikler (Faz 2-3 içine serpiştirilir)
|
||||
- **Alerjen / diyet etiketleme** (vegan, glutensiz, acı seviyesi) — küçük DB eklentisi, satış argümanı güçlü
|
||||
- **En çok satan ürün rozeti** — analytics_events'ten otomatik türetilir, ekstra veri girişi gerektirmez
|
||||
|
||||
### MVP Başarı Kriteri
|
||||
PRD bölüm 30'daki kriterler + Ana KPI: **kayıt olan restoranın ilk menüsünü yayınlama oranı** (signup → restaurant → photo → AI → approve → publish → QR → paid funnel'ı takip edilir).
|
||||
|
||||
---
|
||||
|
||||
## V1.5 — İlk Genişleme
|
||||
|
||||
- Custom domain bağlama + DNS verification (CNAME) + Cloudflare SSL
|
||||
- Tam analytics: ürün/kategori bazlı görüntülenme, saatlik yoğunluk, dil kullanımı, QR taramaları
|
||||
- Push notifications ("Menünüz 30 gündür güncellenmedi", "Aboneliğiniz 3 gün sonra yenilenecek", "Bugün 250 kez görüntülendi")
|
||||
- Çoklu dil: TR/EN/DE/AR, restoran tarafından düzenlenebilir çeviri
|
||||
- 5 temaya tamamlama (Elegant, Modern, Minimal, Dark, Classic)
|
||||
- AI açıklama/görsel öneri zenginleştirme (AI, ürün açıklaması taslağı önerir, görsel önerir — restoran onaylar)
|
||||
- Zaman bazlı menü (kahvaltı/öğle/akşam otomatik geçiş)
|
||||
- QR masa materyali üretimi (masa standı / A4 poster PDF, AI image generation ile)
|
||||
- Menü tazelik skoru / gamification (güncelleme dürtmesi, retention için)
|
||||
- Offline/PWA cache — zayıf wifi ortamlarında son yüklenen menü açılabilir
|
||||
|
||||
## V2 — Büyüme Motorları
|
||||
|
||||
- **Zincir/şube desteği** — DB zaten `Restaurant → Location` olarak tasarlı, sadece UI + pricing katmanı eklenir. Yüksek ARPU segmenti.
|
||||
- **Ajans/reseller white-label modeli** — ajanslar müşterileri adına çoklu restoran portföyünü tek hesaptan yönetebilir
|
||||
- **Referral programı** — restoran getir, indirim kazan (growth loop)
|
||||
- **Menü versiyon geçmişi / geri alma** — AI hatalarına karşı güvenlik ağı, audit
|
||||
- **Google review widget** — public menüden Google Maps yorumuna yönlendirme
|
||||
- **Sosyal paylaşım kartı üretimi** — ürün başına otomatik Instagram post görseli (fiyat + QR ile); restoran paylaştıkça marka (`menu.x.com`) organik yayılır
|
||||
- **Turist modu** — tarayıcı diline/lokasyona göre otomatik dil + tahmini döviz kuru gösterimi (resmi değil, tahmini). Turistik bölge restoranları için güçlü farklılaştırıcı.
|
||||
- **Fiyat önerisi / pazar karşılaştırması** — AI, bölgesel fiyat verisiyle "bu ürün bölge ortalamasının altında/üstünde" içgörüsü sunar; premium pakete özel data ürünü
|
||||
|
||||
## V3 — Platform / Uzun Vade
|
||||
|
||||
PRD bölüm 28'deki roadmap:
|
||||
- Online sipariş, masa siparişi
|
||||
- Basit "garson çağır" butonu (tam POS değil, sadece bildirim — V3'e ucuz bir ön adım)
|
||||
- Kitchen Display
|
||||
- Rezervasyon
|
||||
- Loyalty, kampanya yönetimi
|
||||
- POS entegrasyonu
|
||||
|
||||
Bu fazların hiçbiri MVP felsefesini ("AI yardımcıdır karar verici değildir", "public menu hızlıdır, hesap açmaz") bozmamalı; sipariş/rezervasyon gibi özellikler ayrı, opsiyonel modüller olarak eklenmelidir.
|
||||
|
||||
---
|
||||
|
||||
## Faz Bağımlılık Notları
|
||||
|
||||
- Faz 2 (AI) → Faz 1'in veri modeline bağımlı, paralel başlatılamaz
|
||||
- Faz 4 (Custom domain, V1.5) → Cloudflare altyapısı Faz 0'da kurulmalı ama aktif kullanım V1.5'e ertelenir
|
||||
- Faz 5 (Abonelik) → RevenueCat webhook'u backend'de Faz 0'daki API iskeletine bağımlı, ama ürün tarafında en son entegre edilebilir (trial süresince ücretsiz kullanım MVP testi için yeterli)
|
||||
- Zincir/şube desteği (V2) → DB şeması Faz 0'da zaten `Location` katmanıyla hazırlandığı için ek migration riski düşük
|
||||
@@ -0,0 +1,69 @@
|
||||
# menul.io — Apple App Store Standartlarında Icon Promptları 🍎
|
||||
|
||||
Apple Human Interface Guidelines (HIG) ve Apple Design Awards standartlarına göre, gerçek bir App Store ikonunda **asla karmaşık 3D sahneler, uçuşan parçacıklar veya karmaşık detaylar bulunmaz**. İkon, iPhone ana ekranında (küçük boyutta) **tek bakışta tanınabilir, ikonik, cesur ve net** bir sembol olmalıdır (Linear, Stripe, Resy, Airbnb, Things 3 kalitesinde).
|
||||
|
||||
Aşağıda **tamamen App Store için optimize edilmiş 8 gerçekçi, minimalist ve ikonik prompt** hazırlanmıştır:
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚡ "Linear / Stripe Tarzı: Minimalist Geometrik 'm' Glifi" (En Çok Tercih Edilen)
|
||||
> **Mantık:** Koyu antrasit zemin üzerinde tek parça, kalın, kusursuz oranlanmış modern "m" monogramı. Köşesinde menü karesini andıran tek bir altın mikro-nokta.
|
||||
```text
|
||||
App Store iOS app icon, ultra-minimalist bold lowercase letter "m" geometric logo mark, matte deep titanium dark grey background (#121214), subtle warm champagne gold anodized finish on the glyph, razor-sharp vector silhouette, single solid iconic mark centered, Apple Design Award winner style, no floating elements, no 3D clutter, perfectly flat with subtle top-to-bottom radial gradient, 1024x1024 squircle canvas, Behance curated branding --v 6.0 --ar 1:1 --no text, mockups, frames
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 🍷 "Resy / Michelin Tarzı: Tek Çizgi Altın Servis Kapağı (Cloche)"
|
||||
> **Mantık:** Fine dining restoran kalitesini simgeleyen, tek bir sürekli çizgiyle çizilmiş lüks altın servis kapağı konturu.
|
||||
```text
|
||||
iOS application icon, minimal single continuous line vector of a luxury dining cloche dome, brushed satin gold line art on a rich dark obsidian background (#0A0A0C), subtle inner shadow, clean luxury hospitality branding, iconic silhouette recognizable at 64px, Apple HIG guidelines, premium, high contrast, flat graphic design, vector mastery --v 6.0 --ar 1:1 --no 3d renders, clutter, text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 🎯 "Things 3 Tarzı: Hassas QR Odak & Menü Matrisi"
|
||||
> **Mantık:** Fotoğraftan menü çıkaran AI vizyonunu temsil eden, 4 köşeli keskin optik vizör ve merkezde zarif bir kare lezzet noktası.
|
||||
```text
|
||||
Official iOS app icon, minimalist geometric camera focus reticle framing a single solid warm gold square, dark mode graphite grey matte texture background, stark contrast, clean Swiss grid system, precision software icon, Dieter Rams industrial design aesthetic, flat vector graphic, centered, perfectly balanced --v 6.0 --ar 1:1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 🍽️ "Craft / Notion Tarzı: İki Tonlu Negatif Alan Çatal & Menü"
|
||||
> **Mantık:** Siyah ve sıcak altın tonlarında, çatal dişlerinin menü satırlarına dönüştüğü zekice kurgulanmış minimalist amblem.
|
||||
```text
|
||||
App Store icon, clever minimalist negative space emblem combining clean fork tines and structured horizontal menu lines, two-tone palette of warm sand gold (#D4AF37) and deep charcoal black (#18181B), bold graphic silhouette, flat vector logo, ultra clean edges, iconic foodtech symbol, premium editorial app style --v 6.0 --ar 1:1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 💎 "Apple Wallet / Dark Mode Tarzı: Gömme (Debossed) Titanyum Glif"
|
||||
> **Mantık:** Cihazın kendi materyalinden oyulmuş gibi duran, fırçalanmış titanyum plaka üzerine gömülü minimalist "M" mührü.
|
||||
```text
|
||||
iOS app icon, tactile debossed geometric "M" symbol pressed into dark brushed aerospace titanium, subtle chamfered gold rim highlight on the inner edge, premium Apple hardware feel, soft ambient studio lighting, ultra-realistic subtle texture, completely flat centered composition, no distraction, luxury SaaS branding --v 6.0 --ar 1:1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ☀️ "Arc / Raycast Tarzı: Modern Saf Altın Prizma Kare"
|
||||
> **Mantık:** QR kodun köşe karesini andıran, sıcak güneş sarısı ve kehribar gradyanlı pürüzsüz yuvarlatılmış kare form.
|
||||
```text
|
||||
iOS 18 app icon, single minimalist rounded square glyph with a nested golden dot, smooth warm amber to liquid gold subtle gradient, deep jet black matte canvas, high contrast, clean vector icon, tech luxury branding, Linear app aesthetic, timeless and instantly recognizable --v 6.0 --ar 1:1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 🏷️ "OpenTable / Monocle Tarzı: Tipografik 'M.' İmzası"
|
||||
> **Mantık:** Prestijli gastronomi dergisi kapağını andıran, özel çizim kalın serif "M." harfi.
|
||||
```text
|
||||
App Store app icon, single custom bold serif letter "M" with a period dot "M.", solid warm champagne gold on ultra-dark matte velvet black, high fashion luxury restaurant branding, editorial typography, crisp vector shape, iconic monogram, flat design, perfectly centered --v 6.0 --ar 1:1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 🟢 "Uber Eats / Vercel Tarzı: Dinamik 3 Çizgili Menü & Işıma"
|
||||
> **Mantık:** Dijital menünün 3 katmanını (Başlangıç, Ana Yemek, Tatlı) ve anında yayını temsil eden 3 minimalist yatay çubuk.
|
||||
```text
|
||||
iOS app icon, three clean horizontal rounded pills arranged with progressive lengths forming an abstract dynamic menu list, brushed gold on deep obsidian background, crisp flat vector icon, Apple Human Interface Guidelines, modern digital restaurant tool, ultra-simple and bold --v 6.0 --ar 1:1
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "menulio",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:api": "pnpm --filter @menulio/api dev",
|
||||
"dev:web": "pnpm --filter @menulio/web dev",
|
||||
"dev:mobile": "pnpm --filter @menulio/mobile start",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@menulio/shared",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./slug.js";
|
||||
export * from "./theme.js";
|
||||
export * from "./types.js";
|
||||
@@ -0,0 +1,30 @@
|
||||
// Slug rules per PRD §10: lowercase, Turkish characters normalized, spaces -> "-", safe charset.
|
||||
const TR_MAP: Record<string, string> = {
|
||||
ç: "c",
|
||||
ğ: "g",
|
||||
ı: "i",
|
||||
ö: "o",
|
||||
ş: "s",
|
||||
ü: "u",
|
||||
Ç: "c",
|
||||
Ğ: "g",
|
||||
İ: "i",
|
||||
Ö: "o",
|
||||
Ş: "s",
|
||||
Ü: "u",
|
||||
};
|
||||
|
||||
export function slugify(input: string): string {
|
||||
const normalized = input
|
||||
.split("")
|
||||
.map((char) => TR_MAP[char] ?? char)
|
||||
.join("");
|
||||
|
||||
return normalized
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Theme config contract shared by the theme engine (PRD §8): mobile writes it,
|
||||
// the public web renderer consumes it. Changing a theme must never touch menu data.
|
||||
export const themeConfigSchema = z.object({
|
||||
primaryColor: z.string(),
|
||||
background: z.string(),
|
||||
productLayout: z.enum(["card", "list"]),
|
||||
categoryLayout: z.enum(["accordion", "tabs", "flat"]),
|
||||
});
|
||||
|
||||
export type ThemeConfig = z.infer<typeof themeConfigSchema>;
|
||||
@@ -0,0 +1,133 @@
|
||||
// Mirrors supabase/migrations/20260819000000_initial_schema.sql.
|
||||
// Kept hand-written for V1; consider generating via `supabase gen types` once
|
||||
// the schema stabilizes.
|
||||
|
||||
export type SubscriptionStatus = "active" | "grace_period" | "suspended" | "deleted";
|
||||
export type AiImportStatus = "pending" | "processing" | "completed" | "failed";
|
||||
export type DomainStatus = "pending" | "verified" | "failed";
|
||||
|
||||
export interface Restaurant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
restaurantId: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
id: string;
|
||||
locationId: string;
|
||||
name: string;
|
||||
isPublished: boolean;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface MenuCategory {
|
||||
id: string;
|
||||
menuId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
imageUrl: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface MenuItemOption {
|
||||
id: string;
|
||||
menuItemId: string;
|
||||
groupName: string;
|
||||
label: string;
|
||||
priceDelta: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface AiImport {
|
||||
id: string;
|
||||
restaurantId: string;
|
||||
sourceImage: string;
|
||||
status: AiImportStatus;
|
||||
model: string | null;
|
||||
error: string | null;
|
||||
processedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AiImportItem {
|
||||
id: string;
|
||||
aiImportId: string;
|
||||
categoryName: string | null;
|
||||
itemName: string;
|
||||
description: string | null;
|
||||
price: number | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
export interface AiExtractedItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface AiExtractedCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
items: AiExtractedItem[];
|
||||
}
|
||||
|
||||
export interface AiImportResponse {
|
||||
importId: string;
|
||||
status: AiImportStatus;
|
||||
model: string | null;
|
||||
categories: AiExtractedCategory[];
|
||||
totalItems: number;
|
||||
lowConfidenceCount: number;
|
||||
}
|
||||
|
||||
export interface AiImportApplyPayload {
|
||||
categories: {
|
||||
name: string;
|
||||
items: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
restaurantId: string;
|
||||
status: SubscriptionStatus;
|
||||
productId: string | null;
|
||||
trialEndsAt: string | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
}
|
||||
|
||||
export interface Domain {
|
||||
id: string;
|
||||
restaurantId: string;
|
||||
hostname: string;
|
||||
isCustom: boolean;
|
||||
status: DomainStatus;
|
||||
}
|
||||