289c808257
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>
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
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()}`;
|
|
}
|