first commit
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { aiStore } from "@/lib/ai-store";
|
||||
import type { AiExtractedCategory, AiExtractedItem } from "@menulio/shared";
|
||||
|
||||
export default function OnboardingAiReviewStep() {
|
||||
const [importData, setImportData] = useState(aiStore.getImportData());
|
||||
const [categories, setCategories] = useState<AiExtractedCategory[]>(importData?.categories ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
const [showAddCat, setShowAddCat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const data = aiStore.getImportData();
|
||||
if (!data) {
|
||||
router.replace("/(onboarding)/scan");
|
||||
return;
|
||||
}
|
||||
setImportData(data);
|
||||
setCategories(data.categories);
|
||||
}, []);
|
||||
|
||||
const totalItems = categories.reduce((acc, c) => acc + c.items.length, 0);
|
||||
const lowConfidenceItems = categories.reduce(
|
||||
(acc, c) => acc + c.items.filter((i) => i.confidence < 0.85).length,
|
||||
0,
|
||||
);
|
||||
|
||||
function updateItem(categoryId: string, itemId: string, field: "name" | "price" | "description", value: string) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
items: cat.items.map((itm) => {
|
||||
if (itm.id !== itemId) return itm;
|
||||
if (field === "price") {
|
||||
const parsed = Number(value.replace(",", "."));
|
||||
return { ...itm, price: Number.isNaN(parsed) ? 0 : parsed, confidence: 1 };
|
||||
}
|
||||
return { ...itm, [field]: value };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteItem(categoryId: string, itemId: string) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
items: cat.items.filter((i) => i.id !== itemId),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function addItem(categoryId: string) {
|
||||
const newItem: AiExtractedItem = {
|
||||
id: `custom-item-${Date.now()}`,
|
||||
name: "Yeni Ürün",
|
||||
price: 100,
|
||||
description: "",
|
||||
confidence: 1,
|
||||
};
|
||||
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return { ...cat, items: [...cat.items, newItem] };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteCategory(categoryId: string) {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== categoryId));
|
||||
}
|
||||
|
||||
function addCategory() {
|
||||
if (!newCatName.trim()) return;
|
||||
const newCat: AiExtractedCategory = {
|
||||
id: `custom-cat-${Date.now()}`,
|
||||
name: newCatName.trim(),
|
||||
items: [],
|
||||
};
|
||||
setCategories((prev) => [...prev, newCat]);
|
||||
setNewCatName("");
|
||||
setShowAddCat(false);
|
||||
}
|
||||
|
||||
async function onSaveAndContinue() {
|
||||
if (categories.length === 0 || totalItems === 0) {
|
||||
Alert.alert("Uyarı", "Lütfen en az bir kategori ve ürün ekleyin.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const active = await getActiveRestaurant();
|
||||
if (!active?.menuId) {
|
||||
throw new Error("Menü bulunamadı.");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
menuId: active.menuId,
|
||||
categories: categories.map((cat) => ({
|
||||
name: cat.name,
|
||||
items: cat.items.map((itm) => ({
|
||||
name: itm.name,
|
||||
description: itm.description || null,
|
||||
price: itm.price,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
await api.post(`/ai-imports/${importData?.importId ?? "new"}/apply`, payload);
|
||||
|
||||
aiStore.clear();
|
||||
Alert.alert("Başarılı 🎉", "Menünüz başarıyla aktarıldı!", [
|
||||
{ text: "Menüye Git", onPress: () => router.replace("/menu") },
|
||||
]);
|
||||
} catch (err) {
|
||||
Alert.alert("Hata", err instanceof Error ? err.message : "Menü kaydedilemedi.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5" }}>
|
||||
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 110 }}>
|
||||
{/* Header Badge */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
ADIM 3 / 4 • AI REVIEW & ONAY
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", marginBottom: 6 }}>
|
||||
Çıkarılan Menüyü İnceleyin
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", marginBottom: 20 }}>
|
||||
AI sonuçlarını kontrol edin, gerekiyorsa fiyat veya isimleri düzenleyin.
|
||||
</Text>
|
||||
|
||||
{/* Summary Banner */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(200, 169, 107, 0.25)",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#1C1917" }}>
|
||||
{totalItems} Ürün • {categories.length} Kategori
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
|
||||
Model: {importData?.model ?? "Menulio Vision"}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#ECFDF5",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||
<Text style={{ color: "#059669", fontSize: 12, fontWeight: "700" }}>Hazır</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{lowConfidenceItems > 0 ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#F5F5F4",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="warning-outline" size={16} color="#D97706" />
|
||||
<Text style={{ color: "#D97706", fontSize: 12, fontWeight: "600", flex: 1 }}>
|
||||
{lowConfidenceItems} ürünün fiyatı veya ismi silik çıkmış olabilir, lütfen sarı etiketli ürünleri kontrol edin.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Categories & Items List */}
|
||||
{categories.map((category) => (
|
||||
<View
|
||||
key={category.id}
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
}}
|
||||
>
|
||||
{/* Category Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1.5,
|
||||
borderBottomColor: "#C8A96B40",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
||||
<Ionicons name="restaurant-outline" size={18} color="#C8A96B" />
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: "#C8A96B" }}>
|
||||
{category.name} ({category.items.length})
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => deleteCategory(category.id)}
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={14} color="#EF4444" />
|
||||
<Text style={{ color: "#EF4444", fontSize: 12, fontWeight: "600" }}>Kategoriyi Sil</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Items in Category */}
|
||||
<View style={{ gap: 12 }}>
|
||||
{category.items.map((item) => {
|
||||
const isLowConfidence = item.confidence < 0.85;
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
style={{
|
||||
backgroundColor: isLowConfidence ? "#FFFBEB" : "#FAFAFA",
|
||||
borderWidth: 1,
|
||||
borderColor: isLowConfidence ? "#FDE68A" : "#F4F4F5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{isLowConfidence ? (
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#FEF3C7",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
marginBottom: 6,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="warning-outline" size={12} color="#B45309" />
|
||||
<Text style={{ color: "#B45309", fontSize: 11, fontWeight: "700" }}>
|
||||
Fiyatı / İsmi Kontrol Edin (%{Math.round(item.confidence * 100)} Güven)
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center", marginBottom: 6 }}>
|
||||
<TextInput
|
||||
value={item.name}
|
||||
onChangeText={(v) => updateItem(category.id, item.id, "name", v)}
|
||||
placeholder="Ürün Adı"
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: "#1C1917",
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<TextInput
|
||||
value={String(item.price)}
|
||||
onChangeText={(v) => updateItem(category.id, item.id, "price", v)}
|
||||
keyboardType="numeric"
|
||||
placeholder="Fiyat"
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: "800",
|
||||
color: "#C8A96B",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
minWidth: 64,
|
||||
textAlign: "right",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 14, fontWeight: "700", color: "#1C1917" }}>₺</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => deleteItem(category.id, item.id)}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="close-circle-outline" size={18} color="#9CA3AF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={item.description ?? ""}
|
||||
onChangeText={(v) => updateItem(category.id, item.id, "description", v)}
|
||||
placeholder="Açıklama (opsiyonel)"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#78716C",
|
||||
paddingVertical: 2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
<Pressable
|
||||
onPress={() => addItem(category.id)}
|
||||
style={{
|
||||
paddingVertical: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderStyle: "dashed",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={16} color="#78716C" />
|
||||
<Text style={{ color: "#78716C", fontSize: 13, fontWeight: "600" }}>
|
||||
Bu Kategoriye Ürün Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add New Category */}
|
||||
{showAddCat ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#C8A96B",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15, fontWeight: "700", color: "#1C1917", marginBottom: 8 }}>
|
||||
Yeni Kategori Ekle
|
||||
</Text>
|
||||
<TextInput
|
||||
value={newCatName}
|
||||
onChangeText={setNewCatName}
|
||||
placeholder="Örn: Başlangıçlar"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
<View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end" }}>
|
||||
<Pressable
|
||||
onPress={() => setShowAddCat(false)}
|
||||
style={{ paddingVertical: 8, paddingHorizontal: 14, borderRadius: 8 }}
|
||||
>
|
||||
<Text style={{ color: "#78716C", fontWeight: "600" }}>Vazgeç</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={addCategory}
|
||||
style={{
|
||||
backgroundColor: "#1C1917",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "700" }}>Ekle</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => setShowAddCat(true)}
|
||||
style={{
|
||||
paddingVertical: 14,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E7E5E4",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={18} color="#1C1917" />
|
||||
<Text style={{ color: "#1C1917", fontSize: 14, fontWeight: "700" }}>
|
||||
Yeni Kategori Ekle
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Bottom Action Bar */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#E7E5E4",
|
||||
padding: 16,
|
||||
paddingBottom: 28,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onSaveAndContinue}
|
||||
disabled={saving}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 14,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: pressed || saving ? 0.85 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
})}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle-outline" size={20} color="#FFFFFF" />
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 16, fontWeight: "700" }}>
|
||||
Menüyü Onayla ve Kaydet ({totalItems} Ürün)
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { setActiveRestaurant } from "@/lib/active-restaurant";
|
||||
|
||||
interface CreateRestaurantResponse {
|
||||
restaurant: { id: string; slug: string };
|
||||
location: { id: string };
|
||||
menu: { id: string };
|
||||
}
|
||||
|
||||
export default function OnboardingRestaurantStep() {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { restaurant, location, menu } = await api.post<CreateRestaurantResponse>("/restaurants", {
|
||||
name,
|
||||
phone: phone || undefined,
|
||||
address: address || undefined,
|
||||
});
|
||||
|
||||
await setActiveRestaurant({
|
||||
restaurantId: restaurant.id,
|
||||
locationId: location.id,
|
||||
menuId: menu.id,
|
||||
slug: restaurant.slug,
|
||||
});
|
||||
|
||||
router.replace("/(onboarding)/scan");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Bir hata oluştu");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 440, width: "100%", alignSelf: "center" }}>
|
||||
{/* Step Badge */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
ADIM 1 / 4 • RESTORAN BİLGİLERİ
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 26, fontWeight: "800", color: "#1C1917", marginBottom: 6 }}>
|
||||
Restoran Bilgilerini Ekle
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#78716C", marginBottom: 28, lineHeight: 20 }}>
|
||||
Sadece isim zorunludur. Telefon ve adres bilgilerinizi daha sonra da düzenleyebilirsiniz.
|
||||
</Text>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FCA5A5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||
<Text style={{ color: "#DC2626", fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Restaurant Name */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="storefront-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Restoran adı (Örn: Kebapçı Ahmet)"
|
||||
placeholderTextColor="#A8A29E"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Phone */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="call-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Telefon numarası (opsiyonel)"
|
||||
placeholderTextColor="#A8A29E"
|
||||
keyboardType="phone-pad"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Address */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="location-outline" size={18} color="#A8A29E" style={{ marginRight: 10 }} />
|
||||
<TextInput
|
||||
placeholder="Adres / Şehir (opsiyonel)"
|
||||
placeholderTextColor="#A8A29E"
|
||||
value={address}
|
||||
onChangeText={setAddress}
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 15, color: "#1C1917" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Submit */}
|
||||
<Pressable
|
||||
onPress={onSubmit}
|
||||
disabled={loading || !name.trim()}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: loading || !name.trim() ? 0.6 : pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ color: "#fff", fontWeight: "700", fontSize: 15 }}>Devam Et</Text>
|
||||
<Ionicons name="arrow-forward" size={16} color="#fff" />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { api } from "@/lib/api";
|
||||
import { getActiveRestaurant } from "@/lib/active-restaurant";
|
||||
import { aiStore } from "@/lib/ai-store";
|
||||
import type { AiImportResponse } from "@menulio/shared";
|
||||
|
||||
export default function OnboardingScanStep() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [currentStage, setCurrentStage] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const stages = [
|
||||
{ title: "Fotoğraf okunuyor...", icon: "document-text-outline" as const },
|
||||
{ title: "Kategoriler ve başlıklar bulunuyor...", icon: "list-outline" as const },
|
||||
{ title: "Yemekler ve açıklamalar çıkarılıyor...", icon: "restaurant-outline" as const },
|
||||
{ title: "Fiyatlar ve para birimi algılanıyor...", icon: "pricetag-outline" as const },
|
||||
{ title: "Güven skorları hesaplanıyor...", icon: "shield-checkmark-outline" as const },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (analyzing) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentStage((prev) => (prev < stages.length - 1 ? prev + 1 : prev));
|
||||
}, 1200);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [analyzing, stages.length]);
|
||||
|
||||
async function processImage(base64: string) {
|
||||
setError(null);
|
||||
setAnalyzing(true);
|
||||
setCurrentStage(0);
|
||||
|
||||
try {
|
||||
const active = await getActiveRestaurant();
|
||||
if (!active?.menuId) {
|
||||
throw new Error("Aktif restoran veya menü bulunamadı.");
|
||||
}
|
||||
|
||||
const formattedImage = base64.startsWith("data:")
|
||||
? base64
|
||||
: `data:image/jpeg;base64,${base64}`;
|
||||
|
||||
const response = await api.post<AiImportResponse>(`/menus/${active.menuId}/import-ai`, {
|
||||
image: formattedImage,
|
||||
});
|
||||
|
||||
aiStore.setImportData(response);
|
||||
router.push("/(onboarding)/ai-review");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fotoğraf analiz edilirken bir hata oluştu.");
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function takePhoto() {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Menü fotoğrafı çekebilmek için kamera erişimine izin vermelisiniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.8,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]?.base64) {
|
||||
await processImage(result.assets[0].base64);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Kamera açılamadı.");
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFromGallery() {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert("İzin Gerekli", "Menü görseli seçebilmek için galeri erişimine izin vermelisiniz.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.8,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]?.base64) {
|
||||
await processImage(result.assets[0].base64);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Galeri açılamadı.");
|
||||
}
|
||||
}
|
||||
|
||||
if (analyzing) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#FAF8F5", justifyContent: "center", alignItems: "center", padding: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
maxWidth: 360,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: "#FDF4E6",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="sparkles" size={32} color="#C8A96B" />
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 20, fontWeight: "700", color: "#1C1917", marginBottom: 6, textAlign: "center" }}>
|
||||
Menünüz Analiz Ediliyor
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: "#78716C", textAlign: "center", marginBottom: 24 }}>
|
||||
Menulio AI menünüzü dijitalleştiriyor...
|
||||
</Text>
|
||||
|
||||
<View style={{ width: "100%", gap: 14 }}>
|
||||
{stages.map((stage, idx) => {
|
||||
const isDone = idx < currentStage;
|
||||
const isCurrent = idx === currentStage;
|
||||
return (
|
||||
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: isDone ? "#ECFDF5" : isCurrent ? "#FDF4E6" : "#F5F5F4",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<Ionicons name="checkmark-circle" size={18} color="#10B981" />
|
||||
) : isCurrent ? (
|
||||
<ActivityIndicator size="small" color="#C8A96B" />
|
||||
) : (
|
||||
<Ionicons name={stage.icon} size={15} color="#A8A29E" />
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: isCurrent ? "700" : "500",
|
||||
color: isDone ? "#10B981" : isCurrent ? "#C8A96B" : "#A8A29E",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{stage.title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1, backgroundColor: "#FAF8F5", padding: 24, justifyContent: "center" }}>
|
||||
<View style={{ maxWidth: 440, width: "100%", alignSelf: "center" }}>
|
||||
{/* Step Badge */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#F3EDE2",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#926E27", fontSize: 12, fontWeight: "700" }}>
|
||||
ADIM 2 / 4 • AI MENU SCANNER
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 28, fontWeight: "800", color: "#1C1917", marginBottom: 8, letterSpacing: -0.5 }}>
|
||||
Menü Fotoğrafını Yükleyin
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#78716C", lineHeight: 22, marginBottom: 32 }}>
|
||||
Basılı menünüzün fotoğrafını çekin veya galeriden seçin. Menulio AI saniyeler içinde tüm yemekleri, kategorileri ve fiyatları dijitalleştirsin.
|
||||
</Text>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#FCA5A5",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 20,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||
<Text style={{ color: "#DC2626", fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Primary CTA: Take Photo */}
|
||||
<Pressable
|
||||
onPress={takePhoto}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#1C1917",
|
||||
borderRadius: 16,
|
||||
padding: 18,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={22} color="#FFFFFF" />
|
||||
<Text style={{ color: "#FFFFFF", fontSize: 16, fontWeight: "700" }}>
|
||||
Menü Fotoğrafı Çek
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Secondary CTA: Pick from Gallery */}
|
||||
<Pressable
|
||||
onPress={pickFromGallery}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E7E5E4",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
marginBottom: 24,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#1C1917" />
|
||||
<Text style={{ color: "#1C1917", fontSize: 15, fontWeight: "600" }}>
|
||||
Galeriden Seç
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Tertiary: Skip to manual */}
|
||||
<Pressable
|
||||
onPress={() => router.replace("/menu")}
|
||||
style={{ padding: 12, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 6 }}
|
||||
>
|
||||
<Ionicons name="create-outline" size={16} color="#78716C" />
|
||||
<Text style={{ color: "#78716C", fontSize: 14, fontWeight: "600", textDecorationLine: "underline" }}>
|
||||
Fotoğrafım yok, menüyü elle oluşturacağım →
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user