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 { 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 Home from './pages/Home'
import CafeDetail from './pages/CafeDetail' import CafeDetail from './pages/CafeDetail'
import PaymentSuccess from './pages/PaymentSuccess' import PaymentSuccess from './pages/PaymentSuccess'
@ -29,7 +29,8 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<ScrollToTop /> <ScrollToTop />
<Routes> <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="/cafe/:id" element={<RequireRole role="customer"><CafeDetail /></RequireRole>} />
<Route path="/payment/success" element={<RequireRole role="customer"><PaymentSuccess /></RequireRole>} /> <Route path="/payment/success" element={<RequireRole role="customer"><PaymentSuccess /></RequireRole>} />
<Route path="/reservations" element={<RequireRole role="customer"><MyReservations /></RequireRole>} /> <Route path="/reservations" element={<RequireRole role="customer"><MyReservations /></RequireRole>} />

View file

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

View file

@ -354,7 +354,8 @@ export default function CafeDetail() {
<button <button
type="button" type="button"
onClick={() => setIsReservationOpen(true)} 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> </button>
@ -365,6 +366,7 @@ export default function CafeDetail() {
onClose={() => setIsReservationOpen(false)} onClose={() => setIsReservationOpen(false)}
cafe={cafe} cafe={cafe}
ticketPrice={schedule ? Number(schedule.ticket_price) : 0} ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
/> />
</div> </div>
); );

View file

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

View file

@ -32,34 +32,35 @@ function ChevronDownIcon({ className = '' }: { className?: string }) {
) )
} }
function MapIcon() { // MapIcon/ListIcon — only used by the disabled نقشه/لیست toggle above, commented out together with it
return ( // function MapIcon() {
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> // return (
<path // <svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
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" // <path
stroke="currentColor" // 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"
strokeWidth={1.25} // stroke="currentColor"
strokeLinecap="round" // strokeWidth={1.25}
strokeLinejoin="round" // 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" /> // <path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg> // <path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
) // </svg>
} // )
// }
function ListIcon() { //
return ( // function ListIcon() {
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> // return (
<path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" /> // <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 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" /> // <path d="M7.33398 13H14.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="M7.33398 8.33398H14.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="M7.33398 3.66602H14.0007" 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 3.66732L2.66667 4.33398L4.66667 2.33398" 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" /> // <path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg> // <path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
) // </svg>
} // )
// }
function MapPlaceholder() { function MapPlaceholder() {
return ( return (
@ -213,7 +214,9 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
} }
export default function Home() { 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 [cities, setCities] = useState<City[]>([])
const [selectedCityUuid, setSelectedCityUuid] = useState('') const [selectedCityUuid, setSelectedCityUuid] = useState('')
const [selectedMatchUuid, setSelectedMatchUuid] = useState('') const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
@ -234,9 +237,9 @@ export default function Home() {
}, []) }, [])
useEffect(() => { 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 if (!effectiveCityUuid || !effectiveMatchUuid) return
fetchCafes({ city_uuid: effectiveCityUuid, event_uuid: effectiveMatchUuid }) fetchCafes({ city_uuid: effectiveCityUuid, event_id: effectiveMatchUuid })
.then(setCafes) .then(setCafes)
.catch(() => {}) .catch(() => {})
}, [effectiveCityUuid, effectiveMatchUuid]) }, [effectiveCityUuid, effectiveMatchUuid])
@ -258,6 +261,7 @@ export default function Home() {
<div className="mt-6 flex items-center justify-between px-4"> <div className="mt-6 flex items-center justify-between px-4">
<h2 className="font-semibold text-gray-900">کافه‌ها</h2> <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"> <div className="flex rounded-full bg-gray-100 p-1">
<button <button
type="button" type="button"
@ -280,18 +284,16 @@ export default function Home() {
لیست لیست
</button> </button>
</div> </div>
*/}
</div> </div>
<div className="mt-3 px-4"> <div className="mt-3 px-4">
{view === 'list' ? ( {/* always the list view for now — restore the `view === 'list' ? ... : <MapPlaceholder />` ternary when the map toggle above is restored */}
<div className="space-y-3"> <div className="space-y-3">
{cafes.map((cafe) => ( {cafes.map((cafe) => (
<CafeCard key={cafe.uuid} cafe={cafe} /> <CafeCard key={cafe.uuid} cafe={cafe} />
))} ))}
</div> </div>
) : (
<MapPlaceholder />
)}
</div> </div>
<button <button

View file

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

View file

@ -7,7 +7,7 @@ import { setCafeUuid } from '../utils/cafe'
import { setUserPhone } from '../utils/userPhone' import { setUserPhone } from '../utils/userPhone'
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber' import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
import { toE164 } from '../utils/phone' import { toE164 } from '../utils/phone'
import { getUserRole } from '../utils/role' import { getUserRole, setUserRole, UserRole } from '../utils/role'
const OTP_LENGTH = 5 const OTP_LENGTH = 5
const DEFAULT_RESEND_SECONDS = 120 const DEFAULT_RESEND_SECONDS = 120
@ -34,9 +34,13 @@ function formatCountdown(seconds: number) {
export default function LoginOtp() { export default function LoginOtp() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() 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 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 [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''))
const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS)) 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('')) const tokens = await verifyOtp(toE164(phone), otp.join(''))
storeAuthTokens(tokens.access_token, tokens.refresh_token) storeAuthTokens(tokens.access_token, tokens.refresh_token)
setUserPhone(phone) 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') { if (role === 'cafe_owner') {
const myCafes = await fetchMyCafes().catch(() => []) const myCafes = await fetchMyCafes().catch(() => [])
@ -120,7 +126,7 @@ export default function LoginOtp() {
navigate('/owner/register') navigate('/owner/register')
} }
} else { } else {
navigate('/') navigate('/home')
} }
} catch { } catch {
setError('کد وارد شده صحیح نیست') 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 InfoIcon from '../components/InfoIcon'
import SuccessIcon from '../components/SuccessIcon' 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 }) { function DetailRow({ label, value }: { label: string; value: string }) {
return ( return (
@ -11,34 +20,39 @@ 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() { export default function PaymentSuccess() {
const navigate = useNavigate() 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 ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4"> <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"> <div className="w-full max-w-sm rounded-3xl bg-white p-6 shadow-lg">
<SuccessIcon /> <SuccessIcon />
{isSuccess ? (
reservation ? (
<>
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2> <h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2"> <div className="mt-2">
<DetailRow label="تعداد" value={mockReservation.count} /> <DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
<DetailRow label="به نام" value={mockReservation.fullName} /> <DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={mockReservation.phone} /> <DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
<DetailRow label="کافه" value={mockReservation.cafeName} /> <DetailRow label="کد رزرو" value={reservation.reserve_code} />
<DetailRow label="بازی" value={mockReservation.matchName} />
<DetailRow label="تاریخ و ساعت" value={mockReservation.dateTime} />
<DetailRow label="کد رزرو" value={mockReservation.reservationCode} />
</div> </div>
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3"> <div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
@ -47,10 +61,19 @@ export default function PaymentSuccess() {
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود. کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود.
</p> </p>
</div> </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 <button
type="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" 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 axios from 'axios'
import { getAccessToken } from '../utils/authToken' import { attachAuthInterceptors } from '../utils/authInterceptor'
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/', baseURL: import.meta.env.VITE_API_BASE_URL || '/',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
api.interceptors.request.use((config) => { attachAuthInterceptors(api)
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default api export default api

View file

@ -66,3 +66,23 @@ export async function verifyOtp(phoneNumber: string, otpCode: string): Promise<T
}) })
return data 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 // 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 // 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 `?.` // empty array) when the cafe has no schedule for the queried event — always guard with `?.`
export interface Cafe extends MyCafe { export interface Cafe extends MyCafe {
@ -132,7 +132,8 @@ export interface Cafe extends MyCafe {
export interface FetchCafesParams { export interface FetchCafesParams {
city_uuid: string 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[]> { 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}/`) const { data } = await api.get<MyReservation>(`/campaign/reservations/${uuid}/`)
return data 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 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' 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' }, headers: { 'Content-Type': 'application/json' },
}) })
ipgApi.interceptors.request.use((config) => { attachAuthInterceptors(ipgApi)
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export interface CreateChargePayload { export interface CreateChargePayload {
amount: number amount: number

View file

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