first commit
@@ -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"]
|
||||
}
|
||||