Rename public discovery app from "finder" to "koja"

Rebrand the public café-discovery app: directories web/finder→web/koja and
docker/finder→docker/koja, plus all service wiring (docker-compose, Caddy
subdomain koja.meezi.ir, env vars KOJA_PORT / NEXT_PUBLIC_KOJA_URL, CI
workflows) and the app's display name (Koja / کجا).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 17:02:22 +03:30
parent 16cff8730b
commit 289c808257
43 changed files with 74 additions and 58 deletions
@@ -0,0 +1,439 @@
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";
const [cafe, menu, reviews] = await Promise.all([
getCafe(slug),
getCafeMenu(slug),
getCafeReviews(slug),
]);
if (!cafe) notFound();
const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name);
const profile = cafe.discoverProfile;
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>
);
}
@@ -0,0 +1,114 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Navbar } from "@/components/layout/navbar";
import { Footer } from "@/components/layout/footer";
import { CafeCard } from "@/components/cafe/cafe-card";
import { AiSearchBar } from "@/components/search/ai-search-bar";
import { discoverCafes } from "@/lib/api";
import { MapPin } from "lucide-react";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
const CITY_DISPLAY: Record<string, { fa: string; en: string }> = {
tehran: { fa: "تهران", en: "Tehran" },
isfahan: { fa: "اصفهان", en: "Isfahan" },
mashhad: { fa: "مشهد", en: "Mashhad" },
shiraz: { fa: "شیراز", en: "Shiraz" },
tabriz: { fa: "تبریز", en: "Tabriz" },
karaj: { fa: "کرج", en: "Karaj" },
rasht: { fa: "رشت", en: "Rasht" },
ahvaz: { fa: "اهواز", en: "Ahvaz" },
qom: { fa: "قم", en: "Qom" },
kermanshah: { fa: "کرمانشاه", en: "Kermanshah" },
};
export async function generateStaticParams() {
return Object.keys(CITY_DISPLAY).flatMap((city) => [
{ locale: "fa", city },
{ locale: "en", city },
]);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; city: string }>;
}): Promise<Metadata> {
const { locale, city } = await params;
const t = await getTranslations({ locale, namespace: "city" });
const displayName = CITY_DISPLAY[city]?.[locale as "fa" | "en"] ?? city;
return {
title: t("cafesIn", { city: displayName }),
description: t("description", { city: displayName }),
alternates: {
canonical: `${BASE}/${locale}/city/${city}`,
languages: {
fa: `${BASE}/fa/city/${city}`,
en: `${BASE}/en/city/${city}`,
},
},
};
}
export default async function CityPage({
params,
}: {
params: Promise<{ locale: string; city: string }>;
}) {
const { locale, city } = await params;
const t = await getTranslations({ locale, namespace: "city" });
const isFa = locale === "fa";
const displayName = CITY_DISPLAY[city]?.[locale as "fa" | "en"] ?? city;
const cityApiParam = CITY_DISPLAY[city]?.[isFa ? "fa" : "en"] ?? city;
const cafes = await discoverCafes({ city: cityApiParam, sort: "rating" });
return (
<>
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
{/* City header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-gray-400">
<a href={`/${locale}`} className="hover:text-brand-700">{isFa ? "خانه" : "Home"}</a>
<span>/</span>
<span>{displayName}</span>
</div>
<h1 className="mt-2 flex items-center gap-2 text-2xl font-extrabold text-gray-900 sm:text-3xl">
<MapPin className="h-6 w-6 text-brand-600" />
{t("cafesIn", { city: displayName })}
</h1>
<p className="mt-1 text-sm text-gray-500">
{cafes.length} {isFa ? "مکان یافت شد" : "places found"}
</p>
</div>
{/* Search in city */}
<div className="mb-8">
<AiSearchBar initialCity={cityApiParam} />
</div>
{cafes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 py-20 text-center text-gray-400">
<p className="text-sm">
{isFa ? "هنوز کافه‌ای در این شهر ثبت نشده" : "No cafes registered in this city yet"}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cafes.map((cafe) => (
<CafeCard
key={cafe.id}
cafe={cafe}
locale={locale}
href={`/${locale}/cafe/${cafe.slug}`}
/>
))}
</div>
)}
</main>
<Footer />
</>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata, Viewport } from "next";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing";
import "@/app/globals.css";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
export const viewport: Viewport = {
themeColor: "#0F6E56",
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return {
metadataBase: new URL(BASE),
applicationName: t("siteName"),
title: {
default: t("homeTitle"),
template: `%s — ${t("siteName")}`,
},
description: t("siteDescription"),
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: t("siteName"),
},
formatDetection: { telephone: false },
openGraph: {
type: "website",
locale: locale === "fa" ? "fa_IR" : "en_US",
url: `${BASE}/${locale}`,
siteName: t("siteName"),
title: t("homeTitle"),
description: t("siteDescription"),
},
twitter: {
card: "summary_large_image",
site: "@MeeziApp",
},
alternates: {
canonical: `${BASE}/${locale}`,
languages: { fa: `${BASE}/fa`, en: `${BASE}/en` },
},
other: {
"mobile-web-app-capable": "yes",
},
};
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as "fa" | "en")) notFound();
const messages = await getMessages();
const dir = locale === "fa" ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
{/* iOS splash / icons */}
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
</head>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
+132
View File
@@ -0,0 +1,132 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Navbar } from "@/components/layout/navbar";
import { Footer } from "@/components/layout/footer";
import { AiSearchBar } from "@/components/search/ai-search-bar";
import { CafeCard } from "@/components/cafe/cafe-card";
import { discoverCafes } from "@/lib/api";
import { Sparkles, MapPin } from "lucide-react";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return {
title: t("homeTitle"),
description: t("homeDescription"),
alternates: {
canonical: `${BASE}/${locale}`,
languages: { fa: `${BASE}/fa`, en: `${BASE}/en` },
},
};
}
const CITIES_FA = ["تهران", "اصفهان", "مشهد", "شیراز", "تبریز", "کرج", "رشت", "اهواز"];
const CITIES_EN = ["Tehran", "Isfahan", "Mashhad", "Shiraz", "Tabriz", "Karaj", "Rasht", "Ahvaz"];
const CITY_SLUGS = ["tehran", "isfahan", "mashhad", "shiraz", "tabriz", "karaj", "rasht", "ahvaz"];
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "home" });
const isFa = locale === "fa";
// Fetch featured cafes (top-rated, with profile)
const featured = await discoverCafes({ sort: "rating" });
const topCafes = featured.slice(0, 8);
const cities = isFa ? CITIES_FA : CITIES_EN;
return (
<>
<Navbar />
<main>
{/* Hero */}
<section className="relative overflow-hidden bg-gradient-to-b from-brand-700 to-brand-900 pb-20 pt-16 text-white">
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,white,transparent_60%)]" />
</div>
<div className="relative mx-auto max-w-3xl px-4 text-center sm:px-6">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-semibold backdrop-blur-sm">
<Sparkles className="h-3 w-3" />
{t("badge")}
</span>
<h1 className="mt-5 text-3xl font-extrabold leading-tight sm:text-5xl">
{t("headline")}
</h1>
<p className="mt-4 text-base text-white/70 sm:text-lg">
{t("subheadline")}
</p>
<div className="mt-8">
<AiSearchBar large />
</div>
</div>
</section>
<div className="mx-auto max-w-7xl px-4 sm:px-6">
{/* Popular cities */}
<section className="py-10">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
{t("popularCities")}
</h2>
<div className="flex flex-wrap gap-2">
{cities.map((city, i) => (
<a
key={city}
href={`/${locale}/city/${CITY_SLUGS[i]}`}
className="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:border-brand-300 hover:text-brand-700"
>
<MapPin className="h-3.5 w-3.5 text-brand-500" />
{city}
</a>
))}
</div>
</section>
{/* Featured cafes */}
<section className="pb-16">
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-900">{t("featuredTitle")}</h2>
<p className="mt-1 text-sm text-gray-500">{t("featuredSubtitle")}</p>
</div>
{topCafes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 py-16 text-center text-gray-400">
<p className="text-sm">{t("noResults")}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{topCafes.map((cafe) => (
<CafeCard
key={cafe.id}
cafe={cafe}
locale={locale}
href={`/${locale}/cafe/${cafe.slug}`}
/>
))}
</div>
)}
<div className="mt-8 text-center">
<a
href={`/${locale}/search`}
className="inline-flex items-center gap-2 rounded-xl border border-brand-200 bg-brand-50 px-6 py-2.5 text-sm font-semibold text-brand-700 transition hover:bg-brand-100"
>
{isFa ? "مشاهده همه →" : "See all →"}
</a>
</div>
</section>
</div>
</main>
<Footer />
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Navbar } from "@/components/layout/navbar";
import { Footer } from "@/components/layout/footer";
import { AiSearchBar } from "@/components/search/ai-search-bar";
import { CafeCard } from "@/components/cafe/cafe-card";
import { discoverCafes } from "@/lib/api";
import type { DiscoverFilters } from "@/lib/types";
import { SlidersHorizontal } from "lucide-react";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
const q = (await searchParams)?.q;
const title = q
? `${q}${t("searchTitle")}`
: t("searchTitle");
return {
title,
robots: { index: false },
};
}
const SORT_OPTIONS_FA = [
{ value: "", label: "مرتبط‌ترین" },
{ value: "rating", label: "بالاترین امتیاز" },
];
const SORT_OPTIONS_EN = [
{ value: "", label: "Most relevant" },
{ value: "rating", label: "Highest rated" },
];
export default async function SearchPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "search" });
const isFa = locale === "fa";
const sp = (await searchParams) ?? {};
const filters: DiscoverFilters = {
q: sp.q,
city: sp.city,
sort: sp.sort,
openNow: sp.openNow === "true",
minRating: sp.minRating ? Number(sp.minRating) : undefined,
themes: sp.themes ? sp.themes.split(",") : undefined,
vibes: sp.vibes ? sp.vibes.split(",") : undefined,
priceTier: sp.priceTier,
};
const results = await discoverCafes(filters);
const sortOpts = isFa ? SORT_OPTIONS_FA : SORT_OPTIONS_EN;
return (
<>
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
{/* Search bar */}
<div className="mb-6">
<AiSearchBar initialValue={sp.q ?? ""} initialCity={sp.city ?? ""} />
</div>
<div className="flex flex-col gap-6 lg:flex-row">
{/* Sidebar filters */}
<aside className="w-full shrink-0 lg:w-56">
<div className="rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-sm font-semibold text-gray-900">
<SlidersHorizontal className="h-4 w-4" />
{t("filters")}
</p>
{(sp.q || sp.city || sp.openNow || sp.minRating || sp.themes || sp.vibes) && (
<a
href={`/${locale}/search`}
className="text-xs font-medium text-brand-600 hover:underline"
>
{t("clearFilters")}
</a>
)}
</div>
{/* Open now */}
<FilterSection label={t("openNow")} className="mt-4">
<a
href={buildUrl(sp, locale, { openNow: sp.openNow === "true" ? undefined : "true" })}
className={`block rounded-lg px-3 py-2 text-sm transition ${sp.openNow === "true" ? "bg-brand-700 text-white" : "text-gray-600 hover:bg-gray-50"}`}
>
{t("openNow")}
</a>
</FilterSection>
{/* Sort */}
<FilterSection label={t("sortBy")} className="mt-4">
{sortOpts.map((o) => (
<a
key={o.value}
href={buildUrl(sp, locale, { sort: o.value || undefined })}
className={`block rounded-lg px-3 py-2 text-sm transition ${(sp.sort ?? "") === o.value ? "bg-brand-50 font-semibold text-brand-700" : "text-gray-600 hover:bg-gray-50"}`}
>
{o.label}
</a>
))}
</FilterSection>
{/* Rating */}
<FilterSection label={t("minRating")} className="mt-4">
{[4, 3, 2].map((r) => (
<a
key={r}
href={buildUrl(sp, locale, { minRating: String(r) })}
className={`block rounded-lg px-3 py-2 text-sm transition ${sp.minRating === String(r) ? "bg-brand-50 font-semibold text-brand-700" : "text-gray-600 hover:bg-gray-50"}`}
>
{"★".repeat(r)} {isFa ? "و بالاتر" : "& up"}
</a>
))}
</FilterSection>
</div>
</aside>
{/* Results */}
<div className="flex-1">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
{t("resultsCount", { count: results.length })}
</p>
</div>
{results.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 py-20 text-center">
<p className="text-sm text-gray-400">
{isFa ? "نتیجه‌ای یافت نشد — جستجوی دیگری امتحان کنید" : "No results — try a different search"}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{results.map((cafe) => (
<CafeCard
key={cafe.id}
cafe={cafe}
locale={locale}
href={`/${locale}/cafe/${cafe.slug}`}
/>
))}
</div>
)}
</div>
</div>
</main>
<Footer />
</>
);
}
function FilterSection({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={className}>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400">{label}</p>
<div className="space-y-0.5">{children}</div>
</div>
);
}
function buildUrl(sp: Record<string, string>, locale: string, override: Record<string, string | undefined>) {
const params = new URLSearchParams(sp);
for (const [k, v] of Object.entries(override)) {
if (v === undefined) params.delete(k);
else params.set(k, v);
}
return `/${locale}/search?${params.toString()}`;
}
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-sans: "Vazirmatn", "Inter", system-ui, sans-serif;
}
@layer base {
html {
font-family: var(--font-sans);
-webkit-tap-highlight-color: transparent;
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
/* RTL-aware scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: theme(colors.gray.300) transparent;
}
}
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
+7
View File
@@ -0,0 +1,7 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+47
View File
@@ -0,0 +1,47 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
id: "/fa",
name: "کجا — جستجوی کافه و رستوران",
short_name: "کجا",
description: "بهترین کافه‌ها و رستوران‌های ایران را با هوش مصنوعی پیدا کنید",
start_url: "/fa",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#0F6E56",
orientation: "portrait-primary",
categories: ["food", "lifestyle", "navigation"],
lang: "fa",
dir: "rtl",
icons: [
{
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
shortcuts: [
{
name: "جستجوی کافه",
short_name: "جستجو",
url: "/fa/search",
icons: [{ src: "/icons/icon-192.png", sizes: "192x192" }],
},
],
screenshots: [],
};
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
export default function OfflinePage() {
return (
<html lang="fa" dir="rtl">
<body
style={{
fontFamily: "Vazirmatn, system-ui, sans-serif",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
margin: 0,
backgroundColor: "#f9fafb",
color: "#111827",
textAlign: "center",
padding: "2rem",
}}
>
<div style={{ fontSize: "4rem", marginBottom: "1rem" }}>📶</div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "0.5rem" }}>
اتصال اینترنت ندارید
</h1>
<p style={{ color: "#6b7280", marginBottom: "1.5rem" }}>
کافههایی که قبلاً بازدید کردهاید در دسترس هستند.
</p>
<button
onClick={() => window.history.back()}
style={{
backgroundColor: "#0F6E56",
color: "white",
border: "none",
borderRadius: "0.75rem",
padding: "0.625rem 1.5rem",
fontSize: "0.875rem",
fontWeight: 600,
cursor: "pointer",
}}
>
بازگشت
</button>
</body>
</html>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// Middleware handles locale routing, but if it ever misses, redirect to /fa
export default function RootPage() {
redirect("/fa");
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/_next/"],
},
],
sitemap: `${BASE}/sitemap.xml`,
host: BASE,
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { MetadataRoute } from "next";
import { getAllCafeSlugs } from "@/lib/api";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
const LOCALES = ["fa", "en"];
const CITIES = [
"tehran", "isfahan", "mashhad", "shiraz", "tabriz",
"karaj", "ahvaz", "qom", "rasht", "kermanshah",
];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const slugs = await getAllCafeSlugs();
const staticPages = LOCALES.flatMap((locale) => [
{
url: `${BASE}/${locale}`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: 1.0,
},
{
url: `${BASE}/${locale}/search`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: 0.8,
},
]);
const cityPages = LOCALES.flatMap((locale) =>
CITIES.map((city) => ({
url: `${BASE}/${locale}/city/${city}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.7,
}))
);
const cafePages = LOCALES.flatMap((locale) =>
slugs.map((slug) => ({
url: `${BASE}/${locale}/cafe/${slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.9,
}))
);
return [...staticPages, ...cityPages, ...cafePages];
}
+111
View File
@@ -0,0 +1,111 @@
import { Star, MapPin, Clock, BadgeCheck } from "lucide-react";
import { cn, formatRating, PRICE_TIER_LABELS } from "@/lib/utils";
import type { CafeDiscoverDto } from "@/lib/types";
interface Props {
cafe: CafeDiscoverDto;
locale: string;
href: string;
}
export function CafeCard({ cafe, locale, href }: Props) {
const isFa = locale === "fa";
const name = isFa ? cafe.name : (cafe.name);
const priceTier = cafe.discoverProfile.priceTier;
const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null;
return (
<a
href={href}
className="group flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5"
>
{/* Cover image */}
<div className="relative h-44 overflow-hidden bg-gray-100">
{cafe.coverImageUrl ? (
<img
src={cafe.coverImageUrl}
alt={name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-brand-50 to-brand-100">
<span className="text-4xl font-bold text-brand-200">{name.charAt(0)}</span>
</div>
)}
{/* Open/Closed badge */}
<div className={cn(
"absolute top-3 end-3 rounded-full px-2.5 py-0.5 text-[10px] font-semibold",
cafe.isOpenNow
? "bg-emerald-500 text-white"
: "bg-gray-800/70 text-white"
)}>
{cafe.isOpenNow ? (isFa ? "باز" : "Open") : (isFa ? "بسته" : "Closed")}
</div>
{/* Logo overlay */}
{cafe.logoUrl && (
<div className="absolute bottom-3 start-3 h-10 w-10 overflow-hidden rounded-xl border-2 border-white bg-white shadow-sm">
<img src={cafe.logoUrl} alt="" className="h-full w-full object-cover" />
</div>
)}
</div>
{/* Content */}
<div className="flex flex-1 flex-col p-4">
<div className="flex items-start gap-2">
<h3 className="flex-1 text-sm font-semibold leading-snug text-gray-900 line-clamp-2">
{name}
{cafe.isVerified && (
<BadgeCheck className="inline ms-1 h-3.5 w-3.5 text-brand-600" />
)}
</h3>
</div>
{/* City */}
{cafe.city && (
<p className="mt-1 flex items-center gap-1 text-xs text-gray-400">
<MapPin className="h-3 w-3 shrink-0" />
{cafe.city}
</p>
)}
{/* Tags */}
{cafe.discoverProfile.themes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{cafe.discoverProfile.themes.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700"
>
{tag}
</span>
))}
</div>
)}
{/* Footer row */}
<div className="mt-auto flex items-center justify-between pt-3">
{/* Rating */}
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
<span className="text-sm font-semibold text-gray-900">
{formatRating(cafe.averageRating)}
</span>
{cafe.reviewCount > 0 && (
<span className="text-xs text-gray-400">
({cafe.reviewCount})
</span>
)}
</div>
{/* Price tier */}
{priceLabel && (
<span className="text-xs text-gray-400">{priceLabel}</span>
)}
</div>
</div>
</a>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useTranslations, useLocale } from "next-intl";
import { MapPin } from "lucide-react";
export function Footer() {
const t = useTranslations("footer");
const locale = useLocale();
return (
<footer className="border-t border-gray-100 bg-white mt-16">
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{/* Brand */}
<div className="col-span-2 sm:col-span-1">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-700">
<MapPin className="h-3.5 w-3.5 text-white" />
</div>
<span className="font-bold text-gray-900">
{locale === "fa" ? "کجا" : "Koja"}
</span>
</div>
<p className="mt-3 text-xs leading-relaxed text-gray-500">{t("tagline")}</p>
</div>
{/* Links */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{t("product")}</p>
<ul className="mt-3 space-y-2">
<li><a href={`/${locale}/search`} className="text-sm text-gray-600 hover:text-brand-700">{t("forCafes")}</a></li>
</ul>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{t("company")}</p>
<ul className="mt-3 space-y-2">
<li><a href="https://meezi.ir" target="_blank" rel="noopener" className="text-sm text-gray-600 hover:text-brand-700">{t("about")}</a></li>
<li><a href="https://meezi.ir/contact" target="_blank" rel="noopener" className="text-sm text-gray-600 hover:text-brand-700">{t("contact")}</a></li>
</ul>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{t("legal")}</p>
<ul className="mt-3 space-y-2">
<li><a href="https://meezi.ir/privacy" target="_blank" rel="noopener" className="text-sm text-gray-600 hover:text-brand-700">{t("privacy")}</a></li>
<li><a href="https://meezi.ir/terms" target="_blank" rel="noopener" className="text-sm text-gray-600 hover:text-brand-700">{t("terms")}</a></li>
</ul>
</div>
</div>
<div className="mt-8 border-t border-gray-100 pt-6 text-center text-xs text-gray-400">
{t("copyright")}
</div>
</div>
</footer>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
import { Search, Menu, X, Globe, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
export function Navbar() {
const t = useTranslations("nav");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const otherLocale = locale === "fa" ? "en" : "fa";
const otherPath = pathname.replace(`/${locale}`, `/${otherLocale}`);
const links = [
{ href: `/${locale}`, label: t("home") },
{ href: `/${locale}/search`, label: t("search") },
];
return (
<header className="sticky top-0 z-40 w-full border-b border-gray-100 bg-white/95 backdrop-blur-sm">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-3 px-4 sm:px-6">
{/* Logo */}
<a href={`/${locale}`} className="flex items-center gap-2 shrink-0">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand-700">
<MapPin className="h-4 w-4 text-white" />
</div>
<span className="text-base font-bold text-gray-900">
{locale === "fa" ? "کجا" : "Koja"}
</span>
</a>
{/* Desktop nav */}
<nav className="hidden flex-1 items-center gap-1 sm:flex ms-4">
{links.map((l) => (
<a
key={l.href}
href={l.href}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
pathname === l.href
? "bg-brand-50 text-brand-700"
: "text-gray-600 hover:bg-gray-100"
)}
>
{l.label}
</a>
))}
</nav>
<div className="flex flex-1 items-center justify-end gap-2 sm:flex-none">
{/* Search shortcut */}
<a
href={`/${locale}/search`}
className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 sm:min-w-[180px]"
>
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">
{locale === "fa" ? "جستجوی کافه..." : "Search cafes..."}
</span>
</a>
{/* Language toggle */}
<a
href={otherPath}
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs font-medium text-gray-500 transition hover:bg-gray-100"
title={otherLocale === "fa" ? "فارسی" : "English"}
>
<Globe className="h-3.5 w-3.5" />
<span>{otherLocale === "fa" ? "FA" : "EN"}</span>
</a>
{/* Mobile menu toggle */}
<button
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 sm:hidden"
onClick={() => setOpen(!open)}
aria-label={open ? t("closeMenu") : t("openMenu")}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{/* Mobile menu */}
{open && (
<div className="border-t border-gray-100 bg-white px-4 py-3 sm:hidden">
{links.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{l.label}
</a>
))}
</div>
)}
</header>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useState, useRef, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { Search, Loader2, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
const SUGGESTIONS_FA = [
"کافه آروم با وای‌فای خوب مناسب کار",
"کافه لوکس مناسب قرار دونفره",
"رستوران سنتی با فضای خانوادگی",
"کافه با نور طبیعی و صبحانه خوب",
"کافه پت‌فرندلی در تهران",
];
const SUGGESTIONS_EN = [
"quiet cafe with good WiFi for working",
"upscale cafe for a date",
"traditional restaurant family-friendly",
"bright cafe with great breakfast",
"pet-friendly cafe in Tehran",
];
interface Props {
initialValue?: string;
initialCity?: string;
autoFocus?: boolean;
large?: boolean;
}
export function AiSearchBar({ initialValue = "", initialCity = "", autoFocus, large }: Props) {
const locale = useLocale();
const t = useTranslations("home");
const router = useRouter();
const [value, setValue] = useState(initialValue);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const suggestions = locale === "fa" ? SUGGESTIONS_FA : SUGGESTIONS_EN;
function submit(q: string) {
const params = new URLSearchParams();
if (q.trim()) params.set("q", q.trim());
if (initialCity) params.set("city", initialCity);
startTransition(() => {
router.push(`/${locale}/search?${params.toString()}`);
});
}
return (
<div className="w-full">
{/* Search input */}
<div className={cn(
"relative flex items-center overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm",
"transition-all focus-within:border-brand-400 focus-within:shadow-md focus-within:shadow-brand-100",
large && "rounded-2xl shadow-lg"
)}>
<div className="flex items-center gap-2 ps-4">
<Sparkles className="h-4 w-4 shrink-0 text-brand-600" />
</div>
<input
ref={inputRef}
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit(value)}
placeholder={t("searchPlaceholder")}
autoFocus={autoFocus}
className={cn(
"flex-1 bg-transparent px-3 py-3 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none",
large && "py-4 text-base"
)}
/>
<button
onClick={() => submit(value)}
disabled={isPending}
className={cn(
"m-1.5 flex items-center gap-1.5 rounded-xl bg-brand-700 px-4 py-2 text-sm font-semibold text-white",
"transition hover:bg-brand-800 disabled:opacity-70",
large && "px-5 py-2.5"
)}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
<span className="hidden sm:inline">{t("searchBtn")}</span>
</button>
</div>
{/* Suggestion chips */}
{large && (
<div className="mt-3 flex flex-wrap gap-2">
{suggestions.map((s) => (
<button
key={s}
onClick={() => { setValue(s); submit(s); }}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
{s}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,86 @@
import type { CafePublicDto } from "@/lib/types";
interface Props {
cafe: CafePublicDto;
locale: string;
baseUrl: string;
}
export function CafeJsonLd({ cafe, locale, baseUrl }: Props) {
const name = locale === "en" && cafe.nameEn ? cafe.nameEn : cafe.name;
const openingHours: string[] = [];
if (cafe.workingHours) {
const days = cafe.workingHours;
const dayMap: [string, keyof typeof days][] = [
["Sa", "sat"], ["Su", "sun"], ["Mo", "mon"],
["Tu", "tue"], ["We", "wed"], ["Th", "thu"], ["Fr", "fri"],
];
for (const [abbr, key] of dayMap) {
const d = days[key];
if (d?.isOpen && d.open && d.close) {
openingHours.push(`${abbr} ${d.open}-${d.close}`);
}
}
}
const schema: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "CafeOrCoffeeShop",
name,
description: cafe.description ?? undefined,
url: `${baseUrl}/${locale}/cafe/${cafe.slug}`,
...(cafe.logoUrl ? { logo: cafe.logoUrl } : {}),
...(cafe.coverImageUrl ? { image: [cafe.coverImageUrl, ...(cafe.galleryUrls ?? [])] } : {}),
...(cafe.address ? { address: { "@type": "PostalAddress", streetAddress: cafe.address, addressLocality: cafe.city ?? undefined, addressCountry: "IR" } } : {}),
...(cafe.phone ? { telephone: cafe.phone } : {}),
...(openingHours.length ? { openingHours } : {}),
...(cafe.instagramHandle ? { sameAs: [`https://instagram.com/${cafe.instagramHandle}`] } : {}),
...(cafe.websiteUrl ? { url: cafe.websiteUrl } : {}),
...(cafe.reviewCount > 0 ? {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: cafe.averageRating.toFixed(1),
reviewCount: cafe.reviewCount,
bestRating: "5",
worstRating: "1",
},
} : {}),
...(cafe.discoverProfile.themes.length ? {
servesCuisine: cafe.discoverProfile.themes,
} : {}),
priceRange: (() => {
const tier = cafe.discoverProfile.priceTier;
if (tier === "budget") return "﷼";
if (tier === "moderate") return "﷼﷼";
if (tier === "upscale") return "﷼﷼﷼";
if (tier === "luxury") return "﷼﷼﷼﷼";
return undefined;
})(),
};
// BreadcrumbList
const breadcrumb = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: locale === "fa" ? "خانه" : "Home", item: `${baseUrl}/${locale}` },
{ "@type": "ListItem", position: 2, name: locale === "fa" ? "جستجو" : "Search", item: `${baseUrl}/${locale}/search` },
...(cafe.city ? [{ "@type": "ListItem", position: 3, name: cafe.city, item: `${baseUrl}/${locale}/city/${cafe.city.toLowerCase()}` }] : []),
{ "@type": "ListItem", position: cafe.city ? 4 : 3, name, item: `${baseUrl}/${locale}/cafe/${cafe.slug}` },
],
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumb) }}
/>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
+6
View File
@@ -0,0 +1,6 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["fa", "en"],
defaultLocale: "fa",
});
+100
View File
@@ -0,0 +1,100 @@
import type {
ApiResponse,
CafeDiscoverDto,
CafePublicDto,
PublicMenuDto,
CafeReviewDto,
NlpHints,
DiscoverFilters,
} from "@/lib/types";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://api.meezi.ir";
async function get<T>(path: string, opts?: RequestInit): Promise<T | null> {
try {
const res = await fetch(`${API_URL}${path}`, {
next: { revalidate: 60 },
...opts,
});
if (!res.ok) return null;
const json = (await res.json()) as ApiResponse<T>;
return json.success ? json.data : null;
} catch {
return null;
}
}
// ── Discover / search ─────────────────────────────────────────────────────────
export async function discoverCafes(
filters: DiscoverFilters
): Promise<CafeDiscoverDto[]> {
const params = new URLSearchParams();
if (filters.city) params.set("city", filters.city);
if (filters.q) params.set("q", filters.q);
if (filters.minRating) params.set("minRating", String(filters.minRating));
if (filters.sort) params.set("sort", filters.sort);
if (filters.themes?.length) params.set("themes", filters.themes.join(","));
if (filters.vibes?.length) params.set("vibes", filters.vibes.join(","));
if (filters.occasions?.length) params.set("occasions", filters.occasions.join(","));
if (filters.spaceFeatures?.length) params.set("spaceFeatures", filters.spaceFeatures.join(","));
if (filters.noise) params.set("noise", filters.noise);
if (filters.priceTier) params.set("priceTier", filters.priceTier);
if (filters.size) params.set("size", filters.size);
if (filters.openNow) params.set("openNow", "true");
const qs = params.toString();
const result = await get<CafeDiscoverDto[]>(
`/api/public/discover${qs ? `?${qs}` : ""}`,
{ next: { revalidate: 30 } }
);
return result ?? [];
}
// ── Individual cafe ───────────────────────────────────────────────────────────
export async function getCafe(slug: string): Promise<CafePublicDto | null> {
return get<CafePublicDto>(`/api/public/cafes/${slug}`, {
next: { revalidate: 300 },
});
}
// ── Menu ──────────────────────────────────────────────────────────────────────
export async function getCafeMenu(slug: string): Promise<PublicMenuDto | null> {
return get<PublicMenuDto>(`/api/public/cafes/${slug}/menu`, {
next: { revalidate: 300 },
});
}
// ── Reviews ───────────────────────────────────────────────────────────────────
export async function getCafeReviews(
slug: string,
page = 1
): Promise<CafeReviewDto[]> {
const result = await get<CafeReviewDto[]>(
`/api/public/cafes/${slug}/reviews?page=${page}&pageSize=10`,
{ next: { revalidate: 120 } }
);
return result ?? [];
}
// ── NLP parse (server-side only; for ISR hint pre-population) ─────────────────
export async function nlpParse(q: string): Promise<NlpHints | null> {
return get<NlpHints>(
`/api/public/discover/nlp-parse?q=${encodeURIComponent(q)}`,
{ cache: "no-store" }
);
}
// ── Slugs for static generation ───────────────────────────────────────────────
export async function getAllCafeSlugs(): Promise<string[]> {
// Fetch all cafes without filters to get slugs for static generation
const cafes = await get<CafeDiscoverDto[]>("/api/public/discover?requireProfile=false", {
next: { revalidate: 3600 },
});
return cafes?.map((c) => c.slug) ?? [];
}
+166
View File
@@ -0,0 +1,166 @@
// ── Discover profile (AI-powered attributes set by cafe owner) ────────────────
export interface CafeDiscoverProfile {
themes: string[];
size: string | null;
floors: string | null;
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevel: string | null;
priceTier: string | null;
}
// ── Badge ─────────────────────────────────────────────────────────────────────
export interface CafeBadge {
key: string;
label: string;
icon: string;
}
// ── Working hours ─────────────────────────────────────────────────────────────
export interface DaySchedule {
isOpen: boolean;
open: string | null;
close: string | null;
}
export interface WorkingHours {
sat: DaySchedule | null;
sun: DaySchedule | null;
mon: DaySchedule | null;
tue: DaySchedule | null;
wed: DaySchedule | null;
thu: DaySchedule | null;
fri: DaySchedule | null;
}
// ── Cafe (search result card) ─────────────────────────────────────────────────
export interface CafeDiscoverDto {
id: string;
name: string;
slug: string;
city: string | null;
address: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
isVerified: boolean;
averageRating: number;
reviewCount: number;
discoverProfile: CafeDiscoverProfile;
badges: CafeBadge[];
galleryUrls: string[];
isOpenNow: boolean;
instagramHandle: string | null;
websiteUrl: string | null;
relevanceScore: number;
}
// ── Cafe (full detail page) ───────────────────────────────────────────────────
export interface CafePublicDto {
id: string;
name: string;
nameAr: string | null;
nameEn: string | null;
slug: string;
city: string | null;
address: string | null;
phone: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
description: string | null;
isVerified: boolean;
averageRating: number;
reviewCount: number;
discoverProfile: CafeDiscoverProfile;
badges: CafeBadge[];
galleryUrls: string[];
isOpenNow: boolean;
instagramHandle: string | null;
websiteUrl: string | null;
workingHours: WorkingHours | null;
}
// ── Menu ──────────────────────────────────────────────────────────────────────
export interface PublicMenuItemDto {
id: string;
categoryId: string;
name: string;
nameAr: string | null;
nameEn: string | null;
description: string | null;
price: number;
discountPercent: number;
imageUrl: string | null;
isAvailable: boolean;
}
export interface PublicMenuCategoryDto {
id: string;
name: string;
nameAr: string | null;
nameEn: string | null;
icon: string | null;
imageUrl: string | null;
items: PublicMenuItemDto[];
}
export interface PublicMenuDto {
cafeId: string;
cafeName: string;
slug: string;
categories: PublicMenuCategoryDto[];
}
// ── Review ────────────────────────────────────────────────────────────────────
export interface CafeReviewDto {
id: string;
authorName: string;
rating: number;
comment: string | null;
createdAt: string;
photoUrls: string[];
}
// ── NLP parse result ─────────────────────────────────────────────────────────
export interface NlpHints {
themes: string[];
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevel: string | null;
priceTier: string | null;
size: string | null;
}
// ── API wrapper ───────────────────────────────────────────────────────────────
export interface ApiResponse<T> {
success: boolean;
data: T | null;
error?: { code: string; message: string };
}
// ── Search filters ────────────────────────────────────────────────────────────
export interface DiscoverFilters {
city?: string;
q?: string;
minRating?: number;
sort?: string;
themes?: string[];
vibes?: string[];
occasions?: string[];
spaceFeatures?: string[];
noise?: string;
priceTier?: string;
size?: string;
openNow?: boolean;
}
+72
View File
@@ -0,0 +1,72 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(price: number, locale: string): string {
if (locale === "fa") {
return new Intl.NumberFormat("fa-IR").format(price) + " تومان";
}
return new Intl.NumberFormat("en-US").format(price) + " ﷼";
}
export function formatRating(rating: number): string {
return rating.toFixed(1);
}
// Convert Persian/Arabic digits to Latin
export function toLatinDigits(str: string): string {
return str
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
// Slugify a Persian or Latin string for URLs
export function slugify(str: string): string {
return toLatinDigits(str)
.toLowerCase()
.trim()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9؀-ۿ-]/g, "")
.replace(/-+/g, "-");
}
const DAY_KEYS_FA: Record<string, string> = {
sat: "شنبه",
sun: "یکشنبه",
mon: "دوشنبه",
tue: "سه‌شنبه",
wed: "چهارشنبه",
thu: "پنجشنبه",
fri: "جمعه",
};
const DAY_KEYS_EN: Record<string, string> = {
sat: "Sat",
sun: "Sun",
mon: "Mon",
tue: "Tue",
wed: "Wed",
thu: "Thu",
fri: "Fri",
};
export function getDayLabel(key: string, locale: string): string {
return locale === "fa" ? DAY_KEYS_FA[key] ?? key : DAY_KEYS_EN[key] ?? key;
}
export const PRICE_TIER_LABELS: Record<string, { fa: string; en: string }> = {
budget: { fa: "مقرون‌به‌صرفه", en: "Budget" },
moderate: { fa: "معمولی", en: "Moderate" },
upscale: { fa: "لوکس", en: "Upscale" },
luxury: { fa: "پریمیوم", en: "Luxury" },
};
export const NOISE_LABELS: Record<string, { fa: string; en: string }> = {
quiet: { fa: "آرام", en: "Quiet" },
moderate: { fa: "نسبتاً آرام", en: "Moderate" },
lively: { fa: "شلوغ", en: "Lively" },
loud: { fa: "پرسروصدا", en: "Loud" },
};
+111
View File
@@ -0,0 +1,111 @@
{
"meta": {
"siteName": "Koja",
"siteDescription": "Discover the best cafes and restaurants in Iran with AI",
"homeTitle": "Koja — AI-Powered Cafe Discovery",
"homeDescription": "Find the best cafes and restaurants near you with AI-powered search. Filter by vibe, price, menu, and amenities.",
"searchTitle": "Search Cafes & Restaurants",
"offlineTitle": "You're Offline",
"offlineDesc": "No internet connection. Previously visited cafes are still available."
},
"nav": {
"home": "Home",
"search": "Search",
"cities": "Cities",
"install": "Install App",
"openMenu": "Open menu",
"closeMenu": "Close menu"
},
"home": {
"badge": "AI-Powered Search",
"headline": "Find Your Perfect Cafe",
"subheadline": "Just type 'quiet cafe with good WiFi' — our AI finds the best matches instantly.",
"searchPlaceholder": "e.g. cozy cafe with natural light, good for work...",
"searchBtn": "Search",
"openNow": "Open now",
"popularCities": "Popular Cities",
"featuredTitle": "Featured Cafes",
"featuredSubtitle": "Top-rated cafes and restaurants across Iran",
"noResults": "No results found",
"noResultsDesc": "Try a different search or adjust your filters."
},
"search": {
"title": "Search Results",
"resultsCount": "{count} cafes & restaurants",
"filters": "Filters",
"sortBy": "Sort by",
"sortRelevance": "Most relevant",
"sortRating": "Highest rated",
"openNow": "Open now",
"minRating": "Min rating",
"city": "City",
"allCities": "All cities",
"themes": "Type",
"vibes": "Vibe",
"priceRange": "Price range",
"size": "Size",
"clearFilters": "Clear",
"detectedFilters": "Detected filters",
"loading": "Searching..."
},
"cafe": {
"openNow": "Open",
"closedNow": "Closed",
"verified": "Verified",
"reviews": "reviews",
"noReviews": "No reviews yet",
"viewMenu": "View Menu",
"reserve": "Reserve a Table",
"directions": "Directions",
"instagram": "Instagram",
"website": "Website",
"phone": "Call",
"workingHours": "Opening Hours",
"gallery": "Gallery",
"menu": "Menu",
"reviewsTab": "Reviews",
"about": "About",
"features": "Features",
"priceRange": "Price range",
"noiseLevel": "Noise level",
"size": "Space size",
"themes": "Cafe type",
"vibes": "Vibe",
"occasions": "Great for",
"spaceFeatures": "Amenities",
"writtenReview": "Write a review",
"yourName": "Your name",
"yourReview": "Your review (optional)",
"submitReview": "Submit review",
"submitting": "Submitting...",
"reviewSuccess": "Review submitted!",
"shareLocation": "Share location",
"backToSearch": "Back to search",
"similarCafes": "Similar cafes",
"notFound": "Cafe not found",
"notFoundDesc": "This cafe doesn't exist or has been removed."
},
"city": {
"cafesIn": "Cafes in {city}",
"description": "Discover the best cafes and restaurants in {city}"
},
"footer": {
"tagline": "AI-powered cafe discovery — powered by Meezi",
"product": "Product",
"forCafes": "For Cafe Owners",
"company": "Company",
"about": "About Meezi",
"contact": "Contact",
"legal": "Legal",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"copyright": "© 2025 Meezi. All rights reserved."
},
"pwa": {
"installTitle": "Install Koja",
"installDesc": "Add to your home screen for faster access",
"installBtn": "Install",
"dismissBtn": "Later",
"offlineBanner": "You're offline — showing cached results"
}
}
+111
View File
@@ -0,0 +1,111 @@
{
"meta": {
"siteName": "کجا",
"siteDescription": "بهترین کافه‌ها و رستوران‌های ایران را با هوش مصنوعی پیدا کنید",
"homeTitle": "کجا — جستجوی هوشمند کافه و رستوران",
"homeDescription": "بهترین کافه‌ها و رستوران‌های شهر را با جستجوی هوش مصنوعی پیدا کنید. فیلتر براساس فضا، قیمت، منو و امکانات.",
"searchTitle": "جستجوی کافه و رستوران",
"offlineTitle": "بدون اینترنت",
"offlineDesc": "اتصال اینترنت ندارید. کافه‌هایی که قبلاً بازدید کرده‌اید در دسترس هستند."
},
"nav": {
"home": "خانه",
"search": "جستجو",
"cities": "شهرها",
"install": "نصب اپ",
"openMenu": "باز کردن منو",
"closeMenu": "بستن منو"
},
"home": {
"badge": "جستجوی هوش مصنوعی",
"headline": "کافه ایده‌آلت را پیدا کن",
"subheadline": "فقط بنویس «یه کافه آروم با وای‌فای خوب» — هوش مصنوعی بهترین‌ها را برایت پیدا می‌کند.",
"searchPlaceholder": "مثلاً: کافه آروم با نور طبیعی، مناسب کار...",
"searchBtn": "جستجو",
"openNow": "باز الان",
"popularCities": "شهرهای محبوب",
"featuredTitle": "کافه‌های برگزیده",
"featuredSubtitle": "بهترین‌های ایران بر اساس امتیاز و نظر کاربران",
"noResults": "نتیجه‌ای یافت نشد",
"noResultsDesc": "جستجوی دیگری امتحان کنید یا فیلترها را تغییر دهید."
},
"search": {
"title": "نتایج جستجو",
"resultsCount": "{count} کافه و رستوران",
"filters": "فیلترها",
"sortBy": "مرتب‌سازی",
"sortRelevance": "مرتبط‌ترین",
"sortRating": "بالاترین امتیاز",
"openNow": "فقط باز",
"minRating": "حداقل امتیاز",
"city": "شهر",
"allCities": "همه شهرها",
"themes": "نوع",
"vibes": "فضا",
"priceRange": "بازه قیمت",
"size": "اندازه",
"clearFilters": "پاک کردن",
"detectedFilters": "فیلترهای تشخیص داده شده",
"loading": "در حال جستجو..."
},
"cafe": {
"openNow": "باز",
"closedNow": "بسته",
"verified": "تأیید شده",
"reviews": "نظر",
"noReviews": "هنوز نظری ثبت نشده",
"viewMenu": "مشاهده منو",
"reserve": "رزرو میز",
"directions": "مسیریابی",
"instagram": "اینستاگرام",
"website": "وبسایت",
"phone": "تماس",
"workingHours": "ساعت کاری",
"gallery": "گالری",
"menu": "منو",
"reviewsTab": "نظرات",
"about": "درباره",
"features": "امکانات",
"priceRange": "محدوده قیمت",
"noiseLevel": "سطح صدا",
"size": "اندازه فضا",
"themes": "نوع کافه",
"vibes": "فضا",
"occasions": "مناسب برای",
"spaceFeatures": "امکانات",
"writtenReview": "ثبت نظر",
"yourName": "نام شما",
"yourReview": "نظر شما (اختیاری)",
"submitReview": "ثبت نظر",
"submitting": "در حال ارسال...",
"reviewSuccess": "نظر شما ثبت شد",
"shareLocation": "اشتراک موقعیت",
"backToSearch": "بازگشت به جستجو",
"similarCafes": "کافه‌های مشابه",
"notFound": "کافه پیدا نشد",
"notFoundDesc": "این کافه وجود ندارد یا حذف شده است."
},
"city": {
"cafesIn": "کافه‌ها در {city}",
"description": "بهترین کافه‌ها و رستوران‌های {city} را کشف کنید"
},
"footer": {
"tagline": "جستجوی هوشمند کافه و رستوران — با هوش مصنوعی میزی",
"product": "محصول",
"forCafes": "برای کافه‌داران",
"company": "شرکت",
"about": "درباره میزی",
"contact": "تماس",
"legal": "حقوقی",
"privacy": "حریم خصوصی",
"terms": "شرایط استفاده",
"copyright": "© ۱۴۰۵ میزی. تمام حقوق محفوظ است."
},
"pwa": {
"installTitle": "نصب اپ کجا",
"installDesc": "برای دسترسی سریع‌تر روی گوشی نصب کنید",
"installBtn": "نصب",
"dismissBtn": "بعداً",
"offlineBanner": "اتصال قطع شد — نمایش نتایج ذخیره‌شده"
}
}
+9
View File
@@ -0,0 +1,9 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
// Match all pathnames except Next.js internals and static files
matcher: ["/((?!_next|_vercel|.*\\..*).*)"],
};