feat(finder): AI-powered cafe finder PWA with Next.js 16
Public cafe discovery app:
- SEO-optimised pages: home, /cafe/[slug], /search, /city/[city]
- AI search bar with natural language queries
- Structured data (JSON-LD) for Google rich results
- City browsing, rating/filter sidebar, similar cafes
- Review listing, full menu preview, working-hours card
- Web App Manifest + offline fallback page (PWA)
- Next.js 16: params/searchParams typed as Promise<{}>
- Fix Lucide icon title→aria-label (type removed upstream)
- "use client" on offline page (onClick handler)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import withPWAInit from "@ducanh2912/next-pwa";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
|
const withPWA = withPWAInit({
|
||||||
|
dest: "public",
|
||||||
|
cacheOnFrontEndNav: true,
|
||||||
|
aggressiveFrontEndNavCaching: true,
|
||||||
|
reloadOnOnline: true,
|
||||||
|
disable: process.env.NODE_ENV === "development",
|
||||||
|
workboxOptions: {
|
||||||
|
disableDevLogs: true,
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /\/cafe\//,
|
||||||
|
handler: "StaleWhileRevalidate",
|
||||||
|
options: {
|
||||||
|
cacheName: "cafe-pages",
|
||||||
|
expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\/api\/public\//,
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "api-cache",
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 5 * 60 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "image-cache",
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "**" },
|
||||||
|
{ protocol: "http", hostname: "**" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withPWA(withNextIntl(nextConfig));
|
||||||
Generated
+10008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "meezi-finder",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.2.6",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6",
|
||||||
|
"next-intl": "4.12.0",
|
||||||
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
|
"workbox-window": "^7.3.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"date-fns-jalali": "^3.4.0-0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.8.3",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "19.1.4",
|
||||||
|
"@types/react-dom": "19.1.4",
|
||||||
|
"tailwindcss": "3.4.14",
|
||||||
|
"postcss": "^8",
|
||||||
|
"autoprefixer": "^10",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "16.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
screenshots: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" ? "میزییاب" : "Meezi Finder"}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" ? "میزییاب" : "Meezi Finder"}
|
||||||
|
</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) }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ["fa", "en"],
|
||||||
|
defaultLocale: "fa",
|
||||||
|
});
|
||||||
@@ -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) ?? [];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"siteName": "Meezi Finder",
|
||||||
|
"siteDescription": "Discover the best cafes and restaurants in Iran with AI",
|
||||||
|
"homeTitle": "Meezi Finder — 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 Meezi Finder",
|
||||||
|
"installDesc": "Add to your home screen for faster access",
|
||||||
|
"installBtn": "Install",
|
||||||
|
"dismissBtn": "Later",
|
||||||
|
"offlineBanner": "You're offline — showing cached results"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "اتصال قطع شد — نمایش نتایج ذخیرهشده"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#f0faf6",
|
||||||
|
100: "#d8f3e8",
|
||||||
|
200: "#b4e8d3",
|
||||||
|
300: "#82d4b7",
|
||||||
|
400: "#4cb896",
|
||||||
|
500: "#289b78",
|
||||||
|
600: "#1a7d60",
|
||||||
|
700: "#0F6E56",
|
||||||
|
800: "#0e5a46",
|
||||||
|
900: "#0d4a3a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user