feat(dashboard): Next.js 16 merchant panel with offline POS and PWA

Complete merchant dashboard upgrade:

Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors

Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect

PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,187 @@
"use client";
import { useTranslations } from "next-intl";
import {
DISCOVER_TAXONOMY,
type CafeDiscoverProfile,
type DiscoverListField,
type DiscoverSingleField,
toggleListValue,
} from "@/lib/cafe-discover-profile";
import { cn } from "@/lib/utils";
type CafeDiscoverProfileEditorProps = {
value: CafeDiscoverProfile;
onChange: (next: CafeDiscoverProfile) => void;
disabled?: boolean;
};
export function CafeDiscoverProfileEditor({
value,
onChange,
disabled,
}: CafeDiscoverProfileEditorProps) {
const t = useTranslations("discoverProfile");
const setList = (field: DiscoverListField, id: string) => {
onChange({ ...value, [field]: toggleListValue(value[field], id) });
};
const setSingle = (field: DiscoverSingleField, id: string) => {
onChange({ ...value, [field]: value[field] === id ? null : id });
};
return (
<div className="space-y-5">
<ProfileSection label={t("sections.themes")} hint={t("hints.themes")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.themes}
selected={value.themes}
label={(id) => t(`themes.${id}`)}
onToggle={(id) => setList("themes", id)}
disabled={disabled}
/>
</ProfileSection>
<ProfileSection label={t("sections.occasions")} hint={t("hints.occasions")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.occasions}
selected={value.occasions}
label={(id) => t(`occasions.${id}`)}
onToggle={(id) => setList("occasions", id)}
disabled={disabled}
/>
</ProfileSection>
<ProfileSection label={t("sections.spaceFeatures")} hint={t("hints.spaceFeatures")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.spaceFeatures}
selected={value.spaceFeatures}
label={(id) => t(`spaceFeatures.${id}`)}
onToggle={(id) => setList("spaceFeatures", id)}
disabled={disabled}
/>
</ProfileSection>
<ProfileSection label={t("sections.vibes")} hint={t("hints.vibes")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.vibes}
selected={value.vibes}
label={(id) => t(`vibes.${id}`)}
onToggle={(id) => setList("vibes", id)}
disabled={disabled}
/>
</ProfileSection>
<div className="grid gap-4 sm:grid-cols-2">
<ProfileSection label={t("sections.size")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.sizes}
selected={value.size ? [value.size] : []}
label={(id) => t(`sizes.${id}`)}
onToggle={(id) => setSingle("size", id)}
disabled={disabled}
single
/>
</ProfileSection>
<ProfileSection label={t("sections.floors")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.floors}
selected={value.floors ? [value.floors] : []}
label={(id) => t(`floors.${id}`)}
onToggle={(id) => setSingle("floors", id)}
disabled={disabled}
single
/>
</ProfileSection>
<ProfileSection label={t("sections.noiseLevel")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.noiseLevels}
selected={value.noiseLevel ? [value.noiseLevel] : []}
label={(id) => t(`noiseLevels.${id}`)}
onToggle={(id) => setSingle("noiseLevel", id)}
disabled={disabled}
single
/>
</ProfileSection>
<ProfileSection label={t("sections.priceTier")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.priceTiers}
selected={value.priceTier ? [value.priceTier] : []}
label={(id) => t(`priceTiers.${id}`)}
onToggle={(id) => setSingle("priceTier", id)}
disabled={disabled}
single
/>
</ProfileSection>
</div>
</div>
);
}
function ProfileSection({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{label}
</p>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
{children}
</div>
);
}
function ChipGrid({
ids,
selected,
label,
onToggle,
disabled,
single,
}: {
ids: readonly string[];
selected: string[];
label: (id: string) => string;
onToggle: (id: string) => void;
disabled?: boolean;
single?: boolean;
}) {
return (
<div className="flex flex-wrap gap-2">
{ids.map((id) => {
const active = selected.includes(id);
return (
<button
key={id}
type="button"
disabled={disabled}
onClick={() => onToggle(id)}
className={cn(
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition active:scale-[0.98]",
active
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 bg-card text-foreground hover:border-[#0F6E56]/40",
disabled && "pointer-events-none opacity-50"
)}
aria-pressed={active}
>
{label(id)}
{!single && active ? (
<span className="ms-1 opacity-70" aria-hidden>
</span>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPut } from "@/lib/api/client";
import { adminGet, adminPut } from "@/lib/api/admin-client";
import {
EMPTY_DISCOVER_PROFILE,
type CafeDiscoverProfile,
} from "@/lib/cafe-discover-profile";
import { CafeDiscoverProfileEditor } from "@/components/discover/cafe-discover-profile-editor";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { notify } from "@/lib/notify";
type ApiDiscoverProfile = {
themes: string[];
size?: string | null;
floors?: string | null;
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevel?: string | null;
priceTier?: string | null;
};
function fromApi(d: ApiDiscoverProfile): CafeDiscoverProfile {
return {
themes: d.themes ?? [],
size: d.size ?? null,
floors: d.floors ?? null,
vibes: d.vibes ?? [],
occasions: d.occasions ?? [],
spaceFeatures: d.spaceFeatures ?? [],
noiseLevel: d.noiseLevel ?? null,
priceTier: d.priceTier ?? null,
};
}
function toApiBody(p: CafeDiscoverProfile) {
return {
themes: p.themes,
size: p.size,
floors: p.floors,
vibes: p.vibes,
occasions: p.occasions,
spaceFeatures: p.spaceFeatures,
noiseLevel: p.noiseLevel,
priceTier: p.priceTier,
};
}
type CafeDiscoverProfilePanelProps = {
cafeId: string;
mode: "merchant" | "admin";
compact?: boolean;
};
export function CafeDiscoverProfilePanel({
cafeId,
mode,
compact,
}: CafeDiscoverProfilePanelProps) {
const t = useTranslations(
mode === "admin" ? "admin.cafes.discoverProfile" : "settings.discoverProfile"
);
const qc = useQueryClient();
const [profile, setProfile] = useState<CafeDiscoverProfile>(EMPTY_DISCOVER_PROFILE);
const queryKey =
mode === "admin"
? ["admin", "cafe-discover-profile", cafeId]
: ["cafe-discover-profile", cafeId];
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
if (mode === "admin") {
const res = await adminGet<ApiDiscoverProfile & { cafeId: string; cafeName: string }>(
`/api/admin/cafes/${cafeId}/discover-profile`
);
return fromApi(res);
}
const res = await apiGet<ApiDiscoverProfile>(`/api/cafes/${cafeId}/discover-profile`);
return fromApi(res);
},
enabled: !!cafeId,
});
useEffect(() => {
if (data) setProfile(data);
}, [data]);
const save = useMutation({
mutationFn: () => {
const body = toApiBody(profile);
return mode === "admin"
? adminPut(`/api/admin/cafes/${cafeId}/discover-profile`, body)
: apiPut(`/api/cafes/${cafeId}/discover-profile`, body);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey });
notify.success(t("saved"));
},
});
const content = (
<>
{!compact ? (
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
) : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : (
<CafeDiscoverProfileEditor
value={profile}
onChange={setProfile}
disabled={save.isPending}
/>
)}
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={save.isPending || isLoading}
onClick={() => save.mutate()}
>
{t("save")}
</Button>
</>
);
if (compact) {
return <div className="space-y-4">{content}</div>;
}
return (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{content}</CardContent>
</Card>
);
}
@@ -0,0 +1,347 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchCafePublicProfile,
removeGalleryPhoto,
updateCafePublicProfile,
uploadGalleryPhoto,
type CafeProfileEdit,
} from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type Props = { cafeId: string };
type Tab = "info" | "gallery" | "hours" | "social";
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
export function CafePublicProfilePanel({ cafeId }: Props) {
const t = useTranslations("cafePublicProfile");
const qc = useQueryClient();
const [tab, setTab] = useState<Tab>("info");
const [saved, setSaved] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Server state ──────────────────────────────────────────────────────────
const { data: profile, isLoading } = useQuery({
queryKey: ["cafe-public-profile", cafeId],
queryFn: () => fetchCafePublicProfile(cafeId),
});
// ── Local edit state ──────────────────────────────────────────────────────
const [description, setDescription] = useState<string>("");
const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data
if (profile && !initialized) {
setDescription(profile.description ?? "");
setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours());
setInitialized(true);
}
// ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({
mutationFn: () =>
updateCafePublicProfile(cafeId, {
description,
instagramHandle: instagram || null,
websiteUrl: website || null,
workingHours: hours,
}),
onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
// ── Gallery upload ────────────────────────────────────────────────────────
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError(null);
try {
const gallery = await uploadGalleryPhoto(cafeId, file);
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
old ? { ...old, galleryUrls: gallery } : old
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : t("uploadFailed");
setUploadError(msg.includes("GALLERY_FULL") ? t("galleryFull") : t("uploadFailed"));
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const removeMutation = useMutation({
mutationFn: (url: string) => removeGalleryPhoto(cafeId, url),
onSuccess: (gallery) => {
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
old ? { ...old, galleryUrls: gallery } : old
);
},
});
// ── Hours helpers ─────────────────────────────────────────────────────────
const setDayField = (
day: keyof WorkingHours,
field: "isOpen" | "open" | "close",
value: string | boolean
) => {
setHours((prev) => ({
...prev,
[day]: {
...((prev[day] as object) ?? { isOpen: false, open: "", close: "" }),
[field]: value,
},
}));
};
if (isLoading) {
return <p className="text-sm text-muted-foreground p-4">{t("loading")}</p>;
}
const tabs: { id: Tab; label: string }[] = [
{ id: "info", label: t("tabs.info") },
{ id: "gallery", label: t("tabs.gallery") },
{ id: "hours", label: t("tabs.hours") },
{ id: "social", label: t("tabs.social") },
];
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold">{t("title")}</h2>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 rounded-xl border border-border/80 bg-muted/40 p-1">
{tabs.map((tb) => (
<button
key={tb.id}
type="button"
onClick={() => setTab(tb.id)}
className={cn(
"flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
tab === tb.id
? "bg-white shadow-sm text-[#0F6E56]"
: "text-muted-foreground hover:text-foreground"
)}
>
{tb.label}
</button>
))}
</div>
{/* ── Info tab ─────────────────────────────────────────────────────── */}
{tab === "info" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div className="space-y-1">
<Label>{t("description")}</Label>
<textarea
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
rows={5}
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
/>
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
</CardContent>
</Card>
)}
{/* ── Gallery tab ──────────────────────────────────────────────────── */}
{tab === "gallery" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div>
<p className="text-sm font-medium">{t("gallery")}</p>
<p className="text-xs text-muted-foreground">{t("galleryHint")}</p>
</div>
{/* Existing photos */}
{profile?.galleryUrls && profile.galleryUrls.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{profile.galleryUrls.map((url) => {
const src = resolveMediaUrl(url);
return (
<div key={url} className="group relative">
<div
className="aspect-square rounded-lg bg-cover bg-center"
style={{ backgroundImage: src ? `url(${src})` : undefined }}
/>
<button
type="button"
onClick={() => removeMutation.mutate(url)}
disabled={removeMutation.isPending}
className="absolute end-1 top-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white opacity-0 transition group-hover:opacity-100 cursor-pointer"
>
{t("removePhoto")}
</button>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">هنوز عکسی آپلود نشده</p>
)}
{/* Upload button */}
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFileChange}
/>
<Button
variant="outline"
size="sm"
disabled={uploading || (profile?.galleryUrls?.length ?? 0) >= 8}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? t("uploading") : t("uploadPhoto")}
</Button>
{uploadError && (
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
)}
</div>
</CardContent>
</Card>
)}
{/* ── Working hours tab ─────────────────────────────────────────────── */}
{tab === "hours" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-3 p-4">
<p className="text-sm font-medium">{t("workingHours")}</p>
<div className="space-y-2">
{DAY_KEYS.map((day) => {
const d = hours[day] as { isOpen: boolean; open?: string; close?: string } | null;
return (
<div key={day} className="flex flex-wrap items-center gap-3 rounded-lg border border-border/60 px-3 py-2">
<span className="w-20 text-sm font-medium">{t(`days.${day}`)}</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={d?.isOpen ?? false}
onChange={(e) => setDayField(day, "isOpen", e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<span className="text-xs">{t("isOpen")}</span>
</label>
{d?.isOpen && (
<div className="flex items-center gap-2">
<input
type="time"
value={d.open ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "open", e.target.value)}
className="rounded border border-border/80 px-2 py-1 text-xs"
dir="ltr"
/>
<span className="text-xs text-muted-foreground"></span>
<input
type="time"
value={d.close ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "close", e.target.value)}
className="rounded border border-border/80 px-2 py-1 text-xs"
dir="ltr"
/>
</div>
)}
</div>
);
})}
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
</CardContent>
</Card>
)}
{/* ── Social tab ───────────────────────────────────────────────────── */}
{tab === "social" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div className="space-y-1">
<Label>{t("instagram")}</Label>
<div className="flex items-center rounded-lg border border-border/80 px-3">
<span className="text-sm text-muted-foreground">@</span>
<Input
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder={t("instagramPlaceholder")}
className="border-0 ps-1 shadow-none"
dir="ltr"
/>
</div>
</div>
<div className="space-y-1">
<Label>{t("website")}</Label>
<Input
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder={t("websitePlaceholder")}
dir="ltr"
/>
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
</CardContent>
</Card>
)}
</div>
);
}
// ── Save button shared sub-component ─────────────────────────────────────────
function SaveButton({
saving,
saved,
onSave,
t,
}: {
saving: boolean;
saved: boolean;
onSave: () => void;
t: ReturnType<typeof useTranslations<"cafePublicProfile">>;
}) {
return (
<Button
onClick={onSave}
disabled={saving}
className="bg-[#0F6E56]"
>
{saving ? "…" : saved ? t("saved") : t("save")}
</Button>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function emptyHours(): WorkingHours {
const day = () => ({ isOpen: false, open: null, close: null });
return { sat: day(), sun: day(), mon: day(), tue: day(), wed: day(), thu: day(), fri: day() };
}
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Sparkles } from "lucide-react";
import { apiPostPublic, ApiClientError } from "@/lib/api/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type CoffeeAdvisorPick = {
name: string;
reason: string;
menuItemId?: string | null;
};
type CoffeeAdvisorResult = {
summary: string;
picks: CoffeeAdvisorPick[];
};
type CoffeeAdvisorPanelProps = {
cafeSlug: string;
};
export function CoffeeAdvisorPanel({ cafeSlug }: CoffeeAdvisorPanelProps) {
const t = useTranslations("discoverPublic.coffeeAdvisor");
const [purpose, setPurpose] = useState("");
const [result, setResult] = useState<CoffeeAdvisorResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submit = async () => {
const trimmed = purpose.trim();
if (trimmed.length < 3) return;
setLoading(true);
setError(null);
setResult(null);
try {
const data = await apiPostPublic<CoffeeAdvisorResult>(
"/api/public/coffee-advisor",
{ purpose: trimmed, cafeSlug }
);
setResult(data);
} catch (e) {
if (e instanceof ApiClientError && e.code === "AI_NOT_CONFIGURED") {
setError(t("notConfigured"));
} else {
setError(t("failed"));
}
} finally {
setLoading(false);
}
};
return (
<Card className="rounded-xl border border-primary/20 bg-gradient-to-b from-[#E1F5EE]/40 to-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" aria-hidden />
{t("title")}
</CardTitle>
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
placeholder={t("placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter" && !loading) void submit();
}}
/>
<Button
type="button"
className="shrink-0 bg-primary hover:bg-primary/90"
disabled={loading || purpose.trim().length < 3}
onClick={() => void submit()}
>
{loading ? t("loading") : t("submit")}
</Button>
</div>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
{result ? (
<div className="space-y-3 rounded-lg border border-primary/15 bg-card/80 p-3">
<p className="text-sm leading-relaxed">{result.summary}</p>
{result.picks.length > 0 ? (
<ul className="space-y-2">
{result.picks.map((pick) => (
<li
key={`${pick.name}-${pick.menuItemId ?? "x"}`}
className="rounded-lg border border-border/60 bg-background px-3 py-2"
>
<p className="text-sm font-medium text-primary">{pick.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{pick.reason}</p>
</li>
))}
</ul>
) : null}
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,337 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import {
fetchPublicCafe,
fetchPublicCafeReviews,
type WorkingHours,
} from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { formatNumber } from "@/lib/format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CoffeeAdvisorPanel } from "@/components/discover/coffee-advisor-panel";
import { cn } from "@/lib/utils";
type Props = { slug: string };
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
export function PublicCafeDetailScreen({ slug }: Props) {
const t = useTranslations("discoverPublic");
const tProfile = useTranslations("discoverProfile");
const locale = useLocale();
const [galleryIndex, setGalleryIndex] = useState(0);
const { data: cafe, isLoading, error } = useQuery({
queryKey: ["public-cafe", slug],
queryFn: () => fetchPublicCafe(slug),
});
const { data: reviews = [] } = useQuery({
queryKey: ["public-cafe-reviews", slug],
queryFn: () => fetchPublicCafeReviews(slug),
enabled: !!slug,
});
const label = (key: string) => {
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers"] as const;
for (const g of groups) {
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
}
return key;
};
const mapSrc =
cafe?.address || cafe?.city
? `https://map.neshan.org/search?term=${encodeURIComponent(
[cafe.address, cafe.city].filter(Boolean).join("، ")
)}`
: null;
if (isLoading) {
return (
<div className="flex min-h-svh items-center justify-center bg-[#f5f5f4]">
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
);
}
if (error || !cafe) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6">
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
<Button asChild variant="outline">
<Link href={`/${locale}/discover`}>{t("backToList")}</Link>
</Button>
</div>
);
}
// Build image list: gallery first, then cover/logo fallback
const allImages = cafe.galleryUrls?.length
? cafe.galleryUrls
: [cafe.coverImageUrl ?? cafe.logoUrl].filter(Boolean) as string[];
const currentImage = resolveMediaUrl(allImages[galleryIndex] ?? null);
const profile = cafe.discoverProfile;
const allTags = [
...profile.occasions,
...profile.vibes,
...profile.spaceFeatures,
...profile.themes,
];
return (
<div className="min-h-svh bg-[#f5f5f4]">
<header className="border-b bg-white px-4 py-4">
<Link href={`/${locale}/discover`} className="text-sm text-[#0F6E56] hover:underline">
{t("backToList")}
</Link>
<div className="mt-2 flex items-center gap-2">
<h1 className="text-lg font-medium">{cafe.name}</h1>
{cafe.isOpenNow && (
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("openNowLabel")}
</span>
)}
</div>
{cafe.city && (
<p className="text-sm text-muted-foreground">
{cafe.city}{cafe.address ? `${cafe.address}` : ""}
</p>
)}
</header>
<main className="mx-auto max-w-3xl space-y-4 p-4">
{/* Gallery carousel */}
{allImages.length > 0 && (
<div className="space-y-2">
{currentImage && (
<div
className="h-52 w-full rounded-xl bg-cover bg-center"
style={{ backgroundImage: `url(${currentImage})` }}
/>
)}
{allImages.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{allImages.map((img, i) => {
const url = resolveMediaUrl(img);
return (
<button
key={i}
type="button"
onClick={() => setGalleryIndex(i)}
className={cn(
"h-14 w-20 shrink-0 rounded-lg bg-cover bg-center transition-all cursor-pointer",
i === galleryIndex
? "ring-2 ring-[#0F6E56]"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundImage: url ? `url(${url})` : undefined }}
/>
);
})}
</div>
)}
</div>
)}
{/* Info card */}
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-3 p-4">
{cafe.averageRating > 0 && (
<p className="text-sm font-medium text-[#0F6E56]">
{formatNumber(cafe.averageRating, locale)} {" "}
{t("reviewCount", { count: cafe.reviewCount })}
</p>
)}
{cafe.description && (
<p className="text-sm leading-relaxed text-muted-foreground">{cafe.description}</p>
)}
<div className="flex flex-wrap gap-1">
{allTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{label(tag)}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Working hours */}
{cafe.workingHours && <WorkingHoursCard hours={cafe.workingHours} t={t} locale={locale} />}
{/* Social links */}
{(cafe.instagramHandle || cafe.websiteUrl) && (
<Card className="rounded-xl border border-border/80">
<CardContent className="flex flex-wrap gap-3 p-4">
{cafe.instagramHandle && (
<a
href={`https://instagram.com/${cafe.instagramHandle}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-pink-400 cursor-pointer"
>
<svg className="h-4 w-4 text-pink-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 1.366.062 2.633.336 3.608 1.31.975.975 1.249 2.242 1.311 3.608.058 1.265.069 1.645.069 4.849 0 3.205-.011 3.584-.069 4.849-.062 1.366-.336 2.633-1.311 3.608-.975.975-2.242 1.249-3.608 1.311-1.266.058-1.644.069-4.85.069-3.204 0-3.584-.011-4.849-.069-1.366-.062-2.633-.336-3.608-1.311-.975-.975-1.249-2.242-1.311-3.608C2.175 15.584 2.163 15.205 2.163 12c0-3.204.012-3.584.07-4.849.062-1.366.336-2.633 1.311-3.608.975-.974 2.242-1.248 3.608-1.31C8.416 2.175 8.796 2.163 12 2.163zm0-2.163C8.741 0 8.333.014 7.053.072 5.197.157 3.355.673 1.965 2.063.573 3.453.157 5.197.072 7.053.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.085 1.856.5 3.598 1.893 4.99C3.355 23.327 5.197 23.843 7.053 23.928 8.333 23.986 8.741 24 12 24s3.667-.014 4.947-.072c1.856-.085 3.598-.501 4.99-1.893 1.393-1.392 1.808-3.134 1.893-4.99.058-1.28.072-1.689.072-4.948 0-3.259-.014-3.667-.072-4.947-.085-1.856-.5-3.598-1.893-4.99C20.645.673 18.803.157 16.947.072 15.667.014 15.259 0 12 0z"/>
<path d="M12 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
</svg>
<span>@{cafe.instagramHandle}</span>
</a>
)}
{cafe.websiteUrl && (
<a
href={cafe.websiteUrl.startsWith("http") ? cafe.websiteUrl : `https://${cafe.websiteUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-blue-400 cursor-pointer"
>
<svg className="h-4 w-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
</svg>
<span>{t("websiteLabel")}</span>
</a>
)}
</CardContent>
</Card>
)}
<CoffeeAdvisorPanel cafeSlug={slug} />
{/* Map */}
{mapSrc && (
<Card className="overflow-hidden rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("mapTitle")}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<iframe
title={t("mapTitle")}
src={mapSrc}
className="h-64 w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
<div className="border-t p-3">
<a
href={mapSrc}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-[#0C447C] hover:underline"
>
{t("openInNeshan")}
</a>
</div>
</CardContent>
</Card>
)}
{/* Reviews */}
{reviews.length > 0 && (
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("reviewsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
{reviews.map((r) => (
<div key={r.id} className="space-y-2 border-b border-border/60 pb-3 last:border-0">
<p className="text-sm font-medium">{r.authorName}</p>
<p className="text-xs text-amber-600">{"★".repeat(r.rating)}</p>
{r.comment && <p className="text-sm text-muted-foreground">{r.comment}</p>}
{r.ownerReply && (
<p className="rounded-lg bg-[#E1F5EE] px-3 py-2 text-sm text-[#0F6E56]">
{t("ownerReply")}: {r.ownerReply}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
<Button asChild className="w-full bg-[#0F6E56]">
<Link href={`/${locale}/discover`}>{t("exploreMore")}</Link>
</Button>
</main>
</div>
);
}
// ── Working hours sub-component ───────────────────────────────────────────────
function WorkingHoursCard({
hours,
t,
locale,
}: {
hours: WorkingHours;
t: ReturnType<typeof useTranslations<"discoverPublic">>;
locale: string;
}) {
// Detect today's day key in Iran time (UTC+3:30)
const iranOffset = 210; // minutes
const iranNow = new Date(Date.now() + iranOffset * 60_000);
const dayIndex = iranNow.getUTCDay(); // 0=Sun ... 6=Sat
const dayKeyMap: Record<number, keyof WorkingHours> = {
6: "sat", 0: "sun", 1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri",
};
const todayKey = dayKeyMap[dayIndex];
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
return (
<Card className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("workingHoursTitle")}</CardTitle>
</CardHeader>
<CardContent className="p-0 pb-2">
<table className="w-full text-sm">
<tbody>
{DAY_KEYS.map((day) => {
const schedule = hours[day];
const isToday = day === todayKey;
return (
<tr
key={day}
className={cn(
"border-b border-border/40 last:border-0",
isToday && "bg-[#E1F5EE]/60"
)}
>
<td className={cn(
"px-4 py-2 font-medium",
isToday ? "text-[#0F6E56]" : "text-foreground"
)}>
{t(`days.${day}`)}
{isToday && (
<span className="ms-1.5 text-[10px] font-normal text-[#0F6E56]">
(امروز)
</span>
)}
</td>
<td className="px-4 py-2 text-end text-muted-foreground">
{!schedule || !schedule.isOpen ? (
<span className="text-red-500">{t("closedLabel")}</span>
) : schedule.open && schedule.close ? (
<span dir="ltr">{schedule.open} {schedule.close}</span>
) : (
<span className="text-emerald-600">{t("openNowLabel")}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,521 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import {
fetchDiscoverTaxonomy,
fetchNlpHints,
fetchPublicDiscover,
type DiscoverSearchParams,
type NlpHints,
type PublicCafeDiscover,
} from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
const CITIES = [
{ id: "tehran", query: "تهران" },
{ id: "karaj", query: "کرج" },
] as const;
type FilterKey = "themes" | "vibes" | "occasions" | "spaceFeatures";
function toggle(list: string[], value: string): string[] {
return list.includes(value) ? list.filter((x) => x !== value) : [...list, value];
}
// Count non-empty detected filter fields
function nlpHintCount(h: NlpHints | null): number {
if (!h) return 0;
return (
h.themes.length +
h.vibes.length +
h.occasions.length +
h.spaceFeatures.length +
(h.noiseLevel ? 1 : 0) +
(h.priceTier ? 1 : 0) +
(h.size ? 1 : 0)
);
}
export function PublicDiscoverScreen() {
const t = useTranslations("discoverPublic");
const tProfile = useTranslations("discoverProfile");
const locale = useLocale();
const [city, setCity] = useState<string>("tehran");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [sort, setSort] = useState("rating");
const [themes, setThemes] = useState<string[]>([]);
const [vibes, setVibes] = useState<string[]>([]);
const [occasions, setOccasions] = useState<string[]>([]);
const [spaceFeatures, setSpaceFeatures] = useState<string[]>([]);
const [noise, setNoise] = useState<string | null>(null);
const [priceTier, setPriceTier] = useState<string | null>(null);
const [size, setSize] = useState<string | null>(null);
const [openNow, setOpenNow] = useState(false);
// Debounce the search input for NLP hints
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 600);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [search]);
// Fetch NLP hints whenever the debounced search changes
const { data: nlpHints } = useQuery({
queryKey: ["nlp-hints", debouncedSearch],
queryFn: () => fetchNlpHints(debouncedSearch),
enabled: debouncedSearch.trim().length > 2,
staleTime: 30_000,
});
const cityQuery = CITIES.find((c) => c.id === city)?.query ?? "تهران";
const params: DiscoverSearchParams = useMemo(
() => ({
city: cityQuery,
q: search.trim() || undefined,
sort,
themes: themes.length ? themes : undefined,
vibes: vibes.length ? vibes : undefined,
occasions: occasions.length ? occasions : undefined,
spaceFeatures: spaceFeatures.length ? spaceFeatures : undefined,
noise: noise ?? undefined,
priceTier: priceTier ?? undefined,
size: size ?? undefined,
openNow,
requireProfile: true,
}),
[cityQuery, search, sort, themes, vibes, occasions, spaceFeatures, noise, priceTier, size, openNow]
);
const { data: taxonomy } = useQuery({
queryKey: ["discover-taxonomy"],
queryFn: fetchDiscoverTaxonomy,
});
const { data: cafes = [], isLoading, isFetching } = useQuery({
queryKey: ["public-discover", params],
queryFn: () => fetchPublicDiscover(params),
});
const label = useCallback(
(key: string) => {
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers", "sizes"] as const;
for (const g of groups) {
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
}
return key;
},
[tProfile]
);
const clearAll = () => {
setThemes([]); setVibes([]); setOccasions([]); setSpaceFeatures([]);
setNoise(null); setPriceTier(null); setSize(null); setSearch("");
setOpenNow(false);
};
const filterSection = (
key: FilterKey,
options: string[] | undefined,
active: string[],
setActive: (v: string[]) => void
) => {
if (!options?.length) return null;
return (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t(`filters.${key}`)}
</p>
<div className="flex flex-wrap gap-2">
{options.slice(0, 14).map((opt) => (
<button
key={opt}
type="button"
onClick={() => setActive(toggle(active, opt))}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors active:scale-[0.98] cursor-pointer",
active.includes(opt)
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(opt)}
</button>
))}
</div>
</div>
);
};
const detectedCount = nlpHintCount(nlpHints ?? null);
return (
<div className="min-h-svh bg-[#f5f5f4]">
<header className="border-b bg-white px-4 py-5">
<div className="mx-auto max-w-3xl">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("brand")}
</p>
<h1 className="text-lg font-medium text-foreground">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
</header>
<main className="mx-auto max-w-3xl space-y-4 p-4">
{/* City selector */}
<div className="flex flex-wrap gap-2">
{CITIES.map((c) => (
<Button
key={c.id}
size="sm"
variant={city === c.id ? "default" : "outline"}
className={city === c.id ? "bg-[#0F6E56]" : ""}
onClick={() => setCity(c.id)}
>
{t(`cities.${c.id}`)}
</Button>
))}
</div>
{/* AI smart search */}
<div className="space-y-2">
<div className="relative">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
className="text-end pe-10"
/>
{/* AI spark indicator */}
<span
className={cn(
"pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-sm transition-opacity",
debouncedSearch.trim().length > 2 ? "opacity-100" : "opacity-30"
)}
aria-hidden
>
</span>
</div>
<p className="text-[11px] text-muted-foreground">{t("searchHint")}</p>
{/* Detected filters banner */}
{detectedCount > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/60 px-3 py-2">
<span className="text-[11px] font-medium text-[#0F6E56]">
{t("aiDetectedLabel")}
</span>
{nlpHints?.themes.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.vibes.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.occasions.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.spaceFeatures.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.noiseLevel && (
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(nlpHints.noiseLevel)}
</span>
)}
{nlpHints?.priceTier && (
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(nlpHints.priceTier)}
</span>
)}
{nlpHints?.size && (
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(nlpHints.size)}
</span>
)}
<button
type="button"
onClick={() => setSearch("")}
className="ms-auto text-[11px] text-[#0F6E56] underline cursor-pointer"
>
{t("aiDetectedClear")}
</button>
</div>
)}
</div>
{/* Filter panel */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardContent className="space-y-4 p-4">
{filterSection("occasions", taxonomy?.occasions, occasions, setOccasions)}
{filterSection("vibes", taxonomy?.vibes, vibes, setVibes)}
{filterSection("spaceFeatures", taxonomy?.spaceFeatures, spaceFeatures, setSpaceFeatures)}
{filterSection("themes", taxonomy?.themes, themes, setThemes)}
{/* Size filter — was missing before */}
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("filters.size")}
</p>
<div className="flex flex-wrap gap-2">
{taxonomy?.sizes?.map((s) => (
<button
key={s}
type="button"
onClick={() => setSize(size === s ? null : s)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
size === s
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(s)}
</button>
))}
</div>
</div>
{/* Noise level */}
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("filters.noise")}
</p>
<div className="flex flex-wrap gap-2">
{taxonomy?.noiseLevels?.map((n) => (
<button
key={n}
type="button"
onClick={() => setNoise(noise === n ? null : n)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
noise === n
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(n)}
</button>
))}
</div>
</div>
{/* Price tier */}
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("filters.priceTier")}
</p>
<div className="flex flex-wrap gap-2">
{taxonomy?.priceTiers?.map((p) => (
<button
key={p}
type="button"
onClick={() => setPriceTier(priceTier === p ? null : p)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
priceTier === p
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(p)}
</button>
))}
</div>
</div>
{/* Open now toggle + actions */}
<div className="flex flex-wrap items-center gap-3 pt-1">
<button
type="button"
onClick={() => setOpenNow((v) => !v)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
openNow
? "border-emerald-500 bg-emerald-50 text-emerald-700"
: "border-border/80 hover:border-emerald-400/60"
)}
>
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
openNow ? "bg-emerald-500" : "bg-muted-foreground/40"
)}
/>
{t("openNow")}
</button>
<Button
size="sm"
variant="outline"
onClick={clearAll}
className="ms-auto"
>
{t("clearFilters")}
</Button>
</div>
</CardContent>
</Card>
{/* Results header */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{isLoading || isFetching
? t("loading")
: t("resultCount", { count: cafes.length })}
</p>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="rounded-lg border border-border/80 bg-white px-2 py-1 text-sm"
>
<option value="rating">{t("sort.rating")}</option>
<option value="reviews">{t("sort.reviews")}</option>
<option value="name">{t("sort.name")}</option>
</select>
</div>
{/* Results */}
{cafes.length === 0 && !isLoading ? (
<Card className="rounded-xl border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">{t("empty")}</p>
</Card>
) : (
<ul className="space-y-3">
{cafes.map((cafe) => (
<CafeDiscoverCard key={cafe.id} cafe={cafe} locale={locale} label={label} t={t} />
))}
</ul>
)}
</main>
</div>
);
}
// ── Card component ────────────────────────────────────────────────────────────
function CafeDiscoverCard({
cafe,
locale,
label,
t,
}: {
cafe: PublicCafeDiscover;
locale: string;
label: (key: string) => string;
t: ReturnType<typeof useTranslations<"discoverPublic">>;
}) {
// Pick the best cover: gallery first, then coverImage, then logo
const firstGallery = cafe.galleryUrls?.[0];
const cover = resolveMediaUrl(firstGallery ?? cafe.coverImageUrl ?? cafe.logoUrl);
const tags = [
...cafe.discoverProfile.occasions.slice(0, 2),
...cafe.discoverProfile.vibes.slice(0, 1),
];
return (
<li>
<Link
href={`/${locale}/discover/${cafe.slug}`}
className="block rounded-xl border border-border/80 bg-white transition-all hover:border-[#0F6E56] hover:shadow-sm active:scale-[0.99] cursor-pointer"
>
{/* Cover image */}
{cover ? (
<div
className="h-32 rounded-t-xl bg-cover bg-center"
style={{ backgroundImage: `url(${cover})` }}
/>
) : (
<div className="flex h-32 items-center justify-center rounded-t-xl bg-muted">
<svg className="h-10 w-10 text-muted-foreground/30" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 19V7a2 2 0 012-2h1V4a1 1 0 012 0v1h10V4a1 1 0 112 0v1h1a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2z"/>
</svg>
</div>
)}
<div className="space-y-2 p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="font-medium text-foreground">{cafe.name}</h2>
{/* Gallery count badge */}
{cafe.galleryUrls?.length > 1 && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{cafe.galleryUrls.length - 1}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Open/closed badge */}
{cafe.isOpenNow && (
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("openNowLabel")}
</span>
)}
{cafe.averageRating > 0 && (
<span className="text-sm font-medium text-[#0F6E56]">
{formatNumber(cafe.averageRating, locale)}
</span>
)}
</div>
</div>
{cafe.city && (
<p className="text-xs text-muted-foreground">
{cafe.city}
{cafe.address ? `${cafe.address}` : ""}
</p>
)}
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{label(tag)}
</Badge>
))}
{cafe.discoverProfile.priceTier && (
<Badge className="bg-amber-100 text-[10px] text-amber-900">
{label(cafe.discoverProfile.priceTier)}
</Badge>
)}
</div>
{/* Gallery strip */}
{cafe.galleryUrls?.length > 1 && (
<div className="flex gap-1 overflow-x-auto pb-0.5">
{cafe.galleryUrls.slice(1, 4).map((url, i) => (
<div
key={i}
className="h-10 w-16 shrink-0 rounded bg-cover bg-center"
style={{ backgroundImage: `url(${resolveMediaUrl(url)})` }}
/>
))}
</div>
)}
<p className="text-xs text-[#0F6E56]">{t("viewCafe")} </p>
</div>
</Link>
</li>
);
}