Compare commits

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:46:19 +03:00
11 changed files with 707 additions and 19 deletions
+2
View File
@@ -4,6 +4,7 @@ import { env } from "./env.js";
import { aiImportsRoutes } from "./routes/ai-imports.js"; import { aiImportsRoutes } from "./routes/ai-imports.js";
import { analyticsRoutes } from "./routes/analytics.js"; import { analyticsRoutes } from "./routes/analytics.js";
import { domainsRoutes } from "./routes/domains.js"; import { domainsRoutes } from "./routes/domains.js";
import { languagesRoutes } from "./routes/languages.js";
import { menuCategoriesRoutes } from "./routes/menu-categories.js"; import { menuCategoriesRoutes } from "./routes/menu-categories.js";
import { menuItemsRoutes } from "./routes/menu-items.js"; import { menuItemsRoutes } from "./routes/menu-items.js";
import { menusRoutes } from "./routes/menus.js"; import { menusRoutes } from "./routes/menus.js";
@@ -46,6 +47,7 @@ await app.register(qrRoutes);
await app.register(qrTemplatesRoutes); await app.register(qrTemplatesRoutes);
await app.register(aiImportsRoutes); await app.register(aiImportsRoutes);
await app.register(domainsRoutes); await app.register(domainsRoutes);
await app.register(languagesRoutes);
await app.register(analyticsRoutes); await app.register(analyticsRoutes);
await app.register(subscriptionRoutes); await app.register(subscriptionRoutes);
+148
View File
@@ -0,0 +1,148 @@
import { env } from "../env.js";
export interface TranslatableItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatableCategory {
id: string;
name: string;
items: TranslatableItem[];
}
export interface TranslatedItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatedCategory {
id: string;
name: string;
items: TranslatedItem[];
}
const LANGUAGE_NAMES: Record<string, string> = {
en: "English",
de: "German",
ar: "Arabic",
ru: "Russian",
fr: "French",
};
function buildPrompt(targetLanguageName: string): string {
return `Sen profesyonel bir restoran menüsü çevirmenisin. Sana Türkçe bir restoran menüsü (kategori adları, ürün adları ve açıklamaları) JSON olarak verilecek.
Bunları ${targetLanguageName} diline çevir. Yemek isimlerini o mutfağın doğal terimleriyle çevir (örn. "Adana Kebap" -> "Adana Kebab", tamamen literal çeviri yapma, restoran menüsü diline uygun doğal bir üslup kullan).
Her kategori ve ürünün "id" alanını AYNEN koru, sadece "name" ve "description" alanlarını çevir. description null ise null bırak.
SADECE aşağıdaki JSON formatında geçerli bir JSON yanıtı ver, başka hiçbir metin ekleme:
{
"categories": [
{ "id": "...", "name": "çevrilmiş kategori adı", "items": [ { "id": "...", "name": "çevrilmiş ürün adı", "description": "çevrilmiş açıklama veya null" } ] }
]
}`;
}
function cleanAndParse(text: string): { categories: TranslatedCategory[] } {
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const parsed = JSON.parse(cleaned) as { categories: TranslatedCategory[] };
if (!parsed.categories || !Array.isArray(parsed.categories)) {
throw new Error("Invalid structure from translator: categories array missing");
}
return parsed;
}
async function translateWithGemini(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{
parts: [
{ text: buildPrompt(targetLanguageName) },
{ text: JSON.stringify({ categories }) },
],
},
],
generationConfig: { response_mime_type: "application/json", temperature: 0.2 },
}),
});
if (!response.ok) {
throw new Error(`Gemini translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!rawText) throw new Error("No text response from Gemini translate");
return cleanAndParse(rawText).categories;
}
async function translateWithOpenAI(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: "gpt-4o-mini",
response_format: { type: "json_object" },
messages: [
{ role: "system", content: buildPrompt(targetLanguageName) },
{ role: "user", content: JSON.stringify({ categories }) },
],
temperature: 0.2,
}),
});
if (!response.ok) {
throw new Error(`OpenAI translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("No message content from OpenAI translate");
return cleanAndParse(content).categories;
}
export function resolveLanguageName(code: string): string {
return LANGUAGE_NAMES[code] ?? code;
}
export async function translateMenu(
categories: TranslatableCategory[],
languageCode: string,
): Promise<TranslatedCategory[]> {
if (categories.length === 0) return [];
const targetLanguageName = resolveLanguageName(languageCode);
if (env.GEMINI_API_KEY) {
try {
return await translateWithGemini(categories, targetLanguageName, env.GEMINI_API_KEY);
} catch (err) {
console.warn("Gemini translate failed, falling back:", err);
}
}
if (env.OPENAI_API_KEY) {
return translateWithOpenAI(categories, targetLanguageName, env.OPENAI_API_KEY);
}
throw new Error("No translation provider configured (GEMINI_API_KEY / OPENAI_API_KEY missing)");
}
+147
View File
@@ -0,0 +1,147 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu, type TranslatableCategory } from "../lib/translator.js";
const SUPPORTED_LANGUAGES = ["en", "de", "ar", "ru", "fr"] as const;
const addLanguageSchema = z.object({
code: z.enum(SUPPORTED_LANGUAGES),
});
async function getRestaurantMenuCategories(restaurantId: string): Promise<TranslatableCategory[]> {
if (!supabase) return [];
const { data: locations } = await supabase.from("locations").select("id").eq("restaurant_id", restaurantId);
const locationIds = (locations ?? []).map((l) => l.id);
if (locationIds.length === 0) return [];
const { data: menus } = await supabase.from("menus").select("id").in("location_id", locationIds);
const menuIds = (menus ?? []).map((m) => m.id);
if (menuIds.length === 0) return [];
const { data: categories } = await supabase
.from("menu_categories")
.select("id, name, menu_items(id, name, description)")
.in("menu_id", menuIds);
return (categories ?? []).map((cat) => ({
id: cat.id,
name: cat.name,
items: (cat.menu_items ?? []).map((item: { id: string; name: string; description: string | null }) => ({
id: item.id,
name: item.name,
description: item.description,
})),
}));
}
export const languagesRoutes: FastifyPluginAsync = async (app) => {
// Add a language: translates all current menu content into it, then
// enables it. New items created afterwards are translated on save
// (see menu-categories.ts / menu-items.ts).
app.post("/restaurants/:id/languages", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
const { id: restaurantId } = req.params as { id: string };
const isMember = await requireRestaurantMember(userId, restaurantId);
if (!isMember) return reply.code(403).send({ message: "forbidden" });
const parsed = addLanguageSchema.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "invalid language code", issues: parsed.error.issues });
}
const { code } = parsed.data;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const current: string[] = restaurant?.enabled_languages ?? ["tr"];
if (current.includes(code)) {
return reply.code(409).send({ message: "Bu dil zaten aktif." });
}
const categories = await getRestaurantMenuCategories(restaurantId);
try {
const translated = await translateMenu(categories, code);
for (const cat of translated) {
const original = categories.find((c) => c.id === cat.id);
const { data: existingCatRow } = await supabase
.from("menu_categories")
.select("translations")
.eq("id", cat.id)
.single();
await supabase
.from("menu_categories")
.update({
translations: { ...(existingCatRow?.translations ?? {}), [code]: { name: cat.name } },
})
.eq("id", cat.id);
for (const item of cat.items) {
const { data: existingItemRow } = await supabase
.from("menu_items")
.select("translations")
.eq("id", item.id)
.single();
await supabase
.from("menu_items")
.update({
translations: {
...(existingItemRow?.translations ?? {}),
[code]: { name: item.name, description: item.description },
},
})
.eq("id", item.id);
}
void original;
}
} catch (err) {
req.log.error(err);
return reply.code(500).send({ message: "Menü çevirisi başarısız oldu", error: (err as Error).message });
}
const { data: updated } = await supabase
.from("restaurants")
.update({ enabled_languages: [...current, code] })
.eq("id", restaurantId)
.select("enabled_languages")
.single();
return reply.send({ enabledLanguages: updated?.enabled_languages ?? [...current, code] });
});
app.delete("/restaurants/:id/languages/:code", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
const { id: restaurantId, code } = req.params as { id: string; code: string };
const isMember = await requireRestaurantMember(userId, restaurantId);
if (!isMember) return reply.code(403).send({ message: "forbidden" });
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const current: string[] = restaurant?.enabled_languages ?? ["tr"];
const next = current.filter((c) => c !== code);
const { data: updated } = await supabase
.from("restaurants")
.update({ enabled_languages: next })
.eq("id", restaurantId)
.select("enabled_languages")
.single();
return reply.send({ enabledLanguages: updated?.enabled_languages ?? next });
});
};
+30
View File
@@ -3,6 +3,34 @@ import { z } from "zod";
import { requireAuth } from "../lib/auth.js"; import { requireAuth } from "../lib/auth.js";
import { getCategoryIfMember, getMenuIfMember } from "../lib/access.js"; import { getCategoryIfMember, getMenuIfMember } from "../lib/access.js";
import { supabase } from "../lib/supabase.js"; import { supabase } from "../lib/supabase.js";
import { translateMenu } from "../lib/translator.js";
// New categories/items are translated into whatever languages the
// restaurant already has enabled beyond Turkish (bulk-translated once when
// a language is first added — see routes/languages.ts).
async function translateNewCategoryInBackground(restaurantId: string, categoryId: string, name: string) {
if (!supabase) return;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const extraLanguages = (restaurant?.enabled_languages ?? []).filter((l: string) => l !== "tr");
if (extraLanguages.length === 0) return;
const translations: Record<string, { name: string }> = {};
for (const lang of extraLanguages) {
try {
const [translated] = await translateMenu([{ id: categoryId, name, items: [] }], lang);
if (translated) translations[lang] = { name: translated.name };
} catch (err) {
console.warn(`Category translation to ${lang} failed:`, err);
}
}
if (Object.keys(translations).length > 0) {
await supabase.from("menu_categories").update({ translations }).eq("id", categoryId);
}
}
const createCategorySchema = z.object({ const createCategorySchema = z.object({
name: z.string().min(1).max(120), name: z.string().min(1).max(120),
@@ -47,6 +75,8 @@ export const menuCategoriesRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ message: "failed to create category" }); return reply.code(500).send({ message: "failed to create category" });
} }
void translateNewCategoryInBackground(access.restaurantId, data.id, data.name);
return reply.code(201).send(data); return reply.code(201).send(data);
}); });
+36
View File
@@ -3,6 +3,40 @@ import { z } from "zod";
import { requireAuth } from "../lib/auth.js"; import { requireAuth } from "../lib/auth.js";
import { getCategoryIfMember, getItemIfMember } from "../lib/access.js"; import { getCategoryIfMember, getItemIfMember } from "../lib/access.js";
import { supabase } from "../lib/supabase.js"; import { supabase } from "../lib/supabase.js";
import { translateMenu } from "../lib/translator.js";
async function translateNewItemInBackground(
restaurantId: string,
itemId: string,
name: string,
description: string | null,
) {
if (!supabase) return;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const extraLanguages = (restaurant?.enabled_languages ?? []).filter((l: string) => l !== "tr");
if (extraLanguages.length === 0) return;
const translations: Record<string, { name: string; description: string | null }> = {};
for (const lang of extraLanguages) {
try {
const [translatedCat] = await translateMenu(
[{ id: "single", name: "-", items: [{ id: itemId, name, description }] }],
lang,
);
const translatedItem = translatedCat?.items[0];
if (translatedItem) translations[lang] = { name: translatedItem.name, description: translatedItem.description };
} catch (err) {
console.warn(`Item translation to ${lang} failed:`, err);
}
}
if (Object.keys(translations).length > 0) {
await supabase.from("menu_items").update({ translations }).eq("id", itemId);
}
}
const createItemSchema = z.object({ const createItemSchema = z.object({
name: z.string().min(1).max(160), name: z.string().min(1).max(160),
@@ -53,6 +87,8 @@ export const menuItemsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ message: "failed to create item" }); return reply.code(500).send({ message: "failed to create item" });
} }
void translateNewItemInBackground(access.restaurantId, data.id, data.name, data.description);
return reply.code(201).send(data); return reply.code(201).send(data);
}); });
+3 -1
View File
@@ -16,6 +16,7 @@ const createRestaurantSchema = z.object({
const updateRestaurantSchema = z.object({ const updateRestaurantSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
logoUrl: z.string().nullish(), logoUrl: z.string().nullish(),
coverUrl: z.string().nullish(),
phone: z.string().max(30).nullish(), phone: z.string().max(30).nullish(),
address: z.string().max(500).nullish(), address: z.string().max(500).nullish(),
slug: z.string().min(2).max(100).optional(), slug: z.string().min(2).max(100).optional(),
@@ -108,7 +109,7 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues }); return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
} }
const { name, logoUrl, phone, address, slug } = parsed.data; const { name, logoUrl, coverUrl, phone, address, slug } = parsed.data;
let formattedSlug: string | undefined = undefined; let formattedSlug: string | undefined = undefined;
if (slug !== undefined) { if (slug !== undefined) {
@@ -145,6 +146,7 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
.update({ .update({
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(logoUrl !== undefined ? { logo_url: logoUrl } : {}), ...(logoUrl !== undefined ? { logo_url: logoUrl } : {}),
...(coverUrl !== undefined ? { cover_url: coverUrl } : {}),
...(phone !== undefined ? { phone } : {}), ...(phone !== undefined ? { phone } : {}),
...(address !== undefined ? { address } : {}), ...(address !== undefined ? { address } : {}),
...(formattedSlug !== undefined ? { slug: formattedSlug } : {}), ...(formattedSlug !== undefined ? { slug: formattedSlug } : {}),
+224
View File
@@ -26,10 +26,20 @@ interface RestaurantDetail {
name: string; name: string;
slug: string; slug: string;
logo_url: string | null; logo_url: string | null;
cover_url: string | null;
phone: string | null; phone: string | null;
address: string | null; address: string | null;
enabled_languages?: string[];
} }
const SUPPORTED_LANGUAGES: { code: string; label: string; flag: string }[] = [
{ code: "en", label: "İngilizce", flag: "🇬🇧" },
{ code: "de", label: "Almanca", flag: "🇩🇪" },
{ code: "ar", label: "Arapça", flag: "🇸🇦" },
{ code: "ru", label: "Rusça", flag: "🇷🇺" },
{ code: "fr", label: "Fransızca", flag: "🇫🇷" },
];
export interface TxtRecord { export interface TxtRecord {
name: string; name: string;
value: string; value: string;
@@ -79,6 +89,7 @@ export default function AccountScreen() {
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [address, setAddress] = useState(""); const [address, setAddress] = useState("");
const [logoUrl, setLogoUrl] = useState<string | null>(null); const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [coverUrl, setCoverUrl] = useState<string | null>(null);
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [savingRest, setSavingRest] = useState(false); const [savingRest, setSavingRest] = useState(false);
const [deletingRestaurant, setDeletingRestaurant] = useState(false); const [deletingRestaurant, setDeletingRestaurant] = useState(false);
@@ -87,6 +98,10 @@ export default function AccountScreen() {
const [selectedTheme, setSelectedTheme] = useState("elegant"); const [selectedTheme, setSelectedTheme] = useState("elegant");
const [savingTheme, setSavingTheme] = useState(false); const [savingTheme, setSavingTheme] = useState(false);
// Language / AI Translation state
const [enabledLanguages, setEnabledLanguages] = useState<string[]>(["tr"]);
const [updatingLanguage, setUpdatingLanguage] = useState<string | null>(null);
// Custom Domain state // Custom Domain state
const [domains, setDomains] = useState<DomainRow[]>([]); const [domains, setDomains] = useState<DomainRow[]>([]);
const [newDomainInput, setNewDomainInput] = useState(""); const [newDomainInput, setNewDomainInput] = useState("");
@@ -117,7 +132,9 @@ export default function AccountScreen() {
setPhone(rest.phone ?? ""); setPhone(rest.phone ?? "");
setAddress(rest.address ?? ""); setAddress(rest.address ?? "");
setLogoUrl(rest.logo_url ?? null); setLogoUrl(rest.logo_url ?? null);
setCoverUrl(rest.cover_url ?? null);
setSlug(rest.slug); setSlug(rest.slug);
setEnabledLanguages(rest.enabled_languages ?? ["tr"]);
// Fetch theme // Fetch theme
const themeRes = await api.get<{ themeKey: string }>(`/restaurants/${cached.restaurantId}/theme`).catch(() => ({ themeKey: "elegant" })); const themeRes = await api.get<{ themeKey: string }>(`/restaurants/${cached.restaurantId}/theme`).catch(() => ({ themeKey: "elegant" }));
@@ -169,6 +186,29 @@ export default function AccountScreen() {
} }
} }
async function handlePickCover() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("İzin Gerekli", "Kapak fotoğrafı seçebilmek için fotoğraf galerisi izni gereklidir.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [16, 9],
quality: 0.6,
base64: true,
});
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const newCover = asset.base64 ? `data:image/jpeg;base64,${asset.base64}` : asset.uri;
setCoverUrl(newCover);
}
}
async function handleSaveRestaurant() { async function handleSaveRestaurant() {
if (!active || !name.trim()) return; if (!active || !name.trim()) return;
setSavingRest(true); setSavingRest(true);
@@ -179,6 +219,7 @@ export default function AccountScreen() {
phone: phone.trim() || null, phone: phone.trim() || null,
address: address.trim() || null, address: address.trim() || null,
logoUrl: logoUrl || null, logoUrl: logoUrl || null,
coverUrl: coverUrl || null,
slug: slug.trim() || undefined, slug: slug.trim() || undefined,
}); });
@@ -214,6 +255,54 @@ export default function AccountScreen() {
} }
} }
async function handleAddLanguage(code: string, label: string) {
if (!active || updatingLanguage) return;
setUpdatingLanguage(code);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
try {
const res = await api.post<{ enabledLanguages: string[] }>(
`/restaurants/${active.restaurantId}/languages`,
{ code },
);
setEnabledLanguages(res.enabledLanguages);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
Alert.alert("Menü Çevrildi 🎉", `Menünüz yapay zeka ile ${label} diline çevrildi.`);
} catch (err) {
Alert.alert("Hata", err instanceof Error ? err.message : "Dil eklenemedi.");
} finally {
setUpdatingLanguage(null);
}
}
async function handleRemoveLanguage(code: string, label: string) {
if (!active || updatingLanguage) return;
Alert.alert(
`${label} Dilini Kaldır`,
"Bu dil menüde artık gösterilmeyecek. Çeviriler saklanacağı için dili tekrar eklerseniz yeniden çevrilmesi gerekmez.",
[
{ text: "Vazgeç", style: "cancel" },
{
text: "Kaldır",
style: "destructive",
onPress: async () => {
setUpdatingLanguage(code);
try {
const res = await api.delete<{ enabledLanguages: string[] }>(
`/restaurants/${active.restaurantId}/languages/${code}`,
);
setEnabledLanguages(res.enabledLanguages);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
} catch (err) {
Alert.alert("Hata", err instanceof Error ? err.message : "Dil kaldırılamadı.");
} finally {
setUpdatingLanguage(null);
}
},
},
],
);
}
async function handleAddCustomDomain() { async function handleAddCustomDomain() {
if (!active || !newDomainInput.trim()) { if (!active || !newDomainInput.trim()) {
Alert.alert("Eksik Bilgi", "Lütfen bağlamak istediğiniz alan adını girin (Örn: menu.kebapciahmet.com)"); Alert.alert("Eksik Bilgi", "Lütfen bağlamak istediğiniz alan adını girin (Örn: menu.kebapciahmet.com)");
@@ -605,6 +694,94 @@ export default function AccountScreen() {
</View> </View>
</View> </View>
{/* Language / AI Translation Section */}
<View
style={{
backgroundColor: "#FFFFFF",
borderRadius: 20,
padding: 20,
borderWidth: 1,
borderColor: "#E7E5E4",
gap: 14,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Ionicons name="language-outline" size={20} color="#C8A96B" />
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
Diller (Yapay Zeka Çevirisi)
</Text>
</View>
<Text style={{ fontSize: 12, color: "#78716C", lineHeight: 18 }}>
Menünüzü yapay zeka ile otomatik olarak çevirin. Müşterileriniz menüde istedikleri dili seçebilir.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{/* Turkish — always on, base language */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "#1C1917",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
}}
>
<Text style={{ fontSize: 13 }}>🇹🇷</Text>
<Text style={{ fontSize: 12, fontWeight: "700", color: "#FFFFFF" }}>Türkçe</Text>
</View>
{SUPPORTED_LANGUAGES.map((lang) => {
const isEnabled = enabledLanguages.includes(lang.code);
const isUpdating = updatingLanguage === lang.code;
return (
<Pressable
key={lang.code}
onPress={() =>
isEnabled
? handleRemoveLanguage(lang.code, lang.label)
: handleAddLanguage(lang.code, lang.label)
}
disabled={isUpdating}
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: isEnabled ? "#FDF8F0" : "#FAF8F5",
borderWidth: 1.5,
borderColor: isEnabled ? "#C8A96B" : "#E7E5E4",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
opacity: isUpdating ? 0.6 : 1,
}}
>
{isUpdating ? (
<ActivityIndicator size="small" color="#C8A96B" />
) : (
<Text style={{ fontSize: 13 }}>{lang.flag}</Text>
)}
<Text
style={{
fontSize: 12,
fontWeight: "700",
color: isEnabled ? "#926E27" : "#57534E",
}}
>
{lang.label}
</Text>
{isEnabled ? (
<Ionicons name="close-circle" size={14} color="#C8A96B" />
) : (
<Ionicons name="add-circle-outline" size={14} color="#A8A29E" />
)}
</Pressable>
);
})}
</View>
</View>
{/* Custom Domain Section */} {/* Custom Domain Section */}
<View <View
style={{ style={{
@@ -902,6 +1079,53 @@ export default function AccountScreen() {
</Text> </Text>
</View> </View>
{/* Cover Photo Picker */}
<View>
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
Kapak Fotoğrafı (Menü Üst Görseli)
</Text>
<Pressable
onPress={handlePickCover}
style={{
width: "100%",
aspectRatio: 16 / 9,
borderRadius: 14,
overflow: "hidden",
backgroundColor: "#1C1917",
borderWidth: 1.5,
borderColor: "#D6D3D1",
alignItems: "center",
justifyContent: "center",
}}
>
{coverUrl ? (
<Image source={{ uri: coverUrl }} style={{ width: "100%", height: "100%" }} resizeMode="cover" />
) : (
<View style={{ alignItems: "center", gap: 6 }}>
<Ionicons name="image-outline" size={28} color="#C8A96B" />
<Text style={{ color: "#C8A96B", fontSize: 12, fontWeight: "700" }}>
Kapak Fotoğrafı Ekle
</Text>
</View>
)}
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "rgba(0,0,0,0.6)",
alignItems: "center",
paddingVertical: 6,
}}
>
<Text style={{ color: "#FFFFFF", fontSize: 11, fontWeight: "700" }}>
📷 {coverUrl ? "Değiştir" : "Fotoğraf Seç"}
</Text>
</View>
</Pressable>
</View>
{/* Restaurant Name */} {/* Restaurant Name */}
<View> <View>
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}> <Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
+12 -5
View File
@@ -17,6 +17,7 @@ interface MenuItemRow {
price: number; price: number;
image_url: string | null; image_url: string | null;
is_active: boolean; is_active: boolean;
translations: Record<string, { name: string; description: string | null }> | null;
} }
interface MenuCategoryRow { interface MenuCategoryRow {
@@ -25,6 +26,7 @@ interface MenuCategoryRow {
description: string | null; description: string | null;
is_active: boolean; is_active: boolean;
menu_items: MenuItemRow[]; menu_items: MenuItemRow[];
translations: Record<string, { name: string }> | null;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -71,14 +73,14 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
async function fetchRestaurantBySlug(slug: string) { async function fetchRestaurantBySlug(slug: string) {
let { data: bySlug, error } = await supabase let { data: bySlug, error } = await supabase
.from("restaurants") .from("restaurants")
.select("id, name, slug, logo_url, cover_url, phone, address") .select("id, name, slug, logo_url, cover_url, phone, address, enabled_languages")
.eq("slug", slug) .eq("slug", slug)
.maybeSingle(); .maybeSingle();
if (error && error.code === "42703") { if (error && error.code === "42703") {
const { data: fallback } = await supabase const { data: fallback } = await supabase
.from("restaurants") .from("restaurants")
.select("id, name, slug, logo_url, phone, address") .select("id, name, slug, logo_url, phone, address, enabled_languages")
.eq("slug", slug) .eq("slug", slug)
.maybeSingle(); .maybeSingle();
if (fallback) { if (fallback) {
@@ -90,14 +92,14 @@ async function fetchRestaurantBySlug(slug: string) {
let { data: domainRow, error: domErr } = await supabase let { data: domainRow, error: domErr } = await supabase
.from("domains") .from("domains")
.select("restaurant_id, restaurants(id, name, slug, logo_url, cover_url, phone, address)") .select("restaurant_id, restaurants(id, name, slug, logo_url, cover_url, phone, address, enabled_languages)")
.eq("hostname", slug) .eq("hostname", slug)
.maybeSingle(); .maybeSingle();
if (domErr && domErr.code === "42703") { if (domErr && domErr.code === "42703") {
const { data: fallbackDom } = await supabase const { data: fallbackDom } = await supabase
.from("domains") .from("domains")
.select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address)") .select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address, enabled_languages)")
.eq("hostname", slug) .eq("hostname", slug)
.maybeSingle(); .maybeSingle();
if (fallbackDom?.restaurants) { if (fallbackDom?.restaurants) {
@@ -150,7 +152,9 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
if (menu) { if (menu) {
const { data: rawCategories } = await supabase const { data: rawCategories } = await supabase
.from("menu_categories") .from("menu_categories")
.select("id, name, description, is_active, menu_items(id, name, description, price, image_url, is_active)") .select(
"id, name, description, is_active, translations, menu_items(id, name, description, price, image_url, is_active, translations)",
)
.eq("menu_id", menu.id) .eq("menu_id", menu.id)
.eq("is_active", true) .eq("is_active", true)
.order("sort_order", { ascending: true }) .order("sort_order", { ascending: true })
@@ -161,6 +165,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
name: cat.name, name: cat.name,
description: cat.description, description: cat.description,
is_active: cat.is_active, is_active: cat.is_active,
translations: cat.translations ?? {},
menu_items: (cat.menu_items ?? []) menu_items: (cat.menu_items ?? [])
.filter((item) => item.is_active) .filter((item) => item.is_active)
.map((item): MenuItem => ({ .map((item): MenuItem => ({
@@ -170,6 +175,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
price: item.price, price: item.price,
image_url: item.image_url, image_url: item.image_url,
is_active: item.is_active, is_active: item.is_active,
translations: item.translations ?? {},
})), })),
})); }));
} else if (slug === "demo" || slug === "gusto-brasserie") { } else if (slug === "demo" || slug === "gusto-brasserie") {
@@ -202,6 +208,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps
categories={categories} categories={categories}
initialThemeKey={themeKey} initialThemeKey={themeKey}
customThemeConfig={customConfig} customThemeConfig={customConfig}
enabledLanguages={restaurant.enabled_languages ?? ["tr"]}
/> />
); );
} }
+88 -13
View File
@@ -12,6 +12,7 @@ export interface MenuItem {
price: number; price: number;
image_url: string | null; image_url: string | null;
is_active: boolean; is_active: boolean;
translations?: Record<string, { name: string; description: string | null }> | null;
} }
export interface MenuCategory { export interface MenuCategory {
@@ -20,8 +21,18 @@ export interface MenuCategory {
description: string | null; description: string | null;
is_active: boolean; is_active: boolean;
menu_items: MenuItem[]; menu_items: MenuItem[];
translations?: Record<string, { name: string }> | null;
} }
const LANGUAGE_LABELS: Record<string, string> = {
tr: "TR",
en: "EN",
de: "DE",
ar: "AR",
ru: "RU",
fr: "FR",
};
export interface RestaurantData { export interface RestaurantData {
id: string; id: string;
name: string; name: string;
@@ -38,6 +49,7 @@ interface PublicMenuClientProps {
initialThemeKey?: string; initialThemeKey?: string;
customThemeConfig?: Partial<ThemeConfig>; customThemeConfig?: Partial<ThemeConfig>;
allowThemeSwitching?: boolean; allowThemeSwitching?: boolean;
enabledLanguages?: string[];
} }
export function PublicMenuClient({ export function PublicMenuClient({
@@ -46,8 +58,10 @@ export function PublicMenuClient({
initialThemeKey = "elegant", initialThemeKey = "elegant",
customThemeConfig, customThemeConfig,
allowThemeSwitching = false, allowThemeSwitching = false,
enabledLanguages = ["tr"],
}: PublicMenuClientProps) { }: PublicMenuClientProps) {
const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey); const [selectedThemeKey, setSelectedThemeKey] = useState<string>(initialThemeKey);
const [selectedLanguage, setSelectedLanguage] = useState<string>("tr");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeCategoryId, setActiveCategoryId] = useState<string>(categories[0]?.id ?? ""); const [activeCategoryId, setActiveCategoryId] = useState<string>(categories[0]?.id ?? "");
const [selectedItem, setSelectedItem] = useState<MenuItem | null>(null); const [selectedItem, setSelectedItem] = useState<MenuItem | null>(null);
@@ -95,12 +109,27 @@ export function PublicMenuClient({
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [categories]); }, [categories]);
// Apply AI-translated text for the selected language, falling back to the
// original Turkish content when a translation isn't available yet.
const localizedCategories = useMemo(() => {
if (selectedLanguage === "tr") return categories;
return categories.map((cat) => ({
...cat,
name: cat.translations?.[selectedLanguage]?.name || cat.name,
menu_items: cat.menu_items.map((item) => ({
...item,
name: item.translations?.[selectedLanguage]?.name || item.name,
description: item.translations?.[selectedLanguage]?.description ?? item.description,
})),
}));
}, [categories, selectedLanguage]);
// Filter categories and items based on search // Filter categories and items based on search
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
if (!q) return categories; if (!q) return localizedCategories;
return categories return localizedCategories
.map((cat) => ({ .map((cat) => ({
...cat, ...cat,
menu_items: cat.menu_items.filter( menu_items: cat.menu_items.filter(
@@ -110,7 +139,7 @@ export function PublicMenuClient({
), ),
})) }))
.filter((cat) => cat.menu_items.length > 0); .filter((cat) => cat.menu_items.length > 0);
}, [categories, searchQuery]); }, [localizedCategories, searchQuery]);
const totalItemsCount = useMemo(() => { const totalItemsCount = useMemo(() => {
return categories.reduce((acc, cat) => acc + cat.menu_items.filter((i) => i.is_active).length, 0); return categories.reduce((acc, cat) => acc + cat.menu_items.filter((i) => i.is_active).length, 0);
@@ -213,12 +242,33 @@ export function PublicMenuClient({
)} )}
<div className="flex items-center gap-1.5 sm:gap-2"> <div className="flex items-center gap-1.5 sm:gap-2">
{/* Language Selector Pills */}
{enabledLanguages.length > 1 && (
<div className="flex items-center gap-1 px-1 py-1 rounded-full bg-black/30 backdrop-blur-md border border-white/15">
{enabledLanguages.map((lang) => {
const isActive = selectedLanguage === lang;
return (
<button
key={lang}
type="button"
onClick={() => setSelectedLanguage(lang)}
className={`px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-[11px] font-bold tracking-wide transition-all ${
isActive ? "bg-white text-stone-900 shadow-sm" : "text-white/80 hover:text-white"
}`}
>
{LANGUAGE_LABELS[lang] ?? lang.toUpperCase()}
</button>
);
})}
</div>
)}
{/* Restaurant Info Trigger */} {/* Restaurant Info Trigger */}
{(restaurant.phone || restaurant.address) && ( {(restaurant.phone || restaurant.address) && (
<button <button
type="button" type="button"
onClick={() => setShowInfoModal(true)} onClick={() => setShowInfoModal(true)}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md" className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
aria-label="Restoran Bilgisi" aria-label="Restoran Bilgisi"
> >
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -231,7 +281,7 @@ export function PublicMenuClient({
<button <button
type="button" type="button"
onClick={handleShare} onClick={handleShare}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md" className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
aria-label="Menüyü Paylaş" aria-label="Menüyü Paylaş"
> >
{copiedLink ? ( {copiedLink ? (
@@ -330,8 +380,9 @@ export function PublicMenuClient({
type="text" type="text"
placeholder="Yemek, içecek veya tatlı ara..." placeholder="Yemek, içecek veya tatlı ara..."
value={searchQuery} value={searchQuery}
aria-label="Menüde arama yap"
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 transition-all shadow-lg" className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 focus-visible:ring-amber-500 transition-all shadow-lg cursor-pointer"
style={{ style={{
outlineColor: theme.config.primaryColor, outlineColor: theme.config.primaryColor,
}} }}
@@ -410,7 +461,7 @@ export function PublicMenuClient({
}} }}
> >
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth py-0.5"> <div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth py-0.5">
{categories.map((cat) => { {localizedCategories.map((cat) => {
const isActive = activeCategoryId === cat.id; const isActive = activeCategoryId === cat.id;
return ( return (
<button <button
@@ -608,6 +659,9 @@ export function PublicMenuClient({
<img <img
src={item.image_url} src={item.image_url}
alt={item.name} alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy" loading="lazy"
/> />
@@ -687,6 +741,9 @@ export function PublicMenuClient({
<img <img
src={item.image_url} src={item.image_url}
alt={item.name} alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover rounded-2xl shadow-md group-hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover rounded-2xl shadow-md group-hover:scale-105 transition-transform duration-300"
loading="lazy" loading="lazy"
/> />
@@ -715,6 +772,9 @@ export function PublicMenuClient({
<img <img
src={item.image_url} src={item.image_url}
alt={item.name} alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-500" className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-500"
loading="lazy" loading="lazy"
/> />
@@ -794,6 +854,9 @@ export function PublicMenuClient({
<img <img
src={item.image_url} src={item.image_url}
alt={item.name} alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover grayscale contrast-125 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-300" className="w-full h-full object-cover grayscale contrast-125 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-300"
loading="lazy" loading="lazy"
/> />
@@ -820,6 +883,9 @@ export function PublicMenuClient({
<img <img
src={item.image_url} src={item.image_url}
alt={item.name} alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy" loading="lazy"
/> />
@@ -909,6 +975,9 @@ export function PublicMenuClient({
<img <img
src={item.image_url} src={item.image_url}
alt={item.name} alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy" loading="lazy"
/> />
@@ -926,23 +995,26 @@ export function PublicMenuClient({
{/* Footer */} {/* Footer */}
<footer <footer
className="px-6 py-10 text-center border-t mt-auto text-xs space-y-4" className="px-6 py-8 text-center border-t mt-auto text-xs space-y-3"
style={{ style={{
borderColor: theme.cardBorder, borderColor: theme.cardBorder,
color: theme.textSecondary, color: theme.textSecondary,
}} }}
> >
<div className="flex items-center justify-center gap-2 font-semibold"> <div className="flex items-center justify-center gap-2 font-bold tracking-wide">
<span>Powered by</span> <span className="opacity-80">Powered by</span>
<span className="text-stone-900 dark:text-white font-black tracking-wider uppercase"> <span
className="font-black tracking-widest uppercase px-2 py-0.5 rounded text-[11px]"
style={{ backgroundColor: `${theme.config.primaryColor}20`, color: theme.config.primaryColor }}
>
MENULIO MENULIO
</span> </span>
</div> </div>
<p className="text-[11px] opacity-70"> <p className="text-[11px] opacity-75 max-w-sm mx-auto">
© {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir. © {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir.
</p> </p>
<AyrisFooterBadge className="mt-4 pt-4 border-t border-black/10 dark:border-white/10" /> <AyrisFooterBadge className="pt-3 border-t border-black/5 dark:border-white/10" />
</footer> </footer>
{/* Floating Back to Top Button */} {/* Floating Back to Top Button */}
@@ -973,6 +1045,9 @@ export function PublicMenuClient({
<img <img
src={selectedItem.image_url} src={selectedItem.image_url}
alt={selectedItem.name} alt={selectedItem.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<button <button
@@ -0,0 +1,15 @@
-- Multi-language menu support (PRD §13). Translations are stored inline as
-- JSONB keyed by language code rather than a separate table — menu content
-- is small and always read/written as a whole category+items tree, so a
-- join-free read matches how the public page already fetches it.
alter table restaurants
add column if not exists enabled_languages text[] not null default array['tr'];
alter table menu_categories
add column if not exists translations jsonb not null default '{}'::jsonb;
-- shape: { "en": { "name": "...", "description": "..." }, "de": {...} }
alter table menu_items
add column if not exists translations jsonb not null default '{}'::jsonb;
-- shape: { "en": { "name": "...", "description": "..." }, "de": {...} }
@@ -0,0 +1,2 @@
alter table restaurants
add column if not exists cover_url text;