diff --git a/.env.example b/.env.example index 988ada0..2db7528 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx index 9c1630b..6855011 100644 --- a/src/components/ReservationModal.tsx +++ b/src/components/ReservationModal.tsx @@ -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( <>
@@ -127,10 +156,9 @@ export default function ReservationModal({ - {/* readOnly mock for now — once OTP auth ships, this comes from the auth context */}
+ {error &&

{error}

}
diff --git a/src/data/mockMyReservations.ts b/src/data/mockMyReservations.ts deleted file mode 100644 index d1b5a63..0000000 --- a/src/data/mockMyReservations.ts +++ /dev/null @@ -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: 'بی‌بای', - }, -] diff --git a/src/pages/LoginOtp.tsx b/src/pages/LoginOtp.tsx index c293b1d..1d5d4e3 100644 --- a/src/pages/LoginOtp.tsx +++ b/src/pages/LoginOtp.tsx @@ -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') { diff --git a/src/pages/MyReservations.tsx b/src/pages/MyReservations.tsx index 75ae8ab..643b2e0 100644 --- a/src/pages/MyReservations.tsx +++ b/src/pages/MyReservations.tsx @@ -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 ( @@ -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]" > -

{reservation.matchLabel}

-

{reservation.dateTimeLabel}

+

{reservation.event_title}

+

{formatReservationDateTime(reservation.event_datetime)}

- {formatPersianNumber(reservation.count)} نفر + {formatPersianNumber(reservation.ticket_count)} نفر - {reservation.code} + {reservation.reservation_code}
@@ -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(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(

جزئیات رزرو

-
- - - - - - - -
+ {reservation && ( +
+ + + + + + + +
+ )}
@@ -90,7 +110,14 @@ function ReservationDetailModal({ reservation, onClose }: { reservation: MyReser export default function MyReservations() { const navigate = useNavigate() - const [selectedReservation, setSelectedReservation] = useState(null) + const [reservations, setReservations] = useState([]) + const [selectedUuid, setSelectedUuid] = useState(null) + + useEffect(() => { + fetchMyReservations() + .then(setReservations) + .catch(() => {}) + }, []) return (
@@ -102,13 +129,13 @@ export default function MyReservations() {
- {mockMyReservations.map((reservation) => ( - setSelectedReservation(reservation)} /> + {reservations.map((reservation) => ( + setSelectedUuid(reservation.uuid)} /> ))}
- {selectedReservation && ( - setSelectedReservation(null)} /> + {selectedUuid && ( + setSelectedUuid(null)} /> )}
) diff --git a/src/services/campaignApi.ts b/src/services/campaignApi.ts index 874abc0..1de955a 100644 --- a/src/services/campaignApi.ts +++ b/src/services/campaignApi.ts @@ -206,3 +206,25 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string const { data } = await api.get(`/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 { + const { data } = await api.get('/campaign/reservations/') + return data +} + +export async function fetchMyReservationByUuid(uuid: string): Promise { + const { data } = await api.get(`/campaign/reservations/${uuid}/`) + return data +} diff --git a/src/services/ipgApi.ts b/src/services/ipgApi.ts new file mode 100644 index 0000000..01514a5 --- /dev/null +++ b/src/services/ipgApi.ts @@ -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 { + const { data } = await ipgApi.post('/api/charge_create/', payload) + return data +} + +export function chargePageUrl(uuid: string): string { + return `${IPG_BASE_URL}/page/charge/${uuid}/` +} + +export default ipgApi diff --git a/src/services/walletApi.ts b/src/services/walletApi.ts new file mode 100644 index 0000000..b0fe6a4 --- /dev/null +++ b/src/services/walletApi.ts @@ -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 { + const { data } = await walletApi.get('/wallet/api/wallets/', { + params: { wallet: walletUuids.join(',') }, + }) + return data.results +} + +export default walletApi diff --git a/src/utils/userPhone.ts b/src/utils/userPhone.ts new file mode 100644 index 0000000..b5b8b58 --- /dev/null +++ b/src/utils/userPhone.ts @@ -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) +}