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
+100
View File
@@ -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) ?? [];
}
+166
View File
@@ -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;
}
+72
View File
@@ -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" },
};