perf: modified the reservation page

This commit is contained in:
Afra ‌ 2026-06-23 13:10:02 +03:30
parent 92cf39f723
commit 3b6d973846
14 changed files with 233 additions and 29 deletions

View file

@ -1,3 +1,4 @@
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
VITE_AUTH_CLIENT_ID=
VITE_AUTH_CLIENT_SECRET=
VITE_APP_BASE_URL=

1
.env.staging Normal file
View file

@ -0,0 +1 @@
VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/

View file

@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:staging": "vite build --mode staging",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"preview": "vite preview"

View file

@ -9,8 +9,10 @@ import CafeOwnerPanel from './pages/CafeOwnerPanel'
import AddEvent from './pages/AddEvent'
import AddEventSuccess from './pages/AddEventSuccess'
import EventReservations from './pages/EventReservations'
import MyReservations from './pages/MyReservations'
import Login from './pages/Login'
import LoginOtp from './pages/LoginOtp'
import RequireRole from './components/RequireRole'
function ScrollToTop() {
const { pathname } = useLocation()
@ -27,16 +29,18 @@ export default function App() {
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/cafe/:id" element={<CafeDetail />} />
<Route path="/payment/success" element={<PaymentSuccess />} />
<Route path="/owner/register" element={<CafeOwnerRegister />} />
<Route path="/owner/register/success" element={<CafeRegisterSuccess />} />
<Route path="/owner/panel" element={<CafeOwnerPanel />} />
<Route path="/owner/events/new" element={<AddEvent />} />
<Route path="/owner/events/success" element={<AddEventSuccess />} />
<Route path="/owner/events/:id/reservations" element={<EventReservations />} />
<Route path="/" element={<RequireRole role="customer"><Home /></RequireRole>} />
<Route path="/cafe/:id" element={<RequireRole role="customer"><CafeDetail /></RequireRole>} />
<Route path="/payment/success" element={<RequireRole role="customer"><PaymentSuccess /></RequireRole>} />
<Route path="/reservations" element={<RequireRole role="customer"><MyReservations /></RequireRole>} />
<Route path="/owner/register" element={<RequireRole role="cafe_owner"><CafeOwnerRegister /></RequireRole>} />
<Route path="/owner/register/success" element={<RequireRole role="cafe_owner"><CafeRegisterSuccess /></RequireRole>} />
<Route path="/owner/panel" element={<RequireRole role="cafe_owner"><CafeOwnerPanel /></RequireRole>} />
<Route path="/owner/events/new" element={<RequireRole role="cafe_owner"><AddEvent /></RequireRole>} />
<Route path="/owner/events/success" element={<RequireRole role="cafe_owner"><AddEventSuccess /></RequireRole>} />
<Route path="/owner/events/:id/reservations" element={<RequireRole role="cafe_owner"><EventReservations /></RequireRole>} />
<Route path="/login" element={<Login />} />
<Route path="/cafe/login" element={<Login />} />
<Route path="/login/otp" element={<LoginOtp />} />
</Routes>
</BrowserRouter>

View file

@ -0,0 +1,10 @@
export default function BookIcon() {
return (
<svg className="h-5 w-5 text-[#4D4D4D]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 16.7397V4.6697C22 3.4697 21.02 2.5797 19.83 2.6797H19.77C17.67 2.8597 14.48 3.9297 12.7 5.0497L12.53 5.1597C12.24 5.3397 11.76 5.3397 11.47 5.1597L11.22 5.0097C9.44 3.8997 6.26 2.8397 4.16 2.6697C2.97 2.5697 2 3.4697 2 4.6597V16.7397C2 17.6997 2.78 18.5997 3.74 18.7197L4.03 18.7597C6.2 19.0497 9.55 20.1497 11.47 21.1997L11.51 21.2197C11.78 21.3697 12.21 21.3697 12.47 21.2197C14.39 20.1597 17.75 19.0497 19.93 18.7597L20.26 18.7197C21.22 18.5997 22 17.6997 22 16.7397Z" />
<path d="M12 5.49023V20.4902" />
<path d="M7.75 8.49023H5.5" />
<path d="M8.5 11.4902H5.5" />
</svg>
)
}

View file

@ -0,0 +1,11 @@
import { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { getUserRole, UserRole } from '../utils/role'
export default function RequireRole({ role, children }: { role: UserRole; children: ReactNode }) {
const userRole = getUserRole()
if (userRole !== role) {
return <Navigate to={userRole === 'cafe_owner' ? '/cafe/login' : '/login'} replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,33 @@
export interface MyReservation {
id: number
matchLabel: string
dateTimeLabel: string
count: number
code: string
name: string
phone: string
cafeName: string
}
export const mockMyReservations: MyReservation[] = [
{
id: 1,
matchLabel: 'برزیل-مراکش',
dateTimeLabel: 'سه‌شنبه ۲۰ خرداد - ساعت ۰۰:۳۰',
count: 3,
code: 'QC-۸۸۸۷',
name: 'وینسو وینسوزاده',
phone: '۰۹۱۲۳۴۵۶۷۸۹',
cafeName: 'بی‌بای',
},
{
id: 2,
matchLabel: 'برزیل-مراکش',
dateTimeLabel: 'سه‌شنبه ۲۰ خرداد - ساعت ۰۰:۳۰',
count: 3,
code: 'QC-۸۸۸۷',
name: 'وینسو وینسوزاده',
phone: '۰۹۱۲۳۴۵۶۷۸۹',
cafeName: 'بی‌بای',
},
]

View file

@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import BookIcon from '../components/BookIcon'
import { mockCafeEvents } from '../data/mockCafeEvents'
function ChevronIcon({ className = 'h-5 w-5 text-gray-400' }: { className?: string }) {
@ -20,17 +21,6 @@ function ProfileCircleIcon() {
)
}
function BookIcon() {
return (
<svg className="h-5 w-5 text-[#4D4D4D]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 16.7397V4.6697C22 3.4697 21.02 2.5797 19.83 2.6797H19.77C17.67 2.8597 14.48 3.9297 12.7 5.0497L12.53 5.1597C12.24 5.3397 11.76 5.3397 11.47 5.1597L11.22 5.0097C9.44 3.8997 6.26 2.8397 4.16 2.6697C2.97 2.5697 2 3.4697 2 4.6597V16.7397C2 17.6997 2.78 18.5997 3.74 18.7197L4.03 18.7597C6.2 19.0497 9.55 20.1497 11.47 21.1997L11.51 21.2197C11.78 21.3697 12.21 21.3697 12.47 21.2197C14.39 20.1597 17.75 19.0497 19.93 18.7597L20.26 18.7197C21.22 18.5997 22 17.6997 22 16.7397Z" />
<path d="M12 5.49023V20.4902" />
<path d="M7.75 8.49023H5.5" />
<path d="M8.5 11.4902H5.5" />
</svg>
)
}
function CalendarIcon() {
return (
<svg className="h-5 w-5 text-[#4D4D4D]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">

View file

@ -12,8 +12,7 @@ export default function CafeRegisterSuccess() {
<SuccessIcon />
<h1 className="mt-6 text-xl font-bold text-gray-900">کافه شما با موفقیت ثبت شد!</h1>
<p className="mt-3 leading-relaxed text-gray-500">
با اضافه کردن ایونت بازی‌ها، کافه شما در کمپین نمایش داده می‌شود و کاربران می‌توانند برای هر مسابقه رزرو ثبت
کنند.
با اضافه کردن ایونت بازی‌ها، کافه شما در کمپین نمایش داده می‌شود و کاربران می‌توانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.
</p>
</div>

View file

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import BookIcon from '../components/BookIcon'
import { mockCafes, Cafe } from '../data/mockCafes'
const cityOptions = ['تهران', 'اصفهان', 'مشهد']
@ -9,6 +10,14 @@ const matchOptions = [
'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
]
function ChevronIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
</svg>
)
}
function ChevronDownIcon({ className = '' }: { className?: string }) {
return (
<svg
@ -109,6 +118,24 @@ function SelectorRow({
)
}
function MyReservationsRow() {
const navigate = useNavigate()
return (
<button
type="button"
onClick={() => navigate('/reservations')}
className="flex w-full items-center justify-between px-4 py-6"
>
<div className="flex items-center gap-2">
<BookIcon />
<span className="text-gray-900">رزروهای شما</span>
</div>
<ChevronIcon className="h-5 w-5 text-gray-400" />
</button>
)
}
function CafeCard({ cafe }: { cafe: Cafe }) {
const navigate = useNavigate()
@ -136,6 +163,8 @@ export default function Home() {
<div className="min-h-screen bg-white pb-24">
<MapPlaceholder />
<MyReservationsRow />
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
<SelectorRow label="بازی انتخابی" value={match} options={matchOptions} onChange={setMatch} />

View file

@ -1,8 +1,9 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { requestOtp } from "../services/authApi";
import { toPersianDigits } from "../utils/formatNumber";
import { toE164 } from "../utils/phone";
import { setUserRole } from "../utils/role";
function CheckIcon() {
return (
@ -24,6 +25,11 @@ export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const initialPhone = (location.state as { phone?: string } | null)?.phone ?? "";
const isOwnerFlow = location.pathname.startsWith("/cafe/login");
useEffect(() => {
setUserRole(isOwnerFlow ? "cafe_owner" : "customer");
}, [isOwnerFlow]);
const [phone, setPhone] = useState(initialPhone);
const [acceptedTerms, setAcceptedTerms] = useState(false);
@ -89,7 +95,7 @@ export default function Login() {
{acceptedTerms && <CheckIcon />}
</button>
<p className="text-sm text-gray-600">
<a href="#" className="text-[#223D5D] underline">
<a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
قوانین و شرایط
</a>{" "}
را می‌پذیرم.

View file

@ -33,6 +33,7 @@ export default function LoginOtp() {
const location = useLocation()
const state = location.state as { phone?: string; ttl?: number } | null
const phone = state?.phone ?? ''
const loginPath = getUserRole() === 'cafe_owner' ? '/cafe/login' : '/login'
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''))
const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS))
@ -44,9 +45,9 @@ export default function LoginOtp() {
useEffect(() => {
if (!phone) {
navigate('/login', { replace: true })
navigate(loginPath, { replace: true })
}
}, [phone, navigate])
}, [phone, loginPath, navigate])
useEffect(() => {
const timer = setInterval(() => {
@ -118,7 +119,7 @@ export default function LoginOtp() {
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
<div className="mt-2 flex items-center justify-start gap-2">
<span className="text-[#223D5D]">{phone}</span>
<button type="button" onClick={() => navigate('/login', { state: { phone } })} aria-label="ویرایش شماره تماس">
<button type="button" onClick={() => navigate(loginPath, { state: { phone } })} aria-label="ویرایش شماره تماس">
<EditIcon className="h-5 w-5 text-[#223D5D]" />
</button>
</div>

View file

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import InfoIcon from '../components/InfoIcon'
import SuccessIcon from '../components/SuccessIcon'
import { mockMyReservations, MyReservation } from '../data/mockMyReservations'
import { formatPersianNumber } from '../utils/formatNumber'
function ChevronIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
</svg>
)
}
function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full flex-col items-start gap-3 rounded-2xl p-6 text-right shadow-[0px_4px_12px_0px_#0000001F]"
>
<h3 className="font-medium text-xl text-gray-900">{reservation.matchLabel}</h3>
<p className="text-lg text-gray-500">{reservation.dateTimeLabel}</p>
<div className="flex items-center gap-2">
<span className="font-normal rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
{formatPersianNumber(reservation.count)} نفر
</span>
<span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
{reservation.code}
</span>
</div>
</button>
)
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-3">
<span className="text-gray-500">{label}</span>
<span className="text-gray-900">{value}</span>
</div>
)
}
function ReservationDetailModal({ reservation, onClose }: { reservation: MyReservation; onClose: () => void }) {
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previousOverflow
}
}, [])
return createPortal(
<div className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
<div
className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6"
onClick={(e) => e.stopPropagation()}
>
<SuccessIcon className="mx-auto h-24 w-24" />
<h2 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.count)} نفر`} />
<DetailRow label="به نام" value={reservation.name} />
<DetailRow label="شماره تماس" value={reservation.phone} />
<DetailRow label="کافه" value={reservation.cafeName} />
<DetailRow label="بازی" value={reservation.matchLabel} />
<DetailRow label="تاریخ و ساعت" value={reservation.dateTimeLabel} />
<DetailRow label="کد رزرو" value={reservation.code} />
</div>
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
<p className="text-right text-sm text-gray-500">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک می‌شود.
</p>
</div>
<button type="button" onClick={onClose} className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white">
بازگشت
</button>
</div>
</div>,
document.body,
)
}
export default function MyReservations() {
const navigate = useNavigate()
const [selectedReservation, setSelectedReservation] = useState<MyReservation | null>(null)
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
<ChevronIcon className="h-5 w-5 -scale-x-100 text-gray-900" />
<span className="text-lg font-bold text-gray-900">رزروهای شما</span>
</button>
<div className="mt-4 flex flex-col gap-3 px-4">
{mockMyReservations.map((reservation) => (
<MyReservationCard key={reservation.id} reservation={reservation} onClick={() => setSelectedReservation(reservation)} />
))}
</div>
{selectedReservation && (
<ReservationDetailModal reservation={selectedReservation} onClose={() => setSelectedReservation(null)} />
)}
</div>
)
}

View file

@ -2,8 +2,11 @@ export type UserRole = 'customer' | 'cafe_owner'
const ROLE_STORAGE_KEY = 'role'
// dev-only helper until real auth/role assignment exists — set via
// localStorage.setItem('role', 'cafe_owner') in devtools to test that flow
// set by Login.tsx based on whether the user entered via /login or /cafe/login
export function setUserRole(role: UserRole) {
localStorage.setItem(ROLE_STORAGE_KEY, role)
}
export function getUserRole(): UserRole {
return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer'
}