feature/WNPT-176 #1

Merged
afra.khalaj merged 11 commits from feature/WNPT-176 into master 2026-06-24 07:41:29 -04:00
13 changed files with 574 additions and 68 deletions
Showing only changes of commit c0b8710ee5 - Show all commits

View file

@ -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=

View file

@ -1 +1,2 @@
VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/

View file

@ -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: 'امکان سفارش' },
]

View 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 }
}

View file

@ -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>

View file

@ -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

View file

@ -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="&copy; 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>

View file

@ -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>

View file

@ -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
View 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
View 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
View 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,
})

View 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)})`
}