perf: modified cafe register flow

This commit is contained in:
Afra ‌ 2026-07-11 14:46:46 +08:00
parent e88c2531f7
commit fa34064828
25 changed files with 339 additions and 259 deletions

BIN
public/main-banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,75 @@
/* Kalameh FaNum — the "FaNum" variant renders Persian digit glyphs (۰-۹) correctly within
the font's own numeral slots, unlike the plain Kalameh set. Scoped to its own
--font-banner token (see index.css) rather than replacing the site-wide Vazirmatn font. */
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-THIN.TTF') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-EXTRALIGHT.TTF') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-LIGHT.TTF') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-REGULAR.TTF') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-MEDIUM.TTF') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-SEMIBOLD.TTF') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-BOLD.TTF') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-EXTRABOLD.TTF') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-BLACK.TTF') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { fetchMatchEvents, MatchEvent } from "../services/campaignApi";
import { useNow } from "../hooks/useNow";
import {
formatIranJalaliHeading,
formatIranKickoffTime,
isSameIranDay,
} from "../utils/iranDate";
export default function HomeBanner() {
const [matches, setMatches] = useState<MatchEvent[]>([]);
const now = useNow();
useEffect(() => {
fetchMatchEvents()
.then(setMatches)
.catch(() => {});
}, []);
// "today" means the Iran calendar day, regardless of the visiting device's own timezone —
// and unlike the match selectors elsewhere, this intentionally includes matches that
// already kicked off earlier today, since it's a poster of the day's full schedule
const todayMatches = matches
.filter((match) => isSameIranDay(new Date(match.match_datetime), now))
.sort(
(a, b) =>
new Date(a.match_datetime).getTime() -
new Date(b.match_datetime).getTime(),
);
return (
<div
className="relative h-[35vh] w-full bg-gray-200 bg-cover bg-center"
style={{ backgroundImage: "url('/main-banner.jpg')" }}
>
{/* darkens the right-hand side only, where the text/match list sits — keeps it
legible no matter what the underlying photo looks like there */}
<div className="absolute inset-0 bg-gradient-to-l from-black/45 to-transparent" />
{/* pinned to the image's right edge with a fixed width — not just text-aligned within
a full-width box — so every piece (title, date, match pills) stays on the right side
of the banner regardless of viewport width */}
<div className="absolute inset-y-0 right-0 flex w-[60%] flex-col justify-center gap-9 px-4 text-right font-banner">
<div className="w-full">
<h2 className="mb-3 text-4xl font-extrabold text-white">
بازی‌های امروز
</h2>
<p className="mb-3 w-full text-right text-4lg font-light text-[#FF7A1A]">
{formatIranJalaliHeading(now)}
</p>
<div className="w-full border-b border-white" />
</div>
{todayMatches.length > 0 && (
<div className="flex max-h-[60%] flex-col items-end gap-2 overflow-y-auto">
{todayMatches.map((match) => (
<div
key={match.uuid}
className="flex w-full items-center justify-end gap-2"
>
<span className="truncate rounded-full bg-[#FF7A1A]/20 px-3 py-1 text-sm text-white">
{match.team_home} {" "} {match.team_away}
</span>
<span className="w-20 shrink-0 rounded-full bg-[#FF7A1A] px-3 py-1 text-center text-sm font-semibold text-white">
{formatIranKickoffTime(match.match_datetime)}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -1,8 +1,10 @@
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap');
@import 'tailwindcss';
@import './assets/banner-fonts/banner-fonts.css';
@theme {
--font-sans: 'Vazirmatn', system-ui, 'Segoe UI', Roboto, sans-serif;
--font-banner: 'Kalameh FaNum', var(--font-sans);
}
html,

View file

@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
import "leaflet/dist/leaflet.css";
@ -11,64 +11,29 @@ import {
fetchCities,
fetchMyCafes,
registerCafe,
registerCafeOwner,
updateCafe,
} from "../services/campaignApi";
import { getCafeUuid, setCafeUuid } from "../utils/cafe";
import { toEnglishDigits } from "../utils/formatNumber";
import { fromE164, toE164 } from "../utils/phone";
// Tehran — default pin until the owner picks their cafe's actual location
const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 };
function StepCircle({
active,
children,
}: {
active: boolean;
children: ReactNode;
}) {
return (
<span
className={`mx-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${
active
? "bg-[#223D5D] text-white"
: "border border-gray-300 text-gray-400"
}`}
>
{children}
</span>
);
}
function Stepper({ currentStep }: { currentStep: 1 | 2 }) {
return (
<div className="flex items-center px-4 py-7">
<span
className={`whitespace-nowrap ${currentStep === 1 ? "font-medium text-gray-900" : "text-gray-400"}`}
>
اطلاعات نماینده
</span>
<StepCircle active={currentStep === 1}>۱</StepCircle>
<span className="mx-1 h-px flex-1 bg-gray-200" />
<StepCircle active={currentStep === 2}>۲</StepCircle>
<span
className={`whitespace-nowrap ${currentStep === 2 ? "font-medium text-gray-900" : "text-gray-400"}`}
>
اطلاعات کافه
</span>
</div>
function findTehran(cities: City[]): City | undefined {
return cities.find(
(c) => c.city_name === "تهران" || c.city_name.toLowerCase() === "tehran",
);
}
function FormField({
label,
placeholder,
hint,
value,
onChange,
}: {
label: string;
placeholder: string;
hint?: string;
value: string;
onChange: (value: string) => void;
}) {
@ -84,73 +49,18 @@ function FormField({
placeholder={placeholder}
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]"
/>
</div>
);
}
function CitySelectField({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: City[];
onChange: (uuid: string) => void;
}) {
const [open, setOpen] = useState(false);
const selectedCity = options.find((city) => city.uuid === value);
return (
<div className="relative mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
{label}
</label>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
>
<span className={selectedCity ? "text-gray-900" : "font-light text-[#999999]"}>
{selectedCity ? selectedCity.city_name : "شهر کافه را انتخاب کنید"}
</span>
<Icon
name="chevronDown"
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-60 overflow-y-auto rounded-xl bg-white shadow-lg">
{options.map((city) => (
<button
key={city.uuid}
type="button"
onClick={() => {
onChange(city.uuid);
setOpen(false);
}}
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
>
{city.city_name}
</button>
))}
</div>
</>
{hint && (
<p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
)}
</div>
);
}
function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) => void }) {
// keep the handlers object reference stable — react-leaflet re-binds map
// listeners (map.off/map.on) whenever this reference changes, and doing
// that mid-drag (e.g. triggered by an unrelated re-render) was silently
// aborting the user's pan gesture. a ref lets onSelect stay fresh without
// recreating the object every render.
function LocationPicker({
onSelect,
}: {
onSelect: (lat: number, lng: number) => void;
}) {
const onSelectRef = useRef(onSelect);
useEffect(() => {
onSelectRef.current = onSelect;
@ -170,16 +80,18 @@ function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) =>
}
function CafeLocationMap({
label,
location,
onChange,
}: {
label: string;
location: { lat: number; lng: number };
onChange: (lat: number, lng: number) => void;
}) {
return (
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
موقعیت کافه روی نقشه
{label}
</label>
<MapContainer
center={[location.lat, location.lng]}
@ -216,7 +128,7 @@ function UploadBox({
</label>
<button
type="button"
className="flex h-36 w-full items-center justify-center rounded-2xl border border-dashed border-gray-300 text-[#666666] bg-[#F8F8F8]"
className="flex h-36 w-full items-center justify-center rounded-2xl border border-dashed border-gray-300 bg-[#F8F8F8] text-[#666666]"
>
{buttonLabel}
</button>
@ -256,36 +168,28 @@ function FeatureChip({
export default function CafeOwnerRegister() {
const navigate = useNavigate();
const location = useLocation();
const initialStep = (location.state as { step?: 1 | 2 } | null)?.step ?? 1;
// the panel's "اطلاعات کافه" link is the only place that lands here with step 2 directly —
// treat that as "editing an existing cafe" rather than "continuing a fresh registration"
const isEditMode = initialStep === 2;
const [step, setStep] = useState<1 | 2>(initialStep);
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState("");
const [nationalId, setNationalId] = useState("");
const [cardNumber, setCardNumber] = useState("");
// panel's "اطلاعات کافه" link navigates here with state:{step:2} to signal edit mode
const isEditMode =
(location.state as { step?: number } | null)?.step === 2;
const [cafeName, setCafeName] = useState("");
const [selectedCityUuid, setSelectedCityUuid] = useState("");
const [address, setAddress] = useState("");
const [cafePhone, setCafePhone] = useState("");
const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION);
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
const [cities, setCities] = useState<City[]>([]);
// explicitly selected in edit mode (pre-filled from existing cafe); empty = auto-select Tehran below
const [selectedCityUuid, setSelectedCityUuid] = useState("");
const [cafeFeatures, setCafeFeatures] = useState<CafeFeature[]>([]);
// returned by /campaign/cafe-owners/ — sent as owner_uuid in the final /campaign/cafes/ request
const [ownerUuid, setOwnerUuid] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// redirect owners who already have a cafe away from fresh registration
useEffect(() => {
if (initialStep === 1 && getCafeUuid()) {
if (!isEditMode && getCafeUuid()) {
navigate("/owner/panel", { replace: true });
}
}, [initialStep, navigate]);
}, [isEditMode, navigate]);
useEffect(() => {
fetchCities()
@ -296,6 +200,13 @@ export default function CafeOwnerRegister() {
.catch(() => {});
}, []);
// fall back to Tehran when no city has been explicitly selected yet
const effectiveCityUuid =
selectedCityUuid ||
findTehran(cities)?.uuid ||
cities[0]?.uuid ||
"";
useEffect(() => {
if (!isEditMode) return;
fetchMyCafes()
@ -306,17 +217,18 @@ export default function CafeOwnerRegister() {
setSelectedCityUuid(cafe.city.uuid);
setAddress(cafe.address);
setCafePhone(fromE164(cafe.phone_number));
setCafeLocation({ lat: cafe.location.coordinates[1], lng: cafe.location.coordinates[0] });
setSelectedFeatures(cafe.features.map((feature) => feature.uuid));
setCafeLocation({
lat: cafe.location.coordinates[1],
lng: cafe.location.coordinates[0],
});
setSelectedFeatures(cafe.features.map((f) => f.uuid));
})
.catch(() => {});
}, [isEditMode]);
const toggleFeature = (feature: string) => {
const toggleFeature = (uuid: string) => {
setSelectedFeatures((prev) =>
prev.includes(feature)
? prev.filter((f) => f !== feature)
: [...prev, feature],
prev.includes(uuid) ? prev.filter((f) => f !== uuid) : [...prev, uuid],
);
};
@ -324,47 +236,34 @@ export default function CafeOwnerRegister() {
setError("");
setIsSubmitting(true);
try {
if (step === 1) {
const owner = await registerCafeOwner({
full_name: fullName,
phone_number: toE164(phone),
national_id: toEnglishDigits(nationalId),
pan_number: toEnglishDigits(cardNumber),
});
setOwnerUuid(owner.uuid);
setStep(2);
} else if (isEditMode) {
const cafeUuid = getCafeUuid() ?? "";
await updateCafe(cafeUuid, {
city_uuid: selectedCityUuid,
const payload = {
city_uuid: effectiveCityUuid,
name: cafeName,
phone_number: cafePhone ? toE164(cafePhone) : "",
location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] },
location: {
type: "Point" as const,
coordinates: [cafeLocation.lng, cafeLocation.lat] as [
number,
number,
],
},
address,
feature_uuids: selectedFeatures,
});
};
if (isEditMode) {
const cafeUuid = getCafeUuid() ?? "";
await updateCafe(cafeUuid, payload);
navigate("/owner/panel");
} else {
const cafe = await registerCafe({
owner_uuid: ownerUuid,
city_uuid: selectedCityUuid,
name: cafeName,
phone_number: cafePhone ? toE164(cafePhone) : "",
location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] },
address,
feature_uuids: selectedFeatures,
});
const cafe = await registerCafe({ owner_uuid: "", ...payload });
setCafeUuid(cafe.uuid);
navigate("/owner/register/success");
}
} catch {
// only the final cafe-registration step has a dedicated success page to mirror —
// owner registration (step 1) and editing an existing cafe just advance/redirect
// inline on success, so their failures stay inline too
if (step === 2 && !isEditMode) {
if (!isEditMode) {
navigate("/owner/register/fail");
} else {
setError("ثبت اطلاعات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
}
} finally {
setIsSubmitting(false);
@ -375,86 +274,39 @@ export default function CafeOwnerRegister() {
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<Stepper currentStep={step} />
<div className="px-4">
{step === 1 ? (
<>
<div className="flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-4">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-md leading-relaxed text-gray-600">
لطفاً یک{" "}
<span className="font-semibold text-gray-900">نماینده</span>{" "}
برای کافه معرفی کنید. نماینده باید در اپلیکیشن
ثبت‌نام کند و پرداخت‌ها به کیف پول او واریز می‌شود. شماره
موبایل، کد ملی و کارت بانکی باید متعلق به یک نفر باشند.
</p>
</div>
<FormField
label="نام و نام خانوادگی"
placeholder="نام و نام خانوادگی نماینده را وارد کنید"
value={fullName}
onChange={setFullName}
/>
<FormField
label="شماره تماس نماینده"
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phone}
onChange={setPhone}
/>
<FormField
label="کد ملی"
placeholder="کد ملی نماینده را وارد کنید"
value={nationalId}
onChange={setNationalId}
/>
<FormField
label="شماره کارت"
placeholder="شماره کارت نماینده را وارد کنید"
value={cardNumber}
onChange={setCardNumber}
/>
</>
) : (
<>
<FormField
label="نام کافه"
placeholder="نام کافه را وارد کنید"
value={cafeName}
onChange={setCafeName}
/>
<CitySelectField
label="شهر"
value={selectedCityUuid}
options={cities}
onChange={setSelectedCityUuid}
/>
<FormField
label="آدرس"
placeholder="موقعیت کافه را با جزئیات وارد کنید"
value={address}
onChange={setAddress}
/>
<FormField
label="شماره تماس کافه"
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={cafePhone}
onChange={setCafePhone}
/>
<CafeLocationMap
label="موقعیت روی نقشه"
location={cafeLocation}
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
/>
<UploadBox label="تصاویر (اختیاری)" buttonLabel="افزودن تصویر" />
<FormField
label="شماره تماس کافه"
placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
hint="شماره را همراه با پیش‌شماره شهر وارد کنید."
value={cafePhone}
onChange={setCafePhone}
/>
<UploadBox label="تصاویر" buttonLabel="افزودن تصویر" />
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
ویژگی‌های کافه
</label>
<div className="flex flex-wrap gap-4">
<div className="grid grid-cols-2 gap-3">
{cafeFeatures.map((feature) => (
<FeatureChip
key={feature.uuid}
@ -465,10 +317,10 @@ export default function CafeOwnerRegister() {
))}
</div>
</div>
</>
)}
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
{error && (
<p className="mt-4 text-right text-sm text-red-500">{error}</p>
)}
</div>
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
@ -484,8 +336,14 @@ export default function CafeOwnerRegister() {
onClick={handleContinue}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال ارسال..." : isEditMode ? "ذخیره تغییرات" : "ادامه"}
{!isSubmitting && !isEditMode && <Icon name="arrowRight" className="h-4 w-4" />}
{isSubmitting
? "در حال ارسال..."
: isEditMode
? "ذخیره تغییرات"
: "ادامه"}
{!isSubmitting && !isEditMode && (
<Icon name="chevronLeft" className="h-4 w-4" />
)}
</button>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
import HomeBanner from '../components/HomeBanner'
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown'
@ -40,13 +41,15 @@ function findTehran(cities: City[]): City | undefined {
// )
// }
function MapPlaceholder() {
return (
<div className="w-full h-[35vh] bg-gray-200 flex items-center justify-center text-gray-500">
بنر
</div>
)
}
// MapPlaceholder — only used by the disabled نقشه/لیست toggle below (the cafes-on-a-map
// view isn't built yet); the top-of-page بنر slot it used to fill is now HomeBanner.
// function MapPlaceholder() {
// return (
// <div className="w-full h-[35vh] bg-gray-200 flex items-center justify-center text-gray-500">
// بنر
// </div>
// )
// }
function CitySelectorRow({
label,
@ -234,7 +237,7 @@ export default function Home() {
return (
<div className="min-h-screen bg-white pb-24">
<MapPlaceholder />
<HomeBanner />
<MyReservationsRow />

68
src/utils/iranDate.ts Normal file
View file

@ -0,0 +1,68 @@
import { toPersianDigits } from './formatNumber'
// the campaign only runs in Iran, and "today" for match scheduling always means the
// Iran calendar day — independent of whatever timezone the visiting browser is in
export const IRAN_TIME_ZONE = 'Asia/Tehran'
const PERSIAN_MONTHS = [
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
]
// ordinal day-of-month words (index 0 = day 1), matching the "هفتم تیر" style of the
// reference banner rather than the numeral "۷ تیر" the Persian Intl calendar gives directly
const PERSIAN_DAY_ORDINALS = [
'یکم', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم', 'هفتم', 'هشتم', 'نهم', 'دهم',
'یازدهم', 'دوازدهم', 'سیزدهم', 'چهاردهم', 'پانزدهم', 'شانزدهم', 'هفدهم', 'هجدهم', 'نوزدهم', 'بیستم',
'بیست و یکم', 'بیست و دوم', 'بیست و سوم', 'بیست و چهارم', 'بیست و پنجم',
'بیست و ششم', 'بیست و هفتم', 'بیست و هشتم', 'بیست و نهم', 'سی‌ام', 'سی و یکم',
]
function getIranJalaliParts(date: Date) {
// `en-US-u-ca-persian` returns the Jalali year/month/day as plain Latin-digit parts —
// unlike `fa-IR`, which mixes in Persian digits and locale month names that would need
// to be parsed back out. Easier to read the numbers here and do our own Persian
// formatting (the ordinal words above) afterwards.
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', {
timeZone: IRAN_TIME_ZONE,
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(date)
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
return { year: get('year'), month: get('month'), day: get('day') }
}
// e.g. "هفتم تیر ۱۴۰۵"
export function formatIranJalaliHeading(date: Date): string {
const { year, month, day } = getIranJalaliParts(date)
const dayWord = PERSIAN_DAY_ORDINALS[day - 1] ?? toPersianDigits(String(day))
const monthName = PERSIAN_MONTHS[month - 1] ?? ''
return `${dayWord} ${monthName} ${toPersianDigits(String(year))}`
}
// Gregorian Y-M-D as seen in Iran's timezone — comparing these strings is the cheapest
// correct way to ask "same calendar day in Tehran", no Jalali conversion needed for that
function getIranCalendarDayKey(date: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: IRAN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
}
export function isSameIranDay(a: Date, b: Date): boolean {
return getIranCalendarDayKey(a) === getIranCalendarDayKey(b)
}
export function formatIranKickoffTime(matchDatetimeIso: string): string {
const time = new Date(matchDatetimeIso).toLocaleTimeString('en-GB', {
timeZone: IRAN_TIME_ZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return toPersianDigits(time)
}