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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user