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