feat: reservation feature connected to api

This commit is contained in:
Afra ‌ 2026-06-24 20:26:55 +03:30
parent 5bad637b1f
commit 02dc37ab52
9 changed files with 193 additions and 60 deletions

View file

@ -1,5 +1,7 @@
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
VITE_WALLET_BASE_URL=https://wallet-staging.gooyal.ir
VITE_IPG_BASE_URL=https://ipg-staging.addwin.ir
VITE_AUTH_CLIENT_ID=
VITE_AUTH_CLIENT_SECRET=
VITE_APP_BASE_URL=

View file

@ -1,7 +1,10 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Cafe } from "../services/campaignApi";
import { chargePageUrl, createCharge } from "../services/ipgApi";
import { fetchWalletBalances } from "../services/walletApi";
import { formatPersianNumber } from "../utils/formatNumber";
import { getUserPhone } from "../utils/userPhone";
import InfoIcon from "./InfoIcon";
interface ReservationModalProps {
@ -11,9 +14,9 @@ interface ReservationModalProps {
ticketPrice: number;
}
const mockPhone = "۰۹۱۲۳۴۵۶۷۸۹";
// mock wallet balance — will come from the user's wallet once auth is implemented
const walletBalance = 10000;
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
// these are fixed test wallet ids given directly for now, replace once that exists
const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
function MinusIcon({ className = "" }: { className?: string }) {
return (
@ -80,6 +83,9 @@ export default function ReservationModal({
}: ReservationModalProps) {
const [fullName, setFullName] = useState("");
const [count, setCount] = useState(1);
const [walletBalance, setWalletBalance] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!isOpen) return;
@ -91,11 +97,34 @@ export default function ReservationModal({
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
fetchWalletBalances(WALLET_UUIDS)
.then((results) => setWalletBalance(results[0]?.balance ?? 0))
.catch(() => {});
}, [isOpen]);
if (!isOpen) return null;
const reservationAmount = ticketPrice * count;
const payableAmount = Math.max(0, reservationAmount - walletBalance);
const handlePay = async () => {
setError("");
setIsSubmitting(true);
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);
} catch {
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
setIsSubmitting(false);
}
};
return createPortal(
<>
<div className="fixed inset-0 z-1100 bg-black/70" onClick={onClose} />
@ -127,10 +156,9 @@ export default function ReservationModal({
<label className="mb-2 block text-right font-semibold text-gray-900">
شماره تماس
</label>
{/* readOnly mock for now — once OTP auth ships, this comes from the auth context */}
<input
type="text"
value={mockPhone}
value={getUserPhone() ?? ""}
readOnly
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]"
@ -199,11 +227,14 @@ export default function ReservationModal({
</div>
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
{error && <p className="mb-2 text-center text-sm text-red-500">{error}</p>}
<button
type="button"
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
disabled={isSubmitting}
onClick={handlePay}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
پرداخت
{isSubmitting ? "در حال انتقال به درگاه..." : "پرداخت"}
</button>
</div>
</div>

View file

@ -1,33 +0,0 @@
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

@ -4,6 +4,7 @@ import { requestOtp, verifyOtp } from '../services/authApi'
import { fetchMyCafes } from '../services/campaignApi'
import { storeAuthTokens } from '../utils/authToken'
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'
@ -107,6 +108,7 @@ export default function LoginOtp() {
try {
const tokens = await verifyOtp(toE164(phone), otp.join(''))
storeAuthTokens(tokens.access_token, tokens.refresh_token)
setUserPhone(phone)
const role = getUserRole()
if (role === 'cafe_owner') {

View file

@ -3,9 +3,19 @@ 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 { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
function formatReservationDateTime(iso: string): string {
return new Intl.DateTimeFormat('fa-IR', {
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso))
}
function ChevronIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
@ -21,14 +31,14 @@ function MyReservationCard({ reservation, onClick }: { reservation: MyReservatio
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>
<h3 className="font-medium text-xl text-gray-900">{reservation.event_title}</h3>
<p className="text-lg text-gray-500">{formatReservationDateTime(reservation.event_datetime)}</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)} نفر
{formatPersianNumber(reservation.ticket_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}
{reservation.reservation_code}
</span>
</div>
</button>
@ -44,7 +54,9 @@ function DetailRow({ label, value }: { label: string; value: string }) {
)
}
function ReservationDetailModal({ reservation, onClose }: { reservation: MyReservation; onClose: () => void }) {
function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) {
const [reservation, setReservation] = useState<MyReservation | null>(null)
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
@ -53,6 +65,12 @@ function ReservationDetailModal({ reservation, onClose }: { reservation: MyReser
}
}, [])
useEffect(() => {
fetchMyReservationByUuid(uuid)
.then(setReservation)
.catch(() => {})
}, [uuid])
return createPortal(
<div className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
<div
@ -62,15 +80,17 @@ function ReservationDetailModal({ reservation, onClose }: { reservation: MyReser
<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>
{reservation && (
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
<DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={reservation.phone_number} />
<DetailRow label="کافه" value={reservation.cafe_name} />
<DetailRow label="بازی" value={reservation.event_title} />
<DetailRow label="تاریخ و ساعت" value={formatReservationDateTime(reservation.event_datetime)} />
<DetailRow label="کد رزرو" value={reservation.reservation_code} />
</div>
)}
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
@ -90,7 +110,14 @@ function ReservationDetailModal({ reservation, onClose }: { reservation: MyReser
export default function MyReservations() {
const navigate = useNavigate()
const [selectedReservation, setSelectedReservation] = useState<MyReservation | null>(null)
const [reservations, setReservations] = useState<MyReservation[]>([])
const [selectedUuid, setSelectedUuid] = useState<string | null>(null)
useEffect(() => {
fetchMyReservations()
.then(setReservations)
.catch(() => {})
}, [])
return (
<div className="min-h-screen bg-white pb-24">
@ -102,13 +129,13 @@ export default function MyReservations() {
</button>
<div className="mt-4 flex flex-col gap-3 px-4">
{mockMyReservations.map((reservation) => (
<MyReservationCard key={reservation.id} reservation={reservation} onClick={() => setSelectedReservation(reservation)} />
{reservations.map((reservation) => (
<MyReservationCard key={reservation.uuid} reservation={reservation} onClick={() => setSelectedUuid(reservation.uuid)} />
))}
</div>
{selectedReservation && (
<ReservationDetailModal reservation={selectedReservation} onClose={() => setSelectedReservation(null)} />
{selectedUuid && (
<ReservationDetailModal uuid={selectedUuid} onClose={() => setSelectedUuid(null)} />
)}
</div>
)

View file

@ -206,3 +206,25 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string
const { data } = await api.get<EventReservation[]>(`/campaign/cafes/${cafeUuid}/events/${eventUuid}/reservations/`)
return data
}
export interface MyReservation {
uuid: string
reservation_code: string
ticket_count: number
full_name: string
phone_number: string
cafe_name: string
event_title: string
event_datetime: string
status: string
}
export async function fetchMyReservations(): Promise<MyReservation[]> {
const { data } = await api.get<MyReservation[]>('/campaign/reservations/')
return data
}
export async function fetchMyReservationByUuid(uuid: string): Promise<MyReservation> {
const { data } = await api.get<MyReservation>(`/campaign/reservations/${uuid}/`)
return data
}

37
src/services/ipgApi.ts Normal file
View file

@ -0,0 +1,37 @@
import axios from 'axios'
import { getAccessToken } from '../utils/authToken'
const IPG_BASE_URL = import.meta.env.VITE_IPG_BASE_URL || 'https://ipg-staging.addwin.ir'
const ipgApi = axios.create({
baseURL: IPG_BASE_URL,
headers: { 'Content-Type': 'application/json' },
})
ipgApi.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export interface CreateChargePayload {
amount: number
callback_url: string
}
export interface ChargeResponse {
uuid: string
}
export async function createCharge(payload: CreateChargePayload): Promise<ChargeResponse> {
const { data } = await ipgApi.post<ChargeResponse>('/api/charge_create/', payload)
return data
}
export function chargePageUrl(uuid: string): string {
return `${IPG_BASE_URL}/page/charge/${uuid}/`
}
export default ipgApi

36
src/services/walletApi.ts Normal file
View file

@ -0,0 +1,36 @@
import axios from 'axios'
import { getAccessToken } from '../utils/authToken'
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
})
export interface WalletBalance {
balance: number
wallet: string
}
interface WalletListResponse {
count: number
next: string | null
previous: string | null
results: WalletBalance[]
}
export async function fetchWalletBalances(walletUuids: string[]): Promise<WalletBalance[]> {
const { data } = await walletApi.get<WalletListResponse>('/wallet/api/wallets/', {
params: { wallet: walletUuids.join(',') },
})
return data.results
}
export default walletApi

9
src/utils/userPhone.ts Normal file
View file

@ -0,0 +1,9 @@
const USER_PHONE_STORAGE_KEY = 'user_phone'
export function setUserPhone(phone: string) {
localStorage.setItem(USER_PHONE_STORAGE_KEY, phone)
}
export function getUserPhone(): string | null {
return localStorage.getItem(USER_PHONE_STORAGE_KEY)
}