Files
meezi/web/koja/src/app/[locale]/cafe/[slug]/page.tsx
T
soroush.asadi e839db7331 fix(koja): default to fa (no browser locale guess); guard null discoverProfile
Koja auto-detected locale from the browser Accept-Language (en for many Persian users); set localeDetection:false so locale-less URLs default to fa. Also guarded cafe.discoverProfile across the cafe page, cafe card, and JSON-LD — a café without a discover profile crashed the page (500). The cafe page now resolves the café first and notFound()s an unknown slug before fetching menu/reviews.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:51:50 +03:30

453 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Navbar } from "@/components/layout/navbar";
import { Footer } from "@/components/layout/footer";
import { CafeJsonLd } from "@/components/seo/cafe-json-ld";
import { getCafe, getCafeMenu, getCafeReviews, getAllCafeSlugs, discoverCafes } from "@/lib/api";
import {
MapPin, Phone, Clock, Star, BadgeCheck, Instagram,
Globe, ChevronLeft, Wifi, Coffee, Users
} from "lucide-react";
import { getDayLabel, PRICE_TIER_LABELS, NOISE_LABELS, formatRating, cn } from "@/lib/utils";
import { CafeCard } from "@/components/cafe/cafe-card";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
export async function generateStaticParams() {
const slugs = await getAllCafeSlugs();
const locales = ["fa", "en"];
return locales.flatMap((locale) => slugs.map((slug) => ({ locale, slug })));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const cafe = await getCafe(slug);
if (!cafe) return { title: "Cafe not found" };
const name = locale === "en" && cafe.nameEn ? cafe.nameEn : cafe.name;
const description = cafe.description
?? (locale === "fa"
? `${name} در ${cafe.city ?? "ایران"} — امتیاز ${formatRating(cafe.averageRating)} از ${cafe.reviewCount} نظر`
: `${name} in ${cafe.city ?? "Iran"} — rated ${formatRating(cafe.averageRating)} from ${cafe.reviewCount} reviews`);
const ogImage = cafe.coverImageUrl
?? `${BASE}/api/og?t=${encodeURIComponent(name)}&s=${encodeURIComponent(description)}`;
return {
title: name,
description,
openGraph: {
type: "website",
title: name,
description,
images: [{ url: ogImage, width: 1200, height: 630, alt: name }],
locale: locale === "fa" ? "fa_IR" : "en_US",
},
twitter: { card: "summary_large_image", title: name, description, images: [ogImage] },
alternates: {
canonical: `${BASE}/${locale}/cafe/${slug}`,
languages: {
fa: `${BASE}/fa/cafe/${slug}`,
en: `${BASE}/en/cafe/${slug}`,
},
},
};
}
const DAY_ORDER = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"] as const;
export default async function CafePage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const t = await getTranslations({ locale, namespace: "cafe" });
const isFa = locale === "fa";
// Resolve the café first so an unknown slug 404s cleanly instead of doing
// (and potentially erroring on) the menu/review fetches.
const cafe = await getCafe(slug);
if (!cafe) notFound();
const [menu, reviews] = await Promise.all([
getCafeMenu(slug),
getCafeReviews(slug),
]);
const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name);
// discoverProfile may be absent for cafés that never filled it in — fall back
// to an empty profile so the page renders instead of throwing a 500.
const profile = cafe.discoverProfile ?? {
themes: [],
size: null,
floors: null,
vibes: [],
occasions: [],
spaceFeatures: [],
noiseLevel: null,
priceTier: null,
};
const priceTier = profile.priceTier;
// Similar cafes
const similar = cafe.city
? (await discoverCafes({ city: cafe.city, sort: "rating" }))
.filter((c) => c.slug !== slug)
.slice(0, 4)
: [];
return (
<>
<CafeJsonLd cafe={cafe} locale={locale} baseUrl={BASE} />
<Navbar />
<main className="pb-16">
{/* Hero cover */}
<div className="relative h-56 overflow-hidden bg-gray-200 sm:h-80">
{cafe.coverImageUrl ? (
<img
src={cafe.coverImageUrl}
alt={name}
className="h-full w-full object-cover"
priority-fetch="high"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-brand-100 to-brand-200">
<Coffee className="h-16 w-16 text-brand-300" />
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Back link */}
<a
href={`/${locale}/search`}
className="absolute start-4 top-4 flex items-center gap-1 rounded-full bg-black/30 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition hover:bg-black/50"
>
{isFa ? <ChevronLeft className="h-3.5 w-3.5 rotate-180" /> : <ChevronLeft className="h-3.5 w-3.5" />}
{t("backToSearch")}
</a>
{/* Open badge */}
<div className={cn(
"absolute end-4 top-4 rounded-full px-3 py-1 text-xs font-semibold",
cafe.isOpenNow ? "bg-emerald-500 text-white" : "bg-gray-800/70 text-white"
)}>
{cafe.isOpenNow ? t("openNow") : t("closedNow")}
</div>
{/* Name / city overlay */}
<div className="absolute bottom-4 start-4 end-4">
<div className="flex items-end gap-3">
{cafe.logoUrl && (
<img
src={cafe.logoUrl}
alt=""
className="h-14 w-14 shrink-0 rounded-2xl border-2 border-white bg-white object-cover shadow-lg"
/>
)}
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-xl font-extrabold text-white sm:text-2xl">
{name}
{cafe.isVerified && (
<BadgeCheck className="h-5 w-5 text-emerald-400" aria-label={t("verified")} />
)}
</h1>
{cafe.city && (
<p className="mt-0.5 flex items-center gap-1 text-sm text-white/80">
<MapPin className="h-3.5 w-3.5" />
{cafe.city}
{cafe.address && `${cafe.address}`}
</p>
)}
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-5xl px-4 sm:px-6">
{/* Quick stats row */}
<div className="mt-6 flex flex-wrap items-center gap-4">
{cafe.reviewCount > 0 && (
<div className="flex items-center gap-1.5">
<Star className="h-4 w-4 fill-amber-400 text-amber-400" />
<span className="text-lg font-bold">{formatRating(cafe.averageRating)}</span>
<span className="text-sm text-gray-400">({cafe.reviewCount} {t("reviews")})</span>
</div>
)}
{priceTier && (
<span className="rounded-full border border-gray-200 px-3 py-0.5 text-sm text-gray-600">
{PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier}
</span>
)}
{profile.noiseLevel && (
<span className="rounded-full border border-gray-200 px-3 py-0.5 text-sm text-gray-600">
{NOISE_LABELS[profile.noiseLevel]?.[isFa ? "fa" : "en"] ?? profile.noiseLevel}
</span>
)}
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main column */}
<div className="lg:col-span-2 space-y-6">
{/* Description */}
{cafe.description && (
<section className="rounded-2xl border border-gray-100 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold text-gray-900">{t("about")}</h2>
<p className="text-sm leading-relaxed text-gray-600">{cafe.description}</p>
</section>
)}
{/* Attributes */}
{(profile.themes.length > 0 || profile.vibes.length > 0 || profile.occasions.length > 0 || profile.spaceFeatures.length > 0) && (
<section className="rounded-2xl border border-gray-100 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-gray-900">{t("features")}</h2>
<div className="space-y-3">
{profile.themes.length > 0 && (
<TagRow label={t("themes")} tags={profile.themes} color="brand" />
)}
{profile.vibes.length > 0 && (
<TagRow label={t("vibes")} tags={profile.vibes} color="purple" />
)}
{profile.occasions.length > 0 && (
<TagRow label={t("occasions")} tags={profile.occasions} color="amber" />
)}
{profile.spaceFeatures.length > 0 && (
<TagRow label={t("spaceFeatures")} tags={profile.spaceFeatures} color="gray" />
)}
</div>
</section>
)}
{/* Gallery */}
{cafe.galleryUrls.length > 0 && (
<section className="rounded-2xl border border-gray-100 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold text-gray-900">{t("gallery")}</h2>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{cafe.galleryUrls.map((url, i) => (
<img
key={i}
src={url}
alt={`${name}${i + 1}`}
className="aspect-square w-full rounded-xl object-cover"
loading="lazy"
/>
))}
</div>
</section>
)}
{/* Menu preview */}
{menu && menu.categories.length > 0 && (
<section className="rounded-2xl border border-gray-100 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-gray-900">{t("menu")}</h2>
<div className="space-y-4">
{menu.categories.slice(0, 3).map((cat) => (
<div key={cat.id}>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
{isFa ? cat.name : (cat.nameEn ?? cat.name)}
</p>
<div className="space-y-2">
{cat.items.slice(0, 4).map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{item.imageUrl && (
<img src={item.imageUrl} alt="" className="h-10 w-10 shrink-0 rounded-lg object-cover" />
)}
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-800">
{isFa ? item.name : (item.nameEn ?? item.name)}
</p>
{item.description && (
<p className="truncate text-xs text-gray-400">{item.description}</p>
)}
</div>
</div>
<span className="shrink-0 text-sm font-semibold text-brand-700">
{new Intl.NumberFormat(isFa ? "fa-IR" : "en-US").format(item.price)}
</span>
</div>
))}
</div>
</div>
))}
</div>
<a
href={`https://app.meezi.ir/m/${cafe.slug}`}
target="_blank"
rel="noopener"
className="mt-4 flex w-full items-center justify-center gap-2 rounded-xl border border-brand-200 py-2.5 text-sm font-semibold text-brand-700 transition hover:bg-brand-50"
>
{t("viewMenu")}
</a>
</section>
)}
{/* Reviews */}
<section className="rounded-2xl border border-gray-100 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-gray-900">{t("reviewsTab")}</h2>
{reviews.length === 0 ? (
<p className="text-sm text-gray-400">{t("noReviews")}</p>
) : (
<div className="space-y-4">
{reviews.map((r) => (
<div key={r.id} className="border-b border-gray-50 pb-4 last:border-0">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">{r.authorName}</p>
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn("h-3 w-3", i < r.rating ? "fill-amber-400 text-amber-400" : "text-gray-200")}
/>
))}
</div>
</div>
{r.comment && (
<p className="mt-1 text-sm leading-relaxed text-gray-600">{r.comment}</p>
)}
</div>
))}
</div>
)}
</section>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Actions */}
<div className="rounded-2xl border border-gray-100 bg-white p-5 space-y-2.5">
<a
href={`https://app.meezi.ir/m/${cafe.slug}`}
target="_blank"
rel="noopener"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-700 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-800"
>
<Coffee className="h-4 w-4" />
{t("viewMenu")}
</a>
{cafe.phone && (
<a
href={`tel:${cafe.phone}`}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-gray-200 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
<Phone className="h-4 w-4" />
{t("phone")}
</a>
)}
{cafe.address && (
<a
href={`https://maps.google.com/?q=${encodeURIComponent(`${cafe.name} ${cafe.address}`)}`}
target="_blank"
rel="noopener"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-gray-200 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
<MapPin className="h-4 w-4" />
{t("directions")}
</a>
)}
{cafe.instagramHandle && (
<a
href={`https://instagram.com/${cafe.instagramHandle}`}
target="_blank"
rel="noopener"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-gray-200 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
<Instagram className="h-4 w-4" />
@{cafe.instagramHandle}
</a>
)}
</div>
{/* Working hours */}
{cafe.workingHours && (
<div className="rounded-2xl border border-gray-100 bg-white p-5">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Clock className="h-4 w-4 text-brand-600" />
{t("workingHours")}
</h3>
<div className="space-y-1.5">
{DAY_ORDER.map((day) => {
const d = cafe.workingHours![day];
if (!d) return null;
return (
<div key={day} className="flex items-center justify-between text-xs">
<span className="text-gray-500">{getDayLabel(day, locale)}</span>
<span className={d.isOpen ? "font-medium text-gray-900" : "text-gray-400"}>
{d.isOpen && d.open && d.close ? `${d.open} ${d.close}` : (isFa ? "تعطیل" : "Closed")}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Badges */}
{cafe.badges.length > 0 && (
<div className="rounded-2xl border border-gray-100 bg-white p-5">
<div className="flex flex-wrap gap-2">
{cafe.badges.map((b) => (
<span
key={b.key}
className="flex items-center gap-1 rounded-full bg-brand-50 px-3 py-1 text-xs font-medium text-brand-700"
>
<span>{b.icon}</span>
{b.label}
</span>
))}
</div>
</div>
)}
</aside>
</div>
{/* Similar cafes */}
{similar.length > 0 && (
<section className="mt-10">
<h2 className="mb-5 text-lg font-bold text-gray-900">{t("similarCafes")}</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{similar.map((c) => (
<CafeCard key={c.id} cafe={c} locale={locale} href={`/${locale}/cafe/${c.slug}`} />
))}
</div>
</section>
)}
</div>
</main>
<Footer />
</>
);
}
function TagRow({ label, tags, color }: { label: string; tags: string[]; color: string }) {
const colorMap: Record<string, string> = {
brand: "bg-brand-50 text-brand-700",
purple: "bg-purple-50 text-purple-700",
amber: "bg-amber-50 text-amber-700",
gray: "bg-gray-100 text-gray-600",
};
return (
<div>
<p className="mb-1.5 text-xs font-medium text-gray-400">{label}</p>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span key={tag} className={cn("rounded-full px-2.5 py-0.5 text-xs font-medium", colorMap[color] ?? colorMap.gray)}>
{tag}
</span>
))}
</div>
</div>
);
}