diff --git a/.env.example b/.env.example index ad0bad0..988ada0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.env.staging b/.env.staging index 344de72..b04b165 100644 --- a/.env.staging +++ b/.env.staging @@ -1 +1,2 @@ VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/ +VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/ diff --git a/src/data/mockCafeFeatures.ts b/src/data/mockCafeFeatures.ts deleted file mode 100644 index 8171d11..0000000 --- a/src/data/mockCafeFeatures.ts +++ /dev/null @@ -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: 'امکان سفارش' }, -] diff --git a/src/hooks/useMatchEvents.ts b/src/hooks/useMatchEvents.ts new file mode 100644 index 0000000..432d46a --- /dev/null +++ b/src/hooks/useMatchEvents.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' +import { fetchMatchEvents, MatchEvent } from '../services/campaignApi' + +export function useMatchEvents() { + const [matches, setMatches] = useState([]) + 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 } +} diff --git a/src/pages/AddEvent.tsx b/src/pages/AddEvent.tsx index d7e5c8e..6201158 100644 --- a/src/pages/AddEvent.tsx +++ b/src/pages/AddEvent.tsx @@ -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 (
@@ -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" > - {value || 'بازی مورد نظر را انتخاب کنید'} + + {selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'} + {open && ( <>
setOpen(false)} /> -
- {matchOptions.map((option) => ( +
+ {options.map((match) => ( ))}
@@ -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 (
@@ -196,11 +228,11 @@ export default function AddEvent() {
- +
- +
@@ -209,16 +241,19 @@ export default function AddEvent() {
+ + {error &&

{error}

}
-
+
diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx index 2602e82..882168a 100644 --- a/src/pages/CafeDetail.tsx +++ b/src/pages/CafeDetail.tsx @@ -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 ( + + + ); +} + function CheckIcon() { return ( void; +}) { + const [open, setOpen] = useState(false); + const selectedCity = options.find((city) => city.uuid === value); + + return ( +
+ + + + {open && ( + <> +
setOpen(false)} /> +
+ {options.map((city) => ( + + ))} +
+ + )} +
+ ); +} + +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 ( +
+ + + + + + + +
+ ); +} + 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([]); + const [cities, setCities] = useState([]); + const [cafeFeatures, setCafeFeatures] = useState([]); + // returned by /campaign/cafe-owners/ — sent as owner_uuid in the final /campaign/cafes/ request + const [ownerUuid, setOwnerUuid] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + + 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 (
@@ -230,6 +428,12 @@ export default function CafeOwnerRegister() { value={cafeName} onChange={setCafeName} /> + + setCafeLocation({ lat, lng })} + /> @@ -251,36 +459,37 @@ export default function CafeOwnerRegister() { ویژگی‌های کافه
- {mockCafeFeatures.map((feature) => ( + {cafeFeatures.map((feature) => ( toggleFeature(feature.id)} + key={feature.uuid} + label={feature.name} + checked={selectedFeatures.includes(feature.uuid)} + onToggle={() => toggleFeature(feature.uuid)} /> ))}
)} + + {error &&

{error}

}
-
+
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index c438e7b..2ea9f1e 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 ( +
+

{label}

+ + + {open && ( + <> +
setOpen(false)} /> +
+ {options.map((match) => ( + + ))} +
+ + )} +
+ ) +} + 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 (
@@ -166,7 +218,13 @@ export default function Home() { - +

کافه‌ها

diff --git a/src/pages/LoginOtp.tsx b/src/pages/LoginOtp.tsx index 4b8384e..c293b1d 100644 --- a/src/pages/LoginOtp.tsx +++ b/src/pages/LoginOtp.tsx @@ -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 { diff --git a/src/services/campaignApi.ts b/src/services/campaignApi.ts new file mode 100644 index 0000000..844eedb --- /dev/null +++ b/src/services/campaignApi.ts @@ -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 { + const { data } = await api.post('/campaign/cafe-owners/', payload) + return data +} + +export interface City { + uuid: string + city_name: string +} + +export async function fetchCities(): Promise { + const { data } = await api.get('/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 { + const { data } = await api.get('/campaign/match-events/') + return data +} + +export interface CafeFeature { + uuid: string + name: string + icon: string | null +} + +export async function fetchCafeFeatures(): Promise { + const { data } = await api.get('/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 { + const { data } = await api.post('/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 { + // bare array, same as /campaign/cities/, /campaign/cafe-features/, etc. — NOT wrapped in { success, data } + const { data } = await api.get('/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 { + const { data } = await api.post(`/campaign/cafes/${cafeUuid}/schedules/`, payload) + return data +} diff --git a/src/utils/cafe.ts b/src/utils/cafe.ts new file mode 100644 index 0000000..a38d26d --- /dev/null +++ b/src/utils/cafe.ts @@ -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) +} diff --git a/src/utils/leafletIcons.ts b/src/utils/leafletIcons.ts new file mode 100644 index 0000000..78421be --- /dev/null +++ b/src/utils/leafletIcons.ts @@ -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, +}) diff --git a/src/utils/matchCountdown.ts b/src/utils/matchCountdown.ts new file mode 100644 index 0000000..b0f7114 --- /dev/null +++ b/src/utils/matchCountdown.ts @@ -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)})` +}