perf: modified cafe register flow
This commit is contained in:
parent
e88c2531f7
commit
fa34064828
25 changed files with 339 additions and 259 deletions
BIN
public/main-banner.jpg
Normal file
BIN
public/main-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 774 KiB |
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-BOLD (1).TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-BOLD (1).TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-LIGHT.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-LIGHT.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-MEDIUM.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-MEDIUM.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF
Normal file
Binary file not shown.
BIN
src/assets/banner-fonts/KALAMEH-THIN (1).TTF
Normal file
BIN
src/assets/banner-fonts/KALAMEH-THIN (1).TTF
Normal file
Binary file not shown.
75
src/assets/banner-fonts/banner-fonts.css
Normal file
75
src/assets/banner-fonts/banner-fonts.css
Normal 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;
|
||||
}
|
||||
BIN
src/assets/images/banners/Guest-banner.jpg
Normal file
BIN
src/assets/images/banners/Guest-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/images/banners/Host-banner.jpg
Normal file
BIN
src/assets/images/banners/Host-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
74
src/components/HomeBanner.tsx
Normal file
74
src/components/HomeBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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() {
|
|||
<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={cafeName}
|
||||
onChange={setCafeName}
|
||||
/>
|
||||
<FormField
|
||||
label="آدرس"
|
||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
/>
|
||||
<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
|
||||
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
|
||||
location={cafeLocation}
|
||||
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
|
||||
/>
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||
ویژگیهای کافه
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{cafeFeatures.map((feature) => (
|
||||
<FeatureChip
|
||||
key={feature.uuid}
|
||||
label={feature.name}
|
||||
checked={selectedFeatures.includes(feature.uuid)}
|
||||
onToggle={() => toggleFeature(feature.uuid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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
68
src/utils/iranDate.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue