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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 17:02:22 +03:30
parent 16cff8730b
commit 289c808257
43 changed files with 74 additions and 58 deletions
+183
View File
@@ -0,0 +1,183 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Navbar } from "@/components/layout/navbar";
import { Footer } from "@/components/layout/footer";
import { AiSearchBar } from "@/components/search/ai-search-bar";
import { CafeCard } from "@/components/cafe/cafe-card";
import { discoverCafes } from "@/lib/api";
import type { DiscoverFilters } from "@/lib/types";
import { SlidersHorizontal } from "lucide-react";
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://find.meezi.ir";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
const q = (await searchParams)?.q;
const title = q
? `${q}${t("searchTitle")}`
: t("searchTitle");
return {
title,
robots: { index: false },
};
}
const SORT_OPTIONS_FA = [
{ value: "", label: "مرتبط‌ترین" },
{ value: "rating", label: "بالاترین امتیاز" },
];
const SORT_OPTIONS_EN = [
{ value: "", label: "Most relevant" },
{ value: "rating", label: "Highest rated" },
];
export default async function SearchPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "search" });
const isFa = locale === "fa";
const sp = (await searchParams) ?? {};
const filters: DiscoverFilters = {
q: sp.q,
city: sp.city,
sort: sp.sort,
openNow: sp.openNow === "true",
minRating: sp.minRating ? Number(sp.minRating) : undefined,
themes: sp.themes ? sp.themes.split(",") : undefined,
vibes: sp.vibes ? sp.vibes.split(",") : undefined,
priceTier: sp.priceTier,
};
const results = await discoverCafes(filters);
const sortOpts = isFa ? SORT_OPTIONS_FA : SORT_OPTIONS_EN;
return (
<>
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
{/* Search bar */}
<div className="mb-6">
<AiSearchBar initialValue={sp.q ?? ""} initialCity={sp.city ?? ""} />
</div>
<div className="flex flex-col gap-6 lg:flex-row">
{/* Sidebar filters */}
<aside className="w-full shrink-0 lg:w-56">
<div className="rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-sm font-semibold text-gray-900">
<SlidersHorizontal className="h-4 w-4" />
{t("filters")}
</p>
{(sp.q || sp.city || sp.openNow || sp.minRating || sp.themes || sp.vibes) && (
<a
href={`/${locale}/search`}
className="text-xs font-medium text-brand-600 hover:underline"
>
{t("clearFilters")}
</a>
)}
</div>
{/* Open now */}
<FilterSection label={t("openNow")} className="mt-4">
<a
href={buildUrl(sp, locale, { openNow: sp.openNow === "true" ? undefined : "true" })}
className={`block rounded-lg px-3 py-2 text-sm transition ${sp.openNow === "true" ? "bg-brand-700 text-white" : "text-gray-600 hover:bg-gray-50"}`}
>
{t("openNow")}
</a>
</FilterSection>
{/* Sort */}
<FilterSection label={t("sortBy")} className="mt-4">
{sortOpts.map((o) => (
<a
key={o.value}
href={buildUrl(sp, locale, { sort: o.value || undefined })}
className={`block rounded-lg px-3 py-2 text-sm transition ${(sp.sort ?? "") === o.value ? "bg-brand-50 font-semibold text-brand-700" : "text-gray-600 hover:bg-gray-50"}`}
>
{o.label}
</a>
))}
</FilterSection>
{/* Rating */}
<FilterSection label={t("minRating")} className="mt-4">
{[4, 3, 2].map((r) => (
<a
key={r}
href={buildUrl(sp, locale, { minRating: String(r) })}
className={`block rounded-lg px-3 py-2 text-sm transition ${sp.minRating === String(r) ? "bg-brand-50 font-semibold text-brand-700" : "text-gray-600 hover:bg-gray-50"}`}
>
{"★".repeat(r)} {isFa ? "و بالاتر" : "& up"}
</a>
))}
</FilterSection>
</div>
</aside>
{/* Results */}
<div className="flex-1">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
{t("resultsCount", { count: results.length })}
</p>
</div>
{results.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 py-20 text-center">
<p className="text-sm text-gray-400">
{isFa ? "نتیجه‌ای یافت نشد — جستجوی دیگری امتحان کنید" : "No results — try a different search"}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{results.map((cafe) => (
<CafeCard
key={cafe.id}
cafe={cafe}
locale={locale}
href={`/${locale}/cafe/${cafe.slug}`}
/>
))}
</div>
)}
</div>
</div>
</main>
<Footer />
</>
);
}
function FilterSection({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={className}>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400">{label}</p>
<div className="space-y-0.5">{children}</div>
</div>
);
}
function buildUrl(sp: Record<string, string>, locale: string, override: Record<string, string | undefined>) {
const params = new URLSearchParams(sp);
for (const [k, v] of Object.entries(override)) {
if (v === undefined) params.delete(k);
else params.set(k, v);
}
return `/${locale}/search?${params.toString()}`;
}