Compare commits

2 Commits

Author SHA1 Message Date
soroush.asadi 087563bce7 feat(settings): use-my-current-location button; surface ticket-load error
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / Deploy · all services (push) Failing after 2m34s
Location card gets a 'موقعیت فعلی من' button that fills lat/lng from the browser's geolocation. Support ticket list now shows the resolved (localized) error instead of a generic message, so a failure is diagnosable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:52:29 +03:30
soroush.asadi e839db7331 fix(koja): default to fa (no browser locale guess); guard null discoverProfile
Koja auto-detected locale from the browser Accept-Language (en for many Persian users); set localeDetection:false so locale-less URLs default to fa. Also guarded cafe.discoverProfile across the cafe page, cafe card, and JSON-LD — a café without a discover profile crashed the page (500). The cafe page now resolves the café first and notFound()s an unknown slug before fetching menu/reviews.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:51:50 +03:30
6 changed files with 51 additions and 11 deletions
@@ -366,6 +366,26 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
> >
ذخیره موقعیت ذخیره موقعیت
</Button> </Button>
<Button
variant="outline"
onClick={() => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
notify.error("مرورگر شما موقعیت‌یابی را پشتیبانی نمی‌کند");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatInput(pos.coords.latitude.toFixed(5));
setLngInput(pos.coords.longitude.toFixed(5));
setLocationError(null);
},
() => notify.error("دسترسی به موقعیت امکان‌پذیر نبود. لطفاً اجازه دسترسی بدهید."),
{ enableHighAccuracy: true, timeout: 10000 }
);
}}
>
موقعیت فعلی من
</Button>
{(latInput || lngInput) && ( {(latInput || lngInput) && (
<Button <Button
variant="ghost" variant="ghost"
@@ -6,6 +6,7 @@ import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -52,6 +53,7 @@ function formatDate(iso: string) {
export function SupportScreen() { export function SupportScreen() {
const t = useTranslations("support"); const t = useTranslations("support");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
@@ -61,6 +63,7 @@ export function SupportScreen() {
data: tickets = [], data: tickets = [],
isLoading, isLoading,
isError, isError,
error,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["support", cafeId], queryKey: ["support", cafeId],
@@ -135,7 +138,7 @@ export function SupportScreen() {
</p> </p>
{isError ? ( {isError ? (
<Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive"> <Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive">
<p>{t("loadFailed")}</p> <p>{apiError(error, t("loadFailed"))}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}> <Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}>
{t("retry")} {t("retry")}
</Button> </Button>
+18 -5
View File
@@ -70,16 +70,29 @@ export default async function CafePage({
const t = await getTranslations({ locale, namespace: "cafe" }); const t = await getTranslations({ locale, namespace: "cafe" });
const isFa = locale === "fa"; const isFa = locale === "fa";
const [cafe, menu, reviews] = await Promise.all([ // Resolve the café first so an unknown slug 404s cleanly instead of doing
getCafe(slug), // (and potentially erroring on) the menu/review fetches.
const cafe = await getCafe(slug);
if (!cafe) notFound();
const [menu, reviews] = await Promise.all([
getCafeMenu(slug), getCafeMenu(slug),
getCafeReviews(slug), getCafeReviews(slug),
]); ]);
if (!cafe) notFound();
const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name); const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name);
const profile = cafe.discoverProfile; // discoverProfile may be absent for cafés that never filled it in — fall back
// to an empty profile so the page renders instead of throwing a 500.
const profile = cafe.discoverProfile ?? {
themes: [],
size: null,
floors: null,
vibes: [],
occasions: [],
spaceFeatures: [],
noiseLevel: null,
priceTier: null,
};
const priceTier = profile.priceTier; const priceTier = profile.priceTier;
// Similar cafes // Similar cafes
+4 -3
View File
@@ -11,7 +11,8 @@ interface Props {
export function CafeCard({ cafe, locale, href }: Props) { export function CafeCard({ cafe, locale, href }: Props) {
const isFa = locale === "fa"; const isFa = locale === "fa";
const name = isFa ? cafe.name : (cafe.name); const name = isFa ? cafe.name : (cafe.name);
const priceTier = cafe.discoverProfile.priceTier; const priceTier = cafe.discoverProfile?.priceTier ?? null;
const themes = cafe.discoverProfile?.themes ?? [];
const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null; const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null;
return ( return (
@@ -72,9 +73,9 @@ export function CafeCard({ cafe, locale, href }: Props) {
)} )}
{/* Tags */} {/* Tags */}
{cafe.discoverProfile.themes.length > 0 && ( {themes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{cafe.discoverProfile.themes.slice(0, 3).map((tag) => ( {themes.slice(0, 3).map((tag) => (
<span <span
key={tag} key={tag}
className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700" className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700"
+2 -2
View File
@@ -46,11 +46,11 @@ export function CafeJsonLd({ cafe, locale, baseUrl }: Props) {
worstRating: "1", worstRating: "1",
}, },
} : {}), } : {}),
...(cafe.discoverProfile.themes.length ? { ...(cafe.discoverProfile?.themes?.length ? {
servesCuisine: cafe.discoverProfile.themes, servesCuisine: cafe.discoverProfile.themes,
} : {}), } : {}),
priceRange: (() => { priceRange: (() => {
const tier = cafe.discoverProfile.priceTier; const tier = cafe.discoverProfile?.priceTier;
if (tier === "budget") return "﷼"; if (tier === "budget") return "﷼";
if (tier === "moderate") return "﷼﷼"; if (tier === "moderate") return "﷼﷼";
if (tier === "upscale") return "﷼﷼﷼"; if (tier === "upscale") return "﷼﷼﷼";
+3
View File
@@ -3,4 +3,7 @@ import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "en"], locales: ["fa", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). Locale-less URLs default to fa.
localeDetection: false,
}); });