feat: cafe register flow
This commit is contained in:
parent
3b6d973846
commit
c0b8710ee5
13 changed files with 574 additions and 68 deletions
|
|
@ -1,3 +1,4 @@
|
|||
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
|
||||
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
|
||||
VITE_AUTH_CLIENT_ID=
|
||||
VITE_AUTH_CLIENT_SECRET=
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/
|
||||
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
export interface CafeFeature {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const mockCafeFeatures: CafeFeature[] = [
|
||||
{ id: 'open-space', label: 'فضای باز' },
|
||||
{ id: 'family-friendly', label: 'فضای مناسب خانواده' },
|
||||
{ id: 'smoking-allowed', label: 'امکان کشیدن سیگار' },
|
||||
{ id: 'parking', label: 'پارکینگ' },
|
||||
{ id: 'delivery', label: 'امکان سفارش' },
|
||||
]
|
||||
23
src/hooks/useMatchEvents.ts
Normal file
23
src/hooks/useMatchEvents.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { fetchMatchEvents, MatchEvent } from '../services/campaignApi'
|
||||
|
||||
export function useMatchEvents() {
|
||||
const [matches, setMatches] = useState<MatchEvent[]>([])
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatchEvents()
|
||||
.then(setMatches)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// drop matches whose kickoff has already passed — only upcoming ones should ever be selectable
|
||||
const upcomingMatches = matches.filter((match) => new Date(match.match_datetime).getTime() > now.getTime())
|
||||
|
||||
return { matches: upcomingMatches, now }
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
|
||||
const matchOptions = [
|
||||
'برزیل-مراکش (۳ روز دیگر-۲:۰۰)',
|
||||
'آرژانتین-فرانسه (۲ روز دیگر-۱۸:۰۰)',
|
||||
'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
|
||||
]
|
||||
import { createCafeSchedule, MatchEvent } from '../services/campaignApi'
|
||||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
import { getCafeUuid } from '../utils/cafe'
|
||||
|
||||
const ENTRY_FEE_STEP = 10000
|
||||
const DESCRIPTION_MAX_LENGTH = 200
|
||||
|
|
@ -51,8 +49,19 @@ function PlusIcon({ className = '' }: { className?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function MatchSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
function MatchSelect({
|
||||
value,
|
||||
options,
|
||||
now,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
options: MatchEvent[]
|
||||
now: Date
|
||||
onChange: (uuid: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedMatch = options.find((match) => match.uuid === value)
|
||||
|
||||
return (
|
||||
<div className="relative mt-5">
|
||||
|
|
@ -62,25 +71,27 @@ function MatchSelect({ value, onChange }: { value: string; onChange: (value: str
|
|||
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={value ? 'text-gray-900' : 'font-light text-[#999999]'}>{value || 'بازی مورد نظر را انتخاب کنید'}</span>
|
||||
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
||||
{selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'}
|
||||
</span>
|
||||
<ChevronDownIcon className={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 overflow-hidden rounded-xl bg-white shadow-lg">
|
||||
{matchOptions.map((option) => (
|
||||
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||
{options.map((match) => (
|
||||
<button
|
||||
key={option}
|
||||
key={match.uuid}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option)
|
||||
onChange(match.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"
|
||||
>
|
||||
{option}
|
||||
{formatMatchLabel(match, now)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -181,10 +192,31 @@ function DescriptionField({ value, onChange }: { value: string; onChange: (value
|
|||
|
||||
export default function AddEvent() {
|
||||
const navigate = useNavigate()
|
||||
const [match, setMatch] = useState('')
|
||||
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||
const { matches, now } = useMatchEvents()
|
||||
const [capacity, setCapacity] = useState(1)
|
||||
const [entryFee, setEntryFee] = useState(100000)
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createCafeSchedule(getCafeUuid() ?? '', {
|
||||
match_event_uuid: selectedMatchUuid,
|
||||
total_tickets: capacity,
|
||||
ticket_price: String(entryFee),
|
||||
description,
|
||||
})
|
||||
navigate('/owner/events/success')
|
||||
} catch {
|
||||
setError('ثبت رویداد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
|
|
@ -196,11 +228,11 @@ export default function AddEvent() {
|
|||
</button>
|
||||
|
||||
<div className="px-4">
|
||||
<MatchSelect value={match} onChange={setMatch} />
|
||||
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
||||
<Stepper value={capacity} step={1} min={1} suffix="نفر" onChange={setCapacity} />
|
||||
<Stepper value={capacity} step={1} min={1} suffix="نفر" editable onChange={setCapacity} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
|
|
@ -209,16 +241,19 @@ export default function AddEvent() {
|
|||
</div>
|
||||
|
||||
<DescriptionField value={description} onChange={setDescription} />
|
||||
|
||||
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 flex justify-end bg-white px-4 py-4">
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex justify-end bg-white px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/owner/events/success')}
|
||||
className="flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
ادامه
|
||||
<ChevronIcon className="h-4 w-4" />
|
||||
{isSubmitting ? 'در حال ارسال...' : 'ادامه'}
|
||||
{!isSubmitting && <ChevronIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,22 +2,11 @@ import { ReactNode, useState } from "react";
|
|||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L from "leaflet";
|
||||
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
|
||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
|
||||
import markerShadow from "leaflet/dist/images/marker-shadow.png";
|
||||
import "../utils/leafletIcons";
|
||||
import { mockCafes, Cafe } from "../data/mockCafes";
|
||||
import InfoIcon from "../components/InfoIcon";
|
||||
import ReservationModal from "../components/ReservationModal";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: markerIcon,
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
shadowUrl: markerShadow,
|
||||
});
|
||||
|
||||
function ChevronLeftIcon() {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
import { ReactNode, useState } from "react";
|
||||
import { ReactNode, 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";
|
||||
import "../utils/leafletIcons";
|
||||
import InfoIcon from "../components/InfoIcon";
|
||||
import { mockCafeFeatures } from "../data/mockCafeFeatures";
|
||||
import {
|
||||
CafeFeature,
|
||||
City,
|
||||
fetchCafeFeatures,
|
||||
fetchCities,
|
||||
registerCafe,
|
||||
registerCafeOwner,
|
||||
} from "../services/campaignApi";
|
||||
import { getCafeUuid, setCafeUuid } from "../utils/cafe";
|
||||
import { toEnglishDigits } from "../utils/formatNumber";
|
||||
import { 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 ArrowIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
|
|
@ -19,6 +35,20 @@ function ArrowIcon({ className = "" }: { className?: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${className}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
|
|
@ -102,6 +132,117 @@ function FormField({
|
|||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<ChevronDownIcon className={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>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
const onSelectRef = useRef(onSelect);
|
||||
useEffect(() => {
|
||||
onSelectRef.current = onSelect;
|
||||
}, [onSelect]);
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
click(e: { latlng: { lat: number; lng: number } }) {
|
||||
onSelectRef.current(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useMapEvents(handlers);
|
||||
return null;
|
||||
}
|
||||
|
||||
function CafeLocationMap({
|
||||
location,
|
||||
onChange,
|
||||
}: {
|
||||
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>
|
||||
<MapContainer
|
||||
center={[location.lat, location.lng]}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
className="h-48 w-full overflow-hidden rounded-2xl"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© Addwin Maps"
|
||||
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
|
||||
maxZoom={19}
|
||||
minZoom={8}
|
||||
/>
|
||||
<Marker position={[location.lat, location.lng]} />
|
||||
<ZoomControl position="topright" />
|
||||
<LocationPicker onSelect={onChange} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadBox({
|
||||
label,
|
||||
buttonLabel,
|
||||
|
|
@ -165,10 +306,34 @@ export default function CafeOwnerRegister() {
|
|||
const [cardNumber, setCardNumber] = useState("");
|
||||
|
||||
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[]>([]);
|
||||
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("");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStep === 1 && getCafeUuid()) {
|
||||
navigate("/owner/panel", { replace: true });
|
||||
}
|
||||
}, [initialStep, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCities()
|
||||
.then(setCities)
|
||||
.catch(() => {});
|
||||
fetchCafeFeatures()
|
||||
.then(setCafeFeatures)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleFeature = (feature: string) => {
|
||||
setSelectedFeatures((prev) =>
|
||||
prev.includes(feature)
|
||||
|
|
@ -177,6 +342,39 @@ export default function CafeOwnerRegister() {
|
|||
);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
setCafeUuid(cafe.uuid);
|
||||
navigate("/owner/register/success");
|
||||
}
|
||||
} catch {
|
||||
setError("ثبت اطلاعات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
|
@ -230,6 +428,12 @@ export default function CafeOwnerRegister() {
|
|||
value={cafeName}
|
||||
onChange={setCafeName}
|
||||
/>
|
||||
<CitySelectField
|
||||
label="شهر"
|
||||
value={selectedCityUuid}
|
||||
options={cities}
|
||||
onChange={setSelectedCityUuid}
|
||||
/>
|
||||
<FormField
|
||||
label="آدرس"
|
||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||
|
|
@ -242,6 +446,10 @@ export default function CafeOwnerRegister() {
|
|||
value={cafePhone}
|
||||
onChange={setCafePhone}
|
||||
/>
|
||||
<CafeLocationMap
|
||||
location={cafeLocation}
|
||||
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
|
||||
/>
|
||||
|
||||
<UploadBox label="تصاویر (اختیاری)" buttonLabel="افزودن تصویر" />
|
||||
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
|
||||
|
|
@ -251,36 +459,37 @@ export default function CafeOwnerRegister() {
|
|||
ویژگیهای کافه
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{mockCafeFeatures.map((feature) => (
|
||||
{cafeFeatures.map((feature) => (
|
||||
<FeatureChip
|
||||
key={feature.id}
|
||||
label={feature.label}
|
||||
checked={selectedFeatures.includes(feature.id)}
|
||||
onToggle={() => toggleFeature(feature.id)}
|
||||
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 className="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">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
||||
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
||||
>
|
||||
پشتیبانی
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
step === 1 ? setStep(2) : navigate("/owner/register/success")
|
||||
}
|
||||
className="flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
ادامه
|
||||
<ArrowIcon className="h-4 w-4" />
|
||||
{isSubmitting ? "در حال ارسال..." : "ادامه"}
|
||||
{!isSubmitting && <ArrowIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ import { useState } from 'react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import BookIcon from '../components/BookIcon'
|
||||
import { mockCafes, Cafe } from '../data/mockCafes'
|
||||
import { MatchEvent } from '../services/campaignApi'
|
||||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
|
||||
const cityOptions = ['تهران', 'اصفهان', 'مشهد']
|
||||
const matchOptions = [
|
||||
'برزیل-مراکش (۳ روز دیگر-۲:۰۰)',
|
||||
'آرژانتین-فرانسه (۲ روز دیگر-۱۸:۰۰)',
|
||||
'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
|
||||
]
|
||||
|
||||
function ChevronIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
|
|
@ -118,6 +116,58 @@ function SelectorRow({
|
|||
)
|
||||
}
|
||||
|
||||
function MatchSelectorRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
now,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: MatchEvent[]
|
||||
now: Date
|
||||
onChange: (uuid: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedMatch = options.find((match) => match.uuid === value)
|
||||
|
||||
return (
|
||||
<div className="relative px-4 mt-4">
|
||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
>
|
||||
<span className="text-gray-900">{selectedMatch ? formatMatchLabel(selectedMatch, now) : ''}</span>
|
||||
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||
{options.map((match) => (
|
||||
<button
|
||||
key={match.uuid}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(match.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"
|
||||
>
|
||||
{formatMatchLabel(match, now)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MyReservationsRow() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
|
@ -157,7 +207,9 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
|
|||
export default function Home() {
|
||||
const [view, setView] = useState<'list' | 'map'>('list')
|
||||
const [city, setCity] = useState(cityOptions[0])
|
||||
const [match, setMatch] = useState(matchOptions[0])
|
||||
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||
const { matches, now } = useMatchEvents()
|
||||
const effectiveMatchUuid = selectedMatchUuid || matches[0]?.uuid || ''
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
|
|
@ -166,7 +218,13 @@ export default function Home() {
|
|||
<MyReservationsRow />
|
||||
|
||||
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
|
||||
<SelectorRow label="بازی انتخابی" value={match} options={matchOptions} onChange={setMatch} />
|
||||
<MatchSelectorRow
|
||||
label="بازی انتخابی"
|
||||
value={effectiveMatchUuid}
|
||||
options={matches}
|
||||
now={now}
|
||||
onChange={setSelectedMatchUuid}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between px-4">
|
||||
<h2 className="font-semibold text-gray-900">کافهها</h2>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { requestOtp, verifyOtp } from '../services/authApi'
|
||||
import { fetchMyCafes } from '../services/campaignApi'
|
||||
import { storeAuthTokens } from '../utils/authToken'
|
||||
import { setCafeUuid } from '../utils/cafe'
|
||||
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
|
||||
import { toE164 } from '../utils/phone'
|
||||
import { getUserRole } from '../utils/role'
|
||||
|
|
@ -106,7 +108,18 @@ export default function LoginOtp() {
|
|||
const tokens = await verifyOtp(toE164(phone), otp.join(''))
|
||||
storeAuthTokens(tokens.access_token, tokens.refresh_token)
|
||||
const role = getUserRole()
|
||||
navigate(role === 'cafe_owner' ? '/owner/panel' : '/')
|
||||
|
||||
if (role === 'cafe_owner') {
|
||||
const myCafes = await fetchMyCafes().catch(() => [])
|
||||
if (myCafes.length > 0) {
|
||||
setCafeUuid(myCafes[0].uuid)
|
||||
navigate('/owner/panel')
|
||||
} else {
|
||||
navigate('/owner/register')
|
||||
}
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
} catch {
|
||||
setError('کد وارد شده صحیح نیست')
|
||||
} finally {
|
||||
|
|
|
|||
131
src/services/campaignApi.ts
Normal file
131
src/services/campaignApi.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import api from './api'
|
||||
|
||||
export interface RegisterCafeOwnerPayload {
|
||||
full_name: string
|
||||
phone_number: string
|
||||
national_id: string
|
||||
pan_number: string
|
||||
}
|
||||
|
||||
export interface CafeOwnerResponse {
|
||||
uuid: string
|
||||
user_uuid: string
|
||||
full_name: string
|
||||
phone_number: string
|
||||
national_id: string
|
||||
pan_number: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function registerCafeOwner(payload: RegisterCafeOwnerPayload): Promise<CafeOwnerResponse> {
|
||||
const { data } = await api.post<CafeOwnerResponse>('/campaign/cafe-owners/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface City {
|
||||
uuid: string
|
||||
city_name: string
|
||||
}
|
||||
|
||||
export async function fetchCities(): Promise<City[]> {
|
||||
const { data } = await api.get<City[]>('/campaign/cities/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface MatchEvent {
|
||||
uuid: string
|
||||
title: string
|
||||
team_home: string
|
||||
team_away: string
|
||||
match_datetime: string
|
||||
}
|
||||
|
||||
export async function fetchMatchEvents(): Promise<MatchEvent[]> {
|
||||
const { data } = await api.get<MatchEvent[]>('/campaign/match-events/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CafeFeature {
|
||||
uuid: string
|
||||
name: string
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
export async function fetchCafeFeatures(): Promise<CafeFeature[]> {
|
||||
const { data } = await api.get<CafeFeature[]>('/campaign/cafe-features/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GeoPoint {
|
||||
type: 'Point'
|
||||
coordinates: [number, number]
|
||||
}
|
||||
|
||||
export interface RegisterCafePayload {
|
||||
owner_uuid: string
|
||||
city_uuid: string
|
||||
name: string
|
||||
phone_number: string
|
||||
location: GeoPoint
|
||||
address: string
|
||||
feature_uuids: string[]
|
||||
}
|
||||
|
||||
export interface CafeResponse extends RegisterCafePayload {
|
||||
uuid: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function registerCafe(payload: RegisterCafePayload): Promise<CafeResponse> {
|
||||
const { data } = await api.post<CafeResponse>('/campaign/cafes/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CafeMedia {
|
||||
uuid: string
|
||||
cafe_uuid: string
|
||||
url: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface MyCafe {
|
||||
uuid: string
|
||||
owner_uuid: string
|
||||
name: string
|
||||
phone_number: string
|
||||
location: GeoPoint
|
||||
address: string
|
||||
is_approved: boolean
|
||||
created_at: string
|
||||
city: City
|
||||
features: CafeFeature[]
|
||||
media: CafeMedia[]
|
||||
}
|
||||
|
||||
export async function fetchMyCafes(): Promise<MyCafe[]> {
|
||||
// bare array, same as /campaign/cities/, /campaign/cafe-features/, etc. — NOT wrapped in { success, data }
|
||||
const { data } = await api.get<MyCafe[]>('/campaign/cafes/my/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CafeSchedule {
|
||||
uuid: string
|
||||
cafe_uuid: string
|
||||
match_event: MatchEvent
|
||||
total_tickets: number
|
||||
ticket_price: string
|
||||
remaining_tickets: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CreateCafeSchedulePayload {
|
||||
match_event_uuid: string
|
||||
total_tickets: number
|
||||
ticket_price: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export async function createCafeSchedule(cafeUuid: string, payload: CreateCafeSchedulePayload): Promise<CafeSchedule> {
|
||||
const { data } = await api.post<CafeSchedule>(`/campaign/cafes/${cafeUuid}/schedules/`, payload)
|
||||
return data
|
||||
}
|
||||
9
src/utils/cafe.ts
Normal file
9
src/utils/cafe.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const CAFE_UUID_STORAGE_KEY = 'cafe_uuid'
|
||||
|
||||
export function setCafeUuid(uuid: string) {
|
||||
localStorage.setItem(CAFE_UUID_STORAGE_KEY, uuid)
|
||||
}
|
||||
|
||||
export function getCafeUuid(): string | null {
|
||||
return localStorage.getItem(CAFE_UUID_STORAGE_KEY)
|
||||
}
|
||||
12
src/utils/leafletIcons.ts
Normal file
12
src/utils/leafletIcons.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import L from 'leaflet'
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png'
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: markerIcon,
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
shadowUrl: markerShadow,
|
||||
})
|
||||
37
src/utils/matchCountdown.ts
Normal file
37
src/utils/matchCountdown.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { toPersianDigits } from './formatNumber'
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function formatKickoffTime(matchDate: Date) {
|
||||
const time = matchDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return toPersianDigits(time)
|
||||
}
|
||||
|
||||
// under 24h: live HH:MM:SS countdown. otherwise: "X روز دیگر". both paired with the kickoff clock time.
|
||||
export function formatMatchCountdown(matchDatetimeIso: string, now: Date): string {
|
||||
const matchDate = new Date(matchDatetimeIso)
|
||||
const diffMs = matchDate.getTime() - now.getTime()
|
||||
const kickoffTime = formatKickoffTime(matchDate)
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return `اکنون-${kickoffTime}`
|
||||
}
|
||||
|
||||
if (diffMs >= ONE_DAY_MS) {
|
||||
const days = Math.floor(diffMs / ONE_DAY_MS)
|
||||
return `${toPersianDigits(String(days))} روز دیگر-${kickoffTime}`
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(diffMs / 1000)
|
||||
const hh = String(Math.floor(totalSeconds / 3600)).padStart(2, '0')
|
||||
const mm = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(totalSeconds % 60).padStart(2, '0')
|
||||
return `${toPersianDigits(`${hh}:${mm}:${ss}`)}-${kickoffTime}`
|
||||
}
|
||||
|
||||
export function formatMatchLabel(
|
||||
match: { team_home: string; team_away: string; match_datetime: string },
|
||||
now: Date,
|
||||
): string {
|
||||
return `${match.team_home}-${match.team_away} (${formatMatchCountdown(match.match_datetime, now)})`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue