first commit

This commit is contained in:
AyrisAI
2026-08-20 01:51:59 +03:00
commit 97b83c7fd4
109 changed files with 21215 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_API_URL=http://localhost:3001
+3
View File
@@ -0,0 +1,3 @@
{
"buildPath": "code"
}
+52
View File
@@ -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 23 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.
+61
View File
@@ -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"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

+36
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};
+21
View File
@@ -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": {}
}
}
+19
View File
@@ -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;
+41
View File
@@ -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"
}
}
+174
View File
@@ -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&apos;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>
);
}
+179
View File
@@ -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>
);
}
+302
View File
@@ -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>
);
}
+27
View File
@@ -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 }} />;
}
+872
View File
@@ -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ü </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>
);
}
+71
View File
@@ -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>
);
}
+748
View File
@@ -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>
);
}
+226
View File
@@ -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>
);
}
+23
View File
@@ -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);
}
+23
View File
@@ -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;
},
};
+36
View File
@@ -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" }),
};
+19
View File
@@ -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,
},
});
+15
View File
@@ -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}`;
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}