perf: cfinal touches

This commit is contained in:
Afra ‌ 2026-06-24 22:47:43 +03:30
parent 02dc37ab52
commit 406fba535d
16 changed files with 299 additions and 113 deletions

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import { BrowserRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom'
import Home from './pages/Home'
import CafeDetail from './pages/CafeDetail'
import PaymentSuccess from './pages/PaymentSuccess'
@ -29,7 +29,8 @@ export default function App() {
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route path="/" element={<RequireRole role="customer"><Home /></RequireRole>} />
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/home" 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>} />

View file

@ -1,9 +1,12 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
import { Cafe } from "../services/campaignApi";
import { chargePageUrl, createCharge } from "../services/ipgApi";
import { fetchWalletBalances } from "../services/walletApi";
import { formatPersianNumber } from "../utils/formatNumber";
import { toE164 } from "../utils/phone";
import { setPendingReservation } from "../utils/pendingReservation";
import { getUserPhone } from "../utils/userPhone";
import InfoIcon from "./InfoIcon";
@ -12,6 +15,7 @@ interface ReservationModalProps {
onClose: () => void;
cafe: Cafe;
ticketPrice: number;
cafeMatchScheduleUuid: string;
}
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
@ -80,7 +84,9 @@ export default function ReservationModal({
onClose,
cafe,
ticketPrice,
cafeMatchScheduleUuid,
}: ReservationModalProps) {
const navigate = useNavigate();
const [fullName, setFullName] = useState("");
const [count, setCount] = useState(1);
const [walletBalance, setWalletBalance] = useState(0);
@ -111,14 +117,36 @@ export default function ReservationModal({
const handlePay = async () => {
setError("");
if (!fullName.trim()) {
setError("لطفاً نام و نام خانوادگی را وارد کنید.");
return;
}
setIsSubmitting(true);
const payload = {
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
people_count: count,
full_name: fullName,
phone_number: toE164(getUserPhone() ?? ""),
};
try {
const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
const { uuid } = await createCharge({
amount: payableAmount,
callback_url: `${appBaseUrl}/payment/success`,
});
window.location.href = chargePageUrl(uuid);
if (payableAmount > 0) {
// reservation creation happens on /payment/success, only once the gateway confirms state=20
setPendingReservation(payload);
const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
const { uuid } = await createCharge({
amount: payableAmount,
callback_url: `${appBaseUrl}/payment/success`,
});
window.location.href = chargePageUrl(uuid);
} else {
// wallet already covers the full amount — no gateway involved, land straight on the same success page
setPendingReservation(payload);
navigate("/payment/success?state=20");
}
} catch {
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
setIsSubmitting(false);
@ -230,11 +258,17 @@ export default function ReservationModal({
{error && <p className="mb-2 text-center text-sm text-red-500">{error}</p>}
<button
type="button"
disabled={isSubmitting}
disabled={isSubmitting || !cafeMatchScheduleUuid}
onClick={handlePay}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال انتقال به درگاه..." : "پرداخت"}
{isSubmitting
? payableAmount > 0
? "در حال انتقال به درگاه..."
: "در حال ثبت رزرو..."
: payableAmount > 0
? "پرداخت"
: "پرداخت از کیف پول"}
</button>
</div>
</div>

View file

@ -354,7 +354,8 @@ export default function CafeDetail() {
<button
type="button"
onClick={() => setIsReservationOpen(true)}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
disabled={!schedule}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
رزرو
</button>
@ -365,6 +366,7 @@ export default function CafeDetail() {
onClose={() => setIsReservationOpen(false)}
cafe={cafe}
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
/>
</div>
);

View file

@ -473,7 +473,7 @@ export default function CafeOwnerRegister() {
onChange={setAddress}
/>
<FormField
label="شماره تماس کافه (اختیاری)"
label="شماره تماس کافه"
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={cafePhone}
onChange={setCafePhone}

View file

@ -32,34 +32,35 @@ function ChevronDownIcon({ className = '' }: { className?: string }) {
)
}
function MapIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.52734 5.186V11.6727C1.52734 12.9393 2.42734 13.4593 3.52068 12.8327L5.08734 11.9393C5.42734 11.746 5.99401 11.726 6.34734 11.906L9.84734 13.6593C10.2007 13.8327 10.7673 13.8193 11.1073 13.626L13.994 11.9727C14.3607 11.7593 14.6673 11.2393 14.6673 10.8127V4.326C14.6673 3.05933 13.7673 2.53933 12.674 3.166L11.1073 4.05933C10.7673 4.25267 10.2007 4.27267 9.84734 4.09267L6.34734 2.346C5.99401 2.17267 5.42734 2.186 5.08734 2.37933L2.20068 4.03267C1.82734 4.246 1.52734 4.766 1.52734 5.186Z"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function ListIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 3.66602H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// MapIcon/ListIcon — only used by the disabled نقشه/لیست toggle above, commented out together with it
// function MapIcon() {
// return (
// <svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path
// d="M1.52734 5.186V11.6727C1.52734 12.9393 2.42734 13.4593 3.52068 12.8327L5.08734 11.9393C5.42734 11.746 5.99401 11.726 6.34734 11.906L9.84734 13.6593C10.2007 13.8327 10.7673 13.8193 11.1073 13.626L13.994 11.9727C14.3607 11.7593 14.6673 11.2393 14.6673 10.8127V4.326C14.6673 3.05933 13.7673 2.53933 12.674 3.166L11.1073 4.05933C10.7673 4.25267 10.2007 4.27267 9.84734 4.09267L6.34734 2.346C5.99401 2.17267 5.42734 2.186 5.08734 2.37933L2.20068 4.03267C1.82734 4.246 1.52734 4.766 1.52734 5.186Z"
// stroke="currentColor"
// strokeWidth={1.25}
// strokeLinecap="round"
// strokeLinejoin="round"
// />
// <path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// </svg>
// )
// }
//
// function ListIcon() {
// return (
// <svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M7.33398 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M7.33398 3.66602H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// </svg>
// )
// }
function MapPlaceholder() {
return (
@ -213,7 +214,9 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
}
export default function Home() {
const [view, setView] = useState<'list' | 'map'>('list')
// نقشه/لیست toggle — disabled for now, the map view isn't part of the app yet
// (will be added in a later version); uncomment together with its JSX below
// const [view, setView] = useState<'list' | 'map'>('list')
const [cities, setCities] = useState<City[]>([])
const [selectedCityUuid, setSelectedCityUuid] = useState('')
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
@ -234,9 +237,9 @@ export default function Home() {
}, [])
useEffect(() => {
// wait until both real defaults are known — city_uuid/event_uuid must always be sent together
// wait until both real defaults are known — city_uuid/event_id must always be sent together
if (!effectiveCityUuid || !effectiveMatchUuid) return
fetchCafes({ city_uuid: effectiveCityUuid, event_uuid: effectiveMatchUuid })
fetchCafes({ city_uuid: effectiveCityUuid, event_id: effectiveMatchUuid })
.then(setCafes)
.catch(() => {})
}, [effectiveCityUuid, effectiveMatchUuid])
@ -258,6 +261,7 @@ export default function Home() {
<div className="mt-6 flex items-center justify-between px-4">
<h2 className="font-semibold text-gray-900">کافه‌ها</h2>
{/* نقشه/لیست toggle — commented out together with the `view` state above, see that comment
<div className="flex rounded-full bg-gray-100 p-1">
<button
type="button"
@ -280,18 +284,16 @@ export default function Home() {
لیست
</button>
</div>
*/}
</div>
<div className="mt-3 px-4">
{view === 'list' ? (
<div className="space-y-3">
{cafes.map((cafe) => (
<CafeCard key={cafe.uuid} cafe={cafe} />
))}
</div>
) : (
<MapPlaceholder />
)}
{/* always the list view for now — restore the `view === 'list' ? ... : <MapPlaceholder />` ternary when the map toggle above is restored */}
<div className="space-y-3">
{cafes.map((cafe) => (
<CafeCard key={cafe.uuid} cafe={cafe} />
))}
</div>
</div>
<button

View file

@ -43,7 +43,7 @@ export default function Login() {
setIsSubmitting(true);
try {
const { ttl } = await requestOtp(toE164(phone));
navigate("/login/otp", { state: { phone, ttl } });
navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
} catch {
setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
} finally {

View file

@ -7,7 +7,7 @@ import { setCafeUuid } from '../utils/cafe'
import { setUserPhone } from '../utils/userPhone'
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
import { toE164 } from '../utils/phone'
import { getUserRole } from '../utils/role'
import { getUserRole, setUserRole, UserRole } from '../utils/role'
const OTP_LENGTH = 5
const DEFAULT_RESEND_SECONDS = 120
@ -34,9 +34,13 @@ function formatCountdown(seconds: number) {
export default function LoginOtp() {
const navigate = useNavigate()
const location = useLocation()
const state = location.state as { phone?: string; ttl?: number } | null
const state = location.state as { phone?: string; ttl?: number; role?: UserRole } | null
const phone = state?.phone ?? ''
const loginPath = getUserRole() === 'cafe_owner' ? '/cafe/login' : '/login'
// the role this OTP attempt is for — passed explicitly from Login.tsx rather than
// re-read from the global getUserRole(), which could've drifted (e.g. another tab/flow
// overwriting it in between) and silently route a cafe owner through the customer path
const role = state?.role ?? getUserRole()
const loginPath = role === '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))
@ -109,7 +113,9 @@ export default function LoginOtp() {
const tokens = await verifyOtp(toE164(phone), otp.join(''))
storeAuthTokens(tokens.access_token, tokens.refresh_token)
setUserPhone(phone)
const role = getUserRole()
// re-sync in case the global value drifted since Login.tsx set it — `role` (from
// route state) is the source of truth for this submission, not getUserRole()
setUserRole(role)
if (role === 'cafe_owner') {
const myCafes = await fetchMyCafes().catch(() => [])
@ -120,7 +126,7 @@ export default function LoginOtp() {
navigate('/owner/register')
}
} else {
navigate('/')
navigate('/home')
}
} catch {
setError('کد وارد شده صحیح نیست')

View file

@ -1,6 +1,15 @@
import { useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import InfoIcon from '../components/InfoIcon'
import SuccessIcon from '../components/SuccessIcon'
import { createReservation, ReservationResult } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import { fromE164 } from '../utils/phone'
import { takePendingReservation } from '../utils/pendingReservation'
// the IPG gateway redirects back here as `/payment/success?state=<code>` regardless of
// whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far
const SUCCESS_STATE = '20'
function DetailRow({ label, value }: { label: string; value: string }) {
return (
@ -11,46 +20,60 @@ function DetailRow({ label, value }: { label: string; value: string }) {
)
}
// mock reservation summary — will come from the payment gateway's return params once integrated
const mockReservation = {
count: '۳ نفر',
fullName: 'هستی حصادی',
phone: '۰۹۱۲۳۴۵۶۷۸۹',
cafeName: 'بی‌بای',
matchName: 'برزیل-مراکش',
dateTime: 'پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰',
reservationCode: 'HH-۲۸۴۷',
}
export default function PaymentSuccess() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isSuccess = searchParams.get('state') === SUCCESS_STATE
const [reservation, setReservation] = useState<ReservationResult | null>(null)
const [error, setError] = useState('')
useEffect(() => {
if (!isSuccess) return
const pending = takePendingReservation()
if (!pending) {
Promise.resolve().then(() => setError('اطلاعات رزرو یافت نشد.'))
return
}
createReservation(pending)
.then(setReservation)
.catch(() => setError('ثبت رزرو با خطا مواجه شد.'))
}, [isSuccess])
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm rounded-3xl bg-white p-6 shadow-lg">
<SuccessIcon />
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2">
<DetailRow label="تعداد" value={mockReservation.count} />
<DetailRow label="به نام" value={mockReservation.fullName} />
<DetailRow label="شماره تماس" value={mockReservation.phone} />
<DetailRow label="کافه" value={mockReservation.cafeName} />
<DetailRow label="بازی" value={mockReservation.matchName} />
<DetailRow label="تاریخ و ساعت" value={mockReservation.dateTime} />
<DetailRow label="کد رزرو" value={mockReservation.reservationCode} />
</div>
{isSuccess ? (
reservation ? (
<>
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
<DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
<DetailRow label="کد رزرو" value={reservation.reserve_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-600">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود.
</p>
</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-600">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود.
</p>
</div>
</>
) : error ? (
<p className="mt-6 text-center text-gray-900">{error}</p>
) : (
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
)
) : (
<p className="mt-6 text-center text-gray-900">پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.</p>
)}
<button
type="button"
onClick={() => navigate('/')}
onClick={() => navigate('/home')}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
صفحه اصلی

View file

@ -1,18 +1,12 @@
import axios from 'axios'
import { getAccessToken } from '../utils/authToken'
import { attachAuthInterceptors } from '../utils/authInterceptor'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/',
headers: { 'Content-Type': 'application/json' }
})
api.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
attachAuthInterceptors(api)
export default api

View file

@ -66,3 +66,23 @@ export async function verifyOtp(phoneNumber: string, otpCode: string): Promise<T
})
return data
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const clientId = import.meta.env.VITE_AUTH_CLIENT_ID
const clientSecret = import.meta.env.VITE_AUTH_CLIENT_SECRET
const credentials = btoa(`${clientId}:${clientSecret}`)
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: SCOPE,
})
const { data } = await authApi.post<TokenResponse>('/oauth2/token/', body, {
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
})
return data
}

View file

@ -123,7 +123,7 @@ export async function fetchMyCafes(): Promise<MyCafe[]> {
}
// the public cafe-listing response matches MyCafe plus an embedded schedule for
// whichever event_uuid was queried — NOT present on /campaign/cafes/my/, don't merge these back into one type.
// whichever event was queried — NOT present on /campaign/cafes/my/, don't merge these back into one type.
// confirmed via a real response that this field can be entirely absent (not even an
// empty array) when the cafe has no schedule for the queried event — always guard with `?.`
export interface Cafe extends MyCafe {
@ -132,7 +132,8 @@ export interface Cafe extends MyCafe {
export interface FetchCafesParams {
city_uuid: string
event_uuid: string
// the query param is genuinely `event_id`, not `event_uuid`, despite the value being a uuid
event_id: string
}
export async function fetchCafes(params: FetchCafesParams): Promise<Cafe[]> {
@ -228,3 +229,27 @@ export async function fetchMyReservationByUuid(uuid: string): Promise<MyReservat
const { data } = await api.get<MyReservation>(`/campaign/reservations/${uuid}/`)
return data
}
export interface CreateReservationPayload {
cafe_match_schedule_uuid: string
people_count: number
full_name: string
phone_number: string
}
export interface ReservationResult {
uuid: string
user_uuid: string
cafe_match_schedule_uuid: string
reserve_code: string
people_count: number
full_name: string
phone_number: string
status: string
created_at: string
}
export async function createReservation(payload: CreateReservationPayload): Promise<ReservationResult> {
const { data } = await api.post<ReservationResult>('/campaign/reservations/', payload)
return data
}

View file

@ -1,5 +1,5 @@
import axios from 'axios'
import { getAccessToken } from '../utils/authToken'
import { attachAuthInterceptors } from '../utils/authInterceptor'
const IPG_BASE_URL = import.meta.env.VITE_IPG_BASE_URL || 'https://ipg-staging.addwin.ir'
@ -8,13 +8,7 @@ const ipgApi = axios.create({
headers: { 'Content-Type': 'application/json' },
})
ipgApi.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
attachAuthInterceptors(ipgApi)
export interface CreateChargePayload {
amount: number

View file

@ -1,18 +1,12 @@
import axios from 'axios'
import { getAccessToken } from '../utils/authToken'
import { attachAuthInterceptors } from '../utils/authInterceptor'
const walletApi = axios.create({
baseURL: import.meta.env.VITE_WALLET_BASE_URL || '/',
headers: { 'Content-Type': 'application/json' },
})
walletApi.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
attachAuthInterceptors(walletApi)
export interface WalletBalance {
balance: number

View file

@ -0,0 +1,61 @@
import { AxiosInstance, AxiosRequestConfig } from 'axios'
import { refreshAccessToken } from '../services/authApi'
import { getUserRole } from './role'
import { clearAuthTokens, getAccessToken, getRefreshToken, storeAuthTokens } from './authToken'
interface RetriableRequestConfig extends AxiosRequestConfig {
_retried?: boolean
}
// shared across every axios instance — concurrent 401s while a refresh is
// already in flight must wait on the same promise, not trigger separate refreshes
let refreshPromise: Promise<string | null> | null = null
function refreshAndStoreAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken()
if (!refreshToken) return Promise.resolve(null)
if (!refreshPromise) {
refreshPromise = refreshAccessToken(refreshToken)
.then((tokens) => {
storeAuthTokens(tokens.access_token, tokens.refresh_token)
return tokens.access_token
})
.catch(() => {
clearAuthTokens()
return null
})
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
export function attachAuthInterceptors(instance: AxiosInstance) {
instance.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
instance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as RetriableRequestConfig | undefined
if (error.response?.status === 401 && originalRequest && !originalRequest._retried) {
originalRequest._retried = true
const newAccessToken = await refreshAndStoreAccessToken()
if (newAccessToken) {
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${newAccessToken}` }
return instance(originalRequest)
}
// refresh token is gone/invalid — nothing left to do but send the user back to login
window.location.href = getUserRole() === 'cafe_owner' ? '/cafe/login' : '/login'
}
return Promise.reject(error)
},
)
}

View file

@ -9,3 +9,12 @@ export function storeAuthTokens(accessToken: string, refreshToken: string) {
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY)
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function clearAuthTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
}

View file

@ -0,0 +1,21 @@
import { CreateReservationPayload } from '../services/campaignApi'
const PENDING_RESERVATION_KEY = 'pending_reservation'
// sessionStorage, not localStorage — this is scoped to a single checkout attempt,
// not something that should persist across browser sessions like role/auth do
export function setPendingReservation(payload: CreateReservationPayload) {
sessionStorage.setItem(PENDING_RESERVATION_KEY, JSON.stringify(payload))
}
// read-once: clears on read so a page refresh on /payment/success never re-submits
export function takePendingReservation(): CreateReservationPayload | null {
const raw = sessionStorage.getItem(PENDING_RESERVATION_KEY)
if (!raw) return null
sessionStorage.removeItem(PENDING_RESERVATION_KEY)
try {
return JSON.parse(raw)
} catch {
return null
}
}