feat: reservation feature connected to api
This commit is contained in:
parent
5bad637b1f
commit
02dc37ab52
9 changed files with 193 additions and 60 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: 'بیبای',
|
||||
},
|
||||
]
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
37
src/services/ipgApi.ts
Normal 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
36
src/services/walletApi.ts
Normal 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
9
src/utils/userPhone.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue