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,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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user