perf: cfinal touches
This commit is contained in:
parent
02dc37ab52
commit
406fba535d
16 changed files with 299 additions and 113 deletions
|
|
@ -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>} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -473,7 +473,7 @@ export default function CafeOwnerRegister() {
|
|||
onChange={setAddress}
|
||||
/>
|
||||
<FormField
|
||||
label="شماره تماس کافه (اختیاری)"
|
||||
label="شماره تماس کافه"
|
||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
value={cafePhone}
|
||||
onChange={setCafePhone}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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('کد وارد شده صحیح نیست')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
صفحه اصلی
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
src/utils/authInterceptor.ts
Normal file
61
src/utils/authInterceptor.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
21
src/utils/pendingReservation.ts
Normal file
21
src/utils/pendingReservation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue