From 3f326fa4070ee61a2dc6e1212e11e16f8971ce68 Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Thu, 20 Aug 2026 17:58:42 +0300 Subject: [PATCH] feat: allow updating custom menu slug with format validation, uniqueness checking and active cache sync --- apps/api/src/routes/restaurants.ts | 43 +++++++++++++++++++- apps/mobile/src/app/account/index.tsx | 56 +++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/restaurants.ts b/apps/api/src/routes/restaurants.ts index 762f822..ae8f2f5 100644 --- a/apps/api/src/routes/restaurants.ts +++ b/apps/api/src/routes/restaurants.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { requireAuth, requireRestaurantMember } from "../lib/auth.js"; +import { slugify } from "@menulio/shared"; import { generateUniqueRestaurantSlug } from "../lib/slug.js"; import { supabase } from "../lib/supabase.js"; import { deleteCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js"; @@ -12,7 +13,13 @@ const createRestaurantSchema = z.object({ address: z.string().max(500).nullish(), }); -const updateRestaurantSchema = createRestaurantSchema.partial(); +const updateRestaurantSchema = z.object({ + name: z.string().min(1).max(120).optional(), + logoUrl: z.string().nullish(), + phone: z.string().max(30).nullish(), + address: z.string().max(500).nullish(), + slug: z.string().min(2).max(100).optional(), +}); export const restaurantsRoutes: FastifyPluginAsync = async (app) => { app.post("/restaurants", async (req, reply) => { @@ -101,7 +108,38 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues }); } - const { name, logoUrl, phone, address } = parsed.data; + const { name, logoUrl, phone, address, slug } = parsed.data; + + let formattedSlug: string | undefined = undefined; + if (slug !== undefined) { + const cleanSlug = slugify(slug); + if (!cleanSlug || cleanSlug.length < 2) { + return reply.code(400).send({ message: "Geçersiz menü adresi formatı. Harf ve rakamlardan oluşmalıdır." }); + } + + const RESERVED_SLUGS = new Set([ + "demo", "templates", "admin", "api", "privacy", "support", + "login", "register", "app", "www", "auth", "account", "menu", "menus", "qr", "settings" + ]); + + if (RESERVED_SLUGS.has(cleanSlug)) { + return reply.code(400).send({ message: "Bu menü adresi sistem tarafından ayrılmıştır, kullanılamaz." }); + } + + const { data: existing } = await supabase + .from("restaurants") + .select("id") + .eq("slug", cleanSlug) + .neq("id", id) + .maybeSingle(); + + if (existing) { + return reply.code(409).send({ message: "Bu özel menü adresi başka bir restoran tarafından kullanılıyor." }); + } + + formattedSlug = cleanSlug; + } + const { data, error } = await supabase .from("restaurants") .update({ @@ -109,6 +147,7 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => { ...(logoUrl !== undefined ? { logo_url: logoUrl } : {}), ...(phone !== undefined ? { phone } : {}), ...(address !== undefined ? { address } : {}), + ...(formattedSlug !== undefined ? { slug: formattedSlug } : {}), updated_at: new Date().toISOString(), }) .eq("id", id) diff --git a/apps/mobile/src/app/account/index.tsx b/apps/mobile/src/app/account/index.tsx index 3d7a00e..76eb751 100644 --- a/apps/mobile/src/app/account/index.tsx +++ b/apps/mobile/src/app/account/index.tsx @@ -18,7 +18,7 @@ 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 { getActiveRestaurant, setActiveRestaurant, clearActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaurant"; import { getPublicMenuUrl, getPublicMenuDisplayUrl } from "@/lib/urls"; interface RestaurantDetail { @@ -174,14 +174,24 @@ export default function AccountScreen() { setSavingRest(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); try { - await api.patch(`/restaurants/${active.restaurantId}`, { + const updated = await api.patch(`/restaurants/${active.restaurantId}`, { name: name.trim(), phone: phone.trim() || null, address: address.trim() || null, logoUrl: logoUrl || null, + slug: slug.trim() || undefined, }); + + if (updated.slug) { + setSlug(updated.slug); + await setActiveRestaurant({ + ...active, + slug: updated.slug, + }); + } + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); - Alert.alert("Başarılı 🎉", "Restoran bilgileri ve logo güncellendi."); + Alert.alert("Başarılı 🎉", "Restoran bilgileri ve özel menü adresi güncellendi."); } catch (err) { Alert.alert("Hata", err instanceof Error ? err.message : "Güncellenemedi."); } finally { @@ -916,6 +926,46 @@ export default function AccountScreen() { /> + {/* Restaurant Slug */} + + + Özel Menü Link Adresi (Slug) + + + + menul.io/ + + setSlug(val.toLowerCase().replace(/[^a-z0-9-]/g, ""))} + placeholder="kebapci-sinan" + placeholderTextColor="#57534E" + autoCapitalize="none" + autoCorrect={false} + style={{ + flex: 1, + paddingVertical: 12, + fontSize: 14, + color: "#1C1917", + fontWeight: "600", + }} + /> + + + Kullanıcılar https://menul.io/{slug || "adresiniz"} üzerinden menünüze ulaşır. + + + {/* Phone */}