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:
@@ -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,42 @@
|
||||
{
|
||||
"name": "koja",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"cap:sync": "cap sync android",
|
||||
"cap:open": "cap open android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/core": "^6.2.0",
|
||||
"@capacitor/splash-screen": "^6.0.3",
|
||||
"cordova-plugin-pushe": "^2.5.0",
|
||||
"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": {
|
||||
"@capacitor/cli": "^6.2.0",
|
||||
"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,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: [],
|
||||
};
|
||||
}
|
||||
@@ -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,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Middleware handles locale routing, but if it ever misses, redirect to /fa
|
||||
export default function RootPage() {
|
||||
redirect("/fa");
|
||||
}
|
||||
@@ -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" ? "کجا" : "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>
|
||||
);
|
||||
}
|
||||
@@ -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) }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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": "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"
|
||||
}
|
||||
}
|
||||
@@ -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,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|.*\\..*).*)"],
|
||||
};
|
||||
@@ -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