feat: allow updating custom menu slug with format validation, uniqueness checking and active cache sync
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyPluginAsync } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||||
|
import { slugify } from "@menulio/shared";
|
||||||
import { generateUniqueRestaurantSlug } from "../lib/slug.js";
|
import { generateUniqueRestaurantSlug } from "../lib/slug.js";
|
||||||
import { supabase } from "../lib/supabase.js";
|
import { supabase } from "../lib/supabase.js";
|
||||||
import { deleteCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js";
|
import { deleteCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js";
|
||||||
@@ -12,7 +13,13 @@ const createRestaurantSchema = z.object({
|
|||||||
address: z.string().max(500).nullish(),
|
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) => {
|
export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.post("/restaurants", async (req, reply) => {
|
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 });
|
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
|
const { data, error } = await supabase
|
||||||
.from("restaurants")
|
.from("restaurants")
|
||||||
.update({
|
.update({
|
||||||
@@ -109,6 +147,7 @@ export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
...(logoUrl !== undefined ? { logo_url: logoUrl } : {}),
|
...(logoUrl !== undefined ? { logo_url: logoUrl } : {}),
|
||||||
...(phone !== undefined ? { phone } : {}),
|
...(phone !== undefined ? { phone } : {}),
|
||||||
...(address !== undefined ? { address } : {}),
|
...(address !== undefined ? { address } : {}),
|
||||||
|
...(formattedSlug !== undefined ? { slug: formattedSlug } : {}),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import * as Haptics from "expo-haptics";
|
|||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { supabase } from "@/lib/supabase";
|
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";
|
import { getPublicMenuUrl, getPublicMenuDisplayUrl } from "@/lib/urls";
|
||||||
|
|
||||||
interface RestaurantDetail {
|
interface RestaurantDetail {
|
||||||
@@ -174,14 +174,24 @@ export default function AccountScreen() {
|
|||||||
setSavingRest(true);
|
setSavingRest(true);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||||
try {
|
try {
|
||||||
await api.patch(`/restaurants/${active.restaurantId}`, {
|
const updated = await api.patch<RestaurantDetail>(`/restaurants/${active.restaurantId}`, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
phone: phone.trim() || null,
|
phone: phone.trim() || null,
|
||||||
address: address.trim() || null,
|
address: address.trim() || null,
|
||||||
logoUrl: logoUrl || 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(() => {});
|
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) {
|
} catch (err) {
|
||||||
Alert.alert("Hata", err instanceof Error ? err.message : "Güncellenemedi.");
|
Alert.alert("Hata", err instanceof Error ? err.message : "Güncellenemedi.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -916,6 +926,46 @@ export default function AccountScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Restaurant Slug */}
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
|
||||||
|
Özel Menü Link Adresi (Slug)
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#FAF8F5",
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: "#D6D3D1",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: "700", color: "#C8A96B", marginRight: 4 }}>
|
||||||
|
menul.io/
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={slug}
|
||||||
|
onChangeText={(val) => 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 11, color: "#A8A29E", marginTop: 4 }}>
|
||||||
|
Kullanıcılar <Text style={{ fontWeight: "700", color: "#78716C" }}>https://menul.io/{slug || "adresiniz"}</Text> üzerinden menünüze ulaşır.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<View>
|
<View>
|
||||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
|
<Text style={{ fontSize: 12, fontWeight: "700", color: "#78716C", marginBottom: 6 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user