Files
meezi/web/dashboard/src/components/settings/settings-shop-panel.tsx
T
soroush.asadi 087563bce7
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
feat(settings): use-my-current-location button; surface ticket-load error
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

404 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiPatch, apiPost, apiUpload, resolveMediaUrl } from "@/lib/api/client";
import {
cafeSettingsQueryKey,
useCafeSettings,
type CafeSettings,
} from "@/lib/hooks/use-cafe-settings";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
// ── Location map preview ──────────────────────────────────────────────────────
function LocationMapPreview({ lat, lng }: { lat: number; lng: number }) {
const zoom = 15;
const src = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01},${lat - 0.01},${lng + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lng}`;
return (
<div className="relative w-full overflow-hidden rounded-lg border" style={{ height: 220 }}>
<iframe
src={src}
title="location preview"
className="h-full w-full border-0"
loading="lazy"
allowFullScreen
/>
</div>
);
}
type SettingsShopPanelProps = {
cafeId: string;
};
export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
const t = useTranslations("settings");
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugError, setSlugError] = useState<string | null>(null);
const [city, setCity] = useState("");
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [coverImageUrl, setCoverImageUrl] = useState("");
const [snappfoodVendorId, setSnappfoodVendorId] = useState("");
const [latInput, setLatInput] = useState("");
const [lngInput, setLngInput] = useState("");
const [locationError, setLocationError] = useState<string | null>(null);
const { data: cafeSettings } = useCafeSettings(cafeId);
const parsedLat = parseFloat(latInput);
const parsedLng = parseFloat(lngInput);
const hasValidLocation =
!isNaN(parsedLat) &&
!isNaN(parsedLng) &&
parsedLat >= 24 && parsedLat <= 40 &&
parsedLng >= 44 && parsedLng <= 64;
useEffect(() => {
if (!cafeSettings) return;
setName(cafeSettings.name ?? "");
setSlug(cafeSettings.slug ?? "");
setCity(cafeSettings.city ?? "");
setPhone(cafeSettings.phone ?? "");
setAddress(cafeSettings.address ?? "");
setDescription(cafeSettings.description ?? "");
setLogoUrl(cafeSettings.logoUrl ?? "");
setCoverImageUrl(cafeSettings.coverImageUrl ?? "");
setSnappfoodVendorId(cafeSettings.snappfoodVendorId ?? "");
setLatInput(cafeSettings.latitude != null ? String(cafeSettings.latitude) : "");
setLngInput(cafeSettings.longitude != null ? String(cafeSettings.longitude) : "");
}, [cafeSettings]);
const saveProfile = useMutation({
mutationFn: () => {
setSlugError(null);
const slugTrimmed = slug.trim();
const isValidSlug = !slugTrimmed || /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slugTrimmed);
if (slugTrimmed && !isValidSlug) {
throw new Error("INVALID_SLUG");
}
return apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
name,
slug: slugTrimmed || undefined,
city,
phone,
address,
description,
logoUrl: logoUrl || null,
coverImageUrl: coverImageUrl || null,
snappfoodVendorId,
});
},
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success(t("profile.saved"));
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INVALID_SLUG") {
setSlugError(t("profile.slugInvalid"));
} else if (msg.includes("SLUG_TAKEN")) {
setSlugError(t("profile.slugTaken"));
}
},
});
const saveLocation = useMutation({
mutationFn: () => {
setLocationError(null);
if (!hasValidLocation && (latInput || lngInput)) {
throw new Error("INVALID_LOCATION");
}
const body = latInput && lngInput && hasValidLocation
? { latitude: parsedLat, longitude: parsedLng }
: { clearLocation: true };
return apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, body);
},
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success("موقعیت ذخیره شد");
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INVALID_LOCATION" || msg.includes("INVALID_LOCATION")) {
setLocationError("مختصات نامعتبر است. مثال: عرض جغرافیایی ۳۵.۶۸۹، طول جغرافیایی ۵۱.۳۸۹");
} else {
notify.error("خطا در ذخیره موقعیت");
}
},
});
const uploadLogo = useMutation({
mutationFn: (file: File) =>
apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/cafe-logo`, file),
onSuccess: (data) => setLogoUrl(data.url),
});
const uploadCover = useMutation({
mutationFn: (file: File) =>
apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/cafe-cover`, file),
onSuccess: (data) => setCoverImageUrl(data.url),
});
const submitTaraz = useMutation({
mutationFn: () =>
apiPost<{ trackingCode?: string; message?: string }>(
`/api/cafes/${cafeId}/tax/taraz/submit`
),
onSuccess: (data) => notify.success(data.message ?? t("tarazQueued")),
});
const logoSrc = resolveMediaUrl(logoUrl);
return (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("profile.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-6 pb-6 pt-0">
<div className="flex flex-wrap items-center gap-4">
{logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="" className="h-16 w-16 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted text-xs text-muted-foreground">
{t("profile.logo")}
</div>
)}
<div className="flex flex-wrap gap-2">
<label className="cursor-pointer rounded-md border px-3 py-2 text-sm hover:bg-muted">
{t("profile.uploadLogo")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadLogo.mutate(f);
}}
/>
</label>
<label className="cursor-pointer rounded-md border px-3 py-2 text-sm hover:bg-muted">
{t("profile.uploadCover")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadCover.mutate(f);
}}
/>
</label>
</div>
</div>
{/* Koja slug */}
<LabeledField
label={t("profile.slug")}
htmlFor="cafe-slug"
hint={t("profile.slugHint")}
>
<Input
id="cafe-slug"
value={slug}
onChange={(e) => {
setSlugError(null);
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}}
placeholder={t("profile.slugPlaceholder")}
dir="ltr"
className="font-mono text-sm"
/>
{slug && (
<p className={`text-xs font-mono ${slugError ? "text-destructive" : "text-muted-foreground"}`}>
koja.meezi.ir/{slug}
</p>
)}
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</LabeledField>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("profile.name")} htmlFor="cafe-name">
<Input id="cafe-name" value={name} onChange={(e) => setName(e.target.value)} />
</LabeledField>
<LabeledField label={t("profile.city")} htmlFor="cafe-city">
<Input id="cafe-city" value={city} onChange={(e) => setCity(e.target.value)} />
</LabeledField>
<LabeledField label={t("profile.phone")} htmlFor="cafe-phone">
<Input
id="cafe-phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("profile.address")} htmlFor="cafe-address">
<Input
id="cafe-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</LabeledField>
</div>
<LabeledField label={t("profile.description")} htmlFor="cafe-description">
<textarea
id="cafe-description"
className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={saveProfile.isPending}
onClick={() => saveProfile.mutate()}
>
{t("saveProfile")}
</Button>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("snappfoodVendor")}</CardTitle>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0">
<LabeledField label={t("snappfoodVendor")} htmlFor="snappfood-vendor">
<Input
id="snappfood-vendor"
value={snappfoodVendorId}
onChange={(e) => setSnappfoodVendorId(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("taraz")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6 pt-0">
<p className="text-sm leading-relaxed text-muted-foreground">{t("tarazHint")}</p>
<Button
variant="outline"
disabled={submitTaraz.isPending}
onClick={() => submitTaraz.mutate()}
>
{t("tarazSubmit")}
</Button>
</CardContent>
</Card>
{/* Location card */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">موقعیت روی نقشه</CardTitle>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6 pt-0">
<p className="text-sm leading-relaxed text-muted-foreground">
موقعیت دقیق کافه/رستوران خود را وارد کنید تا مشتریان بتوانند آن را پیدا کنند.
برای دریافت مختصات دقیق میتوانید از{" "}
<a
href={`https://neshan.org/maps/@${parsedLat || 35.6892},${parsedLng || 51.389},15z`}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
نقشه نشان
</a>{" "}
استفاده کنید.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label="عرض جغرافیایی (Latitude)" htmlFor="cafe-lat">
<Input
id="cafe-lat"
value={latInput}
onChange={(e) => { setLatInput(e.target.value); setLocationError(null); }}
placeholder="مثال: ۳۵.۶۸۹۲"
dir="ltr"
className="text-end"
inputMode="decimal"
/>
</LabeledField>
<LabeledField label="طول جغرافیایی (Longitude)" htmlFor="cafe-lng">
<Input
id="cafe-lng"
value={lngInput}
onChange={(e) => { setLngInput(e.target.value); setLocationError(null); }}
placeholder="مثال: ۵۱.۳۸۹"
dir="ltr"
className="text-end"
inputMode="decimal"
/>
</LabeledField>
</div>
{locationError && (
<p className="text-xs text-destructive">{locationError}</p>
)}
{hasValidLocation && (
<LocationMapPreview lat={parsedLat} lng={parsedLng} />
)}
<div className="flex flex-wrap gap-2">
<Button
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={saveLocation.isPending}
onClick={() => saveLocation.mutate()}
>
ذخیره موقعیت
</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) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setLatInput(""); setLngInput(""); setLocationError(null); }}
>
پاک کردن موقعیت
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}