diff --git a/public/main-banner.jpg b/public/main-banner.jpg new file mode 100644 index 0000000..09bf6c2 Binary files /dev/null and b/public/main-banner.jpg differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF new file mode 100644 index 0000000..94780a6 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF new file mode 100644 index 0000000..b63c8ca Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF new file mode 100644 index 0000000..9cd41c9 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF new file mode 100644 index 0000000..9f81e75 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF new file mode 100644 index 0000000..41dc12e Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF new file mode 100644 index 0000000..eda2ccb Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF new file mode 100644 index 0000000..1c3075c Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF new file mode 100644 index 0000000..78effde Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF new file mode 100644 index 0000000..bcc3764 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-BOLD (1).TTF b/src/assets/banner-fonts/KALAMEH-BOLD (1).TTF new file mode 100644 index 0000000..5478371 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-BOLD (1).TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF b/src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF new file mode 100644 index 0000000..e3b6611 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-LIGHT.TTF b/src/assets/banner-fonts/KALAMEH-LIGHT.TTF new file mode 100644 index 0000000..6821c28 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-LIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-MEDIUM.TTF b/src/assets/banner-fonts/KALAMEH-MEDIUM.TTF new file mode 100644 index 0000000..3ebc195 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-MEDIUM.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF b/src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF new file mode 100644 index 0000000..178d8da Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF b/src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF new file mode 100644 index 0000000..52e6a40 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-THIN (1).TTF b/src/assets/banner-fonts/KALAMEH-THIN (1).TTF new file mode 100644 index 0000000..dc3ff4b Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-THIN (1).TTF differ diff --git a/src/assets/banner-fonts/banner-fonts.css b/src/assets/banner-fonts/banner-fonts.css new file mode 100644 index 0000000..45e6463 --- /dev/null +++ b/src/assets/banner-fonts/banner-fonts.css @@ -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; +} diff --git a/src/assets/images/banners/Guest-banner.jpg b/src/assets/images/banners/Guest-banner.jpg new file mode 100644 index 0000000..ce01af1 Binary files /dev/null and b/src/assets/images/banners/Guest-banner.jpg differ diff --git a/src/assets/images/banners/Host-banner.jpg b/src/assets/images/banners/Host-banner.jpg new file mode 100644 index 0000000..91f01be Binary files /dev/null and b/src/assets/images/banners/Host-banner.jpg differ diff --git a/src/components/HomeBanner.tsx b/src/components/HomeBanner.tsx new file mode 100644 index 0000000..0923364 --- /dev/null +++ b/src/components/HomeBanner.tsx @@ -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([]); + 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 ( +
+ {/* darkens the right-hand side only, where the text/match list sits — keeps it + legible no matter what the underlying photo looks like there */} +
+ + {/* 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 */} +
+
+

+ بازی‌های امروز +

+

+ {formatIranJalaliHeading(now)} +

+
+
+ + {todayMatches.length > 0 && ( +
+ {todayMatches.map((match) => ( +
+ + {match.team_home} {" "} {match.team_away} + + + {formatIranKickoffTime(match.match_datetime)} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/index.css b/src/index.css index 4a553a5..5351747 100644 --- a/src/index.css +++ b/src/index.css @@ -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, diff --git a/src/pages/CafeOwnerRegister.tsx b/src/pages/CafeOwnerRegister.tsx index 0193dcf..5f095f0 100644 --- a/src/pages/CafeOwnerRegister.tsx +++ b/src/pages/CafeOwnerRegister.tsx @@ -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 ( - - {children} - - ); -} - -function Stepper({ currentStep }: { currentStep: 1 | 2 }) { - return ( -
- - اطلاعات نماینده - - ۱ - - ۲ - - اطلاعات کافه - -
+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]" /> -
- ); -} - -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 ( -
- - - - {open && ( - <> -
setOpen(false)} /> -
- {options.map((city) => ( - - ))} -
- + {hint && ( +

{hint}

)}
); } -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 (
@@ -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([]); - const [cities, setCities] = useState([]); + // explicitly selected in edit mode (pre-filled from existing cafe); empty = auto-select Tehran below + const [selectedCityUuid, setSelectedCityUuid] = useState(""); const [cafeFeatures, setCafeFeatures] = useState([]); - // 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 payload = { + city_uuid: effectiveCityUuid, + name: cafeName, + phone_number: cafePhone ? toE164(cafePhone) : "", + 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, { - city_uuid: selectedCityUuid, - name: cafeName, - phone_number: cafePhone ? toE164(cafePhone) : "", - location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] }, - address, - feature_uuids: selectedFeatures, - }); + 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,100 +274,53 @@ export default function CafeOwnerRegister() {
- -
- {step === 1 ? ( - <> -
- -

- لطفاً یک{" "} - نماینده{" "} - برای کافه معرفی کنید. نماینده باید در اپلیکیشن - ثبت‌نام کند و پرداخت‌ها به کیف پول او واریز می‌شود. شماره - موبایل، کد ملی و کارت بانکی باید متعلق به یک نفر باشند. -

-
+ + + setCafeLocation({ lat, lng })} + /> + + + - - - - - - ) : ( - <> - - - - - setCafeLocation({ lat, lng })} - /> +
+ +
+ {cafeFeatures.map((feature) => ( + toggleFeature(feature.uuid)} + /> + ))} +
+
- - - -
- -
- {cafeFeatures.map((feature) => ( - toggleFeature(feature.uuid)} - /> - ))} -
-
- + {error && ( +

{error}

)} - - {error &&

{error}

}
@@ -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 && } + {isSubmitting + ? "در حال ارسال..." + : isEditMode + ? "ذخیره تغییرات" + : "ادامه"} + {!isSubmitting && !isEditMode && ( + + )}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index c2c61be..6226168 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 ( -
- بنر -
- ) -} +// 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 ( +//
+// بنر +//
+// ) +// } function CitySelectorRow({ label, @@ -234,7 +237,7 @@ export default function Home() { return (
- + diff --git a/src/utils/iranDate.ts b/src/utils/iranDate.ts new file mode 100644 index 0000000..318d51a --- /dev/null +++ b/src/utils/iranDate.ts @@ -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) +}