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 url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
@import './assets/banner-fonts/banner-fonts.css';
@theme { @theme {
--font-sans: 'Vazirmatn', system-ui, 'Segoe UI', Roboto, sans-serif; --font-sans: 'Vazirmatn', system-ui, 'Segoe UI', Roboto, sans-serif;
--font-banner: 'Kalameh FaNum', var(--font-sans);
} }
html, 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 { useLocation, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet"; import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
@ -11,64 +11,29 @@ import {
fetchCities, fetchCities,
fetchMyCafes, fetchMyCafes,
registerCafe, registerCafe,
registerCafeOwner,
updateCafe, updateCafe,
} from "../services/campaignApi"; } from "../services/campaignApi";
import { getCafeUuid, setCafeUuid } from "../utils/cafe"; import { getCafeUuid, setCafeUuid } from "../utils/cafe";
import { toEnglishDigits } from "../utils/formatNumber";
import { fromE164, toE164 } from "../utils/phone"; 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 }; const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 };
function StepCircle({ function findTehran(cities: City[]): City | undefined {
active, return cities.find(
children, (c) => c.city_name === "تهران" || c.city_name.toLowerCase() === "tehran",
}: {
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 FormField({ function FormField({
label, label,
placeholder, placeholder,
hint,
value, value,
onChange, onChange,
}: { }: {
label: string; label: string;
placeholder: string; placeholder: string;
hint?: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
@ -84,73 +49,18 @@ function FormField({
placeholder={placeholder} 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]" className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]"
/> />
</div> {hint && (
); <p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
}
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>
</>
)} )}
</div> </div>
); );
} }
function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) => void }) { function LocationPicker({
// keep the handlers object reference stable — react-leaflet re-binds map onSelect,
// listeners (map.off/map.on) whenever this reference changes, and doing }: {
// that mid-drag (e.g. triggered by an unrelated re-render) was silently onSelect: (lat: number, lng: number) => void;
// aborting the user's pan gesture. a ref lets onSelect stay fresh without }) {
// recreating the object every render.
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
useEffect(() => { useEffect(() => {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
@ -170,16 +80,18 @@ function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) =>
} }
function CafeLocationMap({ function CafeLocationMap({
label,
location, location,
onChange, onChange,
}: { }: {
label: string;
location: { lat: number; lng: number }; location: { lat: number; lng: number };
onChange: (lat: number, lng: number) => void; onChange: (lat: number, lng: number) => void;
}) { }) {
return ( return (
<div className="mt-5"> <div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900"> <label className="mb-3 block text-right text-lg font-normal text-gray-900">
موقعیت کافه روی نقشه {label}
</label> </label>
<MapContainer <MapContainer
center={[location.lat, location.lng]} center={[location.lat, location.lng]}
@ -216,7 +128,7 @@ function UploadBox({
</label> </label>
<button <button
type="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} {buttonLabel}
</button> </button>
@ -256,36 +168,28 @@ function FeatureChip({
export default function CafeOwnerRegister() { export default function CafeOwnerRegister() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const initialStep = (location.state as { step?: 1 | 2 } | null)?.step ?? 1; // panel's "اطلاعات کافه" link navigates here with state:{step:2} to signal edit mode
// the panel's "اطلاعات کافه" link is the only place that lands here with step 2 directly — const isEditMode =
// treat that as "editing an existing cafe" rather than "continuing a fresh registration" (location.state as { step?: number } | null)?.step === 2;
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("");
const [cafeName, setCafeName] = useState(""); const [cafeName, setCafeName] = useState("");
const [selectedCityUuid, setSelectedCityUuid] = useState("");
const [address, setAddress] = useState(""); const [address, setAddress] = useState("");
const [cafePhone, setCafePhone] = useState(""); const [cafePhone, setCafePhone] = useState("");
const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION); const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION);
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]); const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
const [cities, setCities] = useState<City[]>([]); 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[]>([]); 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 [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
// redirect owners who already have a cafe away from fresh registration
useEffect(() => { useEffect(() => {
if (initialStep === 1 && getCafeUuid()) { if (!isEditMode && getCafeUuid()) {
navigate("/owner/panel", { replace: true }); navigate("/owner/panel", { replace: true });
} }
}, [initialStep, navigate]); }, [isEditMode, navigate]);
useEffect(() => { useEffect(() => {
fetchCities() fetchCities()
@ -296,6 +200,13 @@ export default function CafeOwnerRegister() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
// fall back to Tehran when no city has been explicitly selected yet
const effectiveCityUuid =
selectedCityUuid ||
findTehran(cities)?.uuid ||
cities[0]?.uuid ||
"";
useEffect(() => { useEffect(() => {
if (!isEditMode) return; if (!isEditMode) return;
fetchMyCafes() fetchMyCafes()
@ -306,17 +217,18 @@ export default function CafeOwnerRegister() {
setSelectedCityUuid(cafe.city.uuid); setSelectedCityUuid(cafe.city.uuid);
setAddress(cafe.address); setAddress(cafe.address);
setCafePhone(fromE164(cafe.phone_number)); setCafePhone(fromE164(cafe.phone_number));
setCafeLocation({ lat: cafe.location.coordinates[1], lng: cafe.location.coordinates[0] }); setCafeLocation({
setSelectedFeatures(cafe.features.map((feature) => feature.uuid)); lat: cafe.location.coordinates[1],
lng: cafe.location.coordinates[0],
});
setSelectedFeatures(cafe.features.map((f) => f.uuid));
}) })
.catch(() => {}); .catch(() => {});
}, [isEditMode]); }, [isEditMode]);
const toggleFeature = (feature: string) => { const toggleFeature = (uuid: string) => {
setSelectedFeatures((prev) => setSelectedFeatures((prev) =>
prev.includes(feature) prev.includes(uuid) ? prev.filter((f) => f !== uuid) : [...prev, uuid],
? prev.filter((f) => f !== feature)
: [...prev, feature],
); );
}; };
@ -324,47 +236,34 @@ export default function CafeOwnerRegister() {
setError(""); setError("");
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (step === 1) { const payload = {
const owner = await registerCafeOwner({ city_uuid: effectiveCityUuid,
full_name: fullName, name: cafeName,
phone_number: toE164(phone), phone_number: cafePhone ? toE164(cafePhone) : "",
national_id: toEnglishDigits(nationalId), location: {
pan_number: toEnglishDigits(cardNumber), type: "Point" as const,
}); coordinates: [cafeLocation.lng, cafeLocation.lat] as [
setOwnerUuid(owner.uuid); number,
setStep(2); number,
} else if (isEditMode) { ],
},
address,
feature_uuids: selectedFeatures,
};
if (isEditMode) {
const cafeUuid = getCafeUuid() ?? ""; const cafeUuid = getCafeUuid() ?? "";
await updateCafe(cafeUuid, { await updateCafe(cafeUuid, payload);
city_uuid: selectedCityUuid,
name: cafeName,
phone_number: cafePhone ? toE164(cafePhone) : "",
location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] },
address,
feature_uuids: selectedFeatures,
});
navigate("/owner/panel"); navigate("/owner/panel");
} else { } else {
const cafe = await registerCafe({ const cafe = await registerCafe({ owner_uuid: "", ...payload });
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,
});
setCafeUuid(cafe.uuid); setCafeUuid(cafe.uuid);
navigate("/owner/register/success"); navigate("/owner/register/success");
} }
} catch { } catch {
// only the final cafe-registration step has a dedicated success page to mirror — if (!isEditMode) {
// 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) {
navigate("/owner/register/fail"); navigate("/owner/register/fail");
} else { } else {
setError("ثبت اطلاعات با خطا مواجه شد. لطفاً دوباره تلاش کنید."); setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
} }
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -375,100 +274,53 @@ export default function CafeOwnerRegister() {
<div className="min-h-screen bg-white pb-24"> <div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" /> <div className="h-16 w-full bg-gray-200" />
<Stepper currentStep={step} />
<div className="px-4"> <div className="px-4">
{step === 1 ? ( <FormField
<> label="نام کافه"
<div className="flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-4"> placeholder="نام کافه را وارد کنید"
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" /> value={cafeName}
<p className="text-right text-md leading-relaxed text-gray-600"> onChange={setCafeName}
لطفاً یک{" "} />
<span className="font-semibold text-gray-900">نماینده</span>{" "} <FormField
برای کافه معرفی کنید. نماینده باید در اپلیکیشن label="آدرس"
ثبت‌نام کند و پرداخت‌ها به کیف پول او واریز می‌شود. شماره placeholder="موقعیت کافه را با جزئیات وارد کنید"
موبایل، کد ملی و کارت بانکی باید متعلق به یک نفر باشند. value={address}
</p> onChange={setAddress}
</div> />
<CafeLocationMap
label="موقعیت روی نقشه"
location={cafeLocation}
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
/>
<FormField
label="شماره تماس کافه"
placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
hint="شماره را همراه با پیش‌شماره شهر وارد کنید."
value={cafePhone}
onChange={setCafePhone}
/>
<UploadBox label="تصاویر" buttonLabel="افزودن تصویر" />
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
<FormField <div className="mt-5">
label="نام و نام خانوادگی" <label className="mb-3 block text-right text-lg font-normal text-gray-900">
placeholder="نام و نام خانوادگی نماینده را وارد کنید" ویژگی‌های کافه
value={fullName} </label>
onChange={setFullName} <div className="grid grid-cols-2 gap-3">
/> {cafeFeatures.map((feature) => (
<FormField <FeatureChip
label="شماره تماس نماینده" key={feature.uuid}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" label={feature.name}
value={phone} checked={selectedFeatures.includes(feature.uuid)}
onChange={setPhone} onToggle={() => toggleFeature(feature.uuid)}
/> />
<FormField ))}
label="کد ملی" </div>
placeholder="کد ملی نماینده را وارد کنید" </div>
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
location={cafeLocation}
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
/>
<UploadBox label="تصاویر (اختیاری)" buttonLabel="افزودن تصویر" /> {error && (
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" /> <p className="mt-4 text-right text-sm text-red-500">{error}</p>
<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">
{cafeFeatures.map((feature) => (
<FeatureChip
key={feature.uuid}
label={feature.name}
checked={selectedFeatures.includes(feature.uuid)}
onToggle={() => toggleFeature(feature.uuid)}
/>
))}
</div>
</div>
</>
)} )}
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
</div> </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"> <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} 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" 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
{!isSubmitting && !isEditMode && <Icon name="arrowRight" className="h-4 w-4" />} ? "در حال ارسال..."
: isEditMode
? "ذخیره تغییرات"
: "ادامه"}
{!isSubmitting && !isEditMode && (
<Icon name="chevronLeft" className="h-4 w-4" />
)}
</button> </button>
</div> </div>
</div> </div>

View file

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