feature/WNPT-301 #6

Merged
afra.khalaj merged 6 commits from feature/WNPT-301 into develop 2026-07-25 04:03:40 -04:00
4 changed files with 446 additions and 303 deletions
Showing only changes of commit 9382cdb666 - Show all commits

View file

@ -5,6 +5,8 @@ import { requestOtp } from "../services/authApi";
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber"; import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
import { toE164 } from "../utils/phone"; import { toE164 } from "../utils/phone";
import { setUserRole } from "../utils/role"; import { setUserRole } from "../utils/role";
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
interface ApiErrorResponse { interface ApiErrorResponse {
status_message?: string; status_message?: string;
@ -12,7 +14,8 @@ interface ApiErrorResponse {
} }
function extractApiError(error: unknown): string { function extractApiError(error: unknown): string {
const data = (error as { response?: { data?: ApiErrorResponse } })?.response?.data; const data = (error as { response?: { data?: ApiErrorResponse } })?.response
?.data;
if (data?.status_message) return data.status_message; if (data?.status_message) return data.status_message;
return "ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید."; return "ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
} }
@ -20,7 +23,8 @@ function extractApiError(error: unknown): string {
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const initialPhone = (location.state as { phone?: string } | null)?.phone ?? ""; const initialPhone =
(location.state as { phone?: string } | null)?.phone ?? "";
const isOwnerFlow = location.pathname.startsWith("/cafe/login"); const isOwnerFlow = location.pathname.startsWith("/cafe/login");
useEffect(() => { useEffect(() => {
@ -43,7 +47,9 @@ 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, role: isOwnerFlow ? "cafe_owner" : "customer" } }); navigate("/login/otp", {
state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" },
});
} catch (err) { } catch (err) {
setError(extractApiError(err)); setError(extractApiError(err));
} finally { } finally {
@ -52,62 +58,91 @@ export default function Login() {
}; };
return ( return (
<form <div className="min-h-screen relative overflow-hidden">
onSubmit={(e) => { <img
e.preventDefault(); src={!isOwnerFlow ? GuestBanner : HostBanner}
if (canContinue) handleContinue(); alt="بنر"
}} className="w-full"
className="flex min-h-screen flex-col justify-center px-6"
>
<h1 className="text-right text-2xl font-bold text-gray-900">
ورود به وینسو
</h1>
<p className="mt-2 text-right text-gray-500">
لطفا شماره تماس خود را وارد کنید.
</p>
<input
type="text"
dir="ltr"
inputMode="numeric"
enterKeyHint="go"
value={phone}
onChange={(e) => {
const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11);
setPhone(toPersianDigits(digits));
setError("");
}}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]"
/> />
{isOwnerFlow && (
{error && <p className="mt-2 text-right text-sm text-red-500">{error}</p>} <div className="mt-8 flex items-center justify-end gap-2 leading-relaxed rounded-2xl bg-[#ECF2F8] px-4 py-3 mx-4">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<button <p className="text-right text-sm text-gray-600">
type="submit" این شماره موبایل به‌عنوان حساب نماینده کافه در اپلیکیشن وینسو ثبت
disabled={!canContinue} می‌شود و پرداخت‌ها به کیف پول آن واریز خواهد شد.
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50" <br />
توجه داشته باشید برای برداشت شماره موبایل، کد ملی و کارت بانکی باید
متعلق به یک نفر باشند.
</p>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (canContinue) handleContinue();
}}
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
> >
{isSubmitting ? "در حال ارسال..." : "ادامه"} <h1 className="text-right text-2xl font-bold text-gray-900">
</button> ورود به وینسو
</h1>
<div className="mt-4 flex items-center justify-center gap-2"> <p className="mt-2 text-right text-gray-500">
<button لطفا شماره تماس خود را وارد کنید.
type="button"
onClick={() => setAcceptedTerms((v) => !v)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
}`}
>
{acceptedTerms && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
<p className="text-sm text-gray-600">
<a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
قوانین و شرایط
</a>{" "}
را می‌پذیرم.
</p> </p>
</div>
</form> <input
id="login-phone-number"
type="text"
dir="ltr"
inputMode="numeric"
enterKeyHint="go"
value={phone}
onChange={(e) => {
const digits = toEnglishDigits(e.target.value)
.replace(/\D/g, "")
.slice(0, 11);
setPhone(toPersianDigits(digits));
setError("");
}}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]"
/>
{error && (
<p className="mt-2 text-right text-sm text-red-500">{error}</p>
)}
<button
type="submit"
disabled={!canContinue}
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال ارسال..." : "ادامه"}
</button>
<div className="mt-4 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAcceptedTerms((v) => !v)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
}`}
>
{acceptedTerms && (
<Icon name="check" className="h-3 w-3 text-white" />
)}
</button>
<p className="text-sm text-gray-600">
<a
href="https://winsoo.ir/terms/"
className="text-[#223D5D] underline"
>
قوانین و شرایط
</a>{" "}
را می‌پذیرم.
</p>
</div>
</form>
</div>
); );
} }

View file

@ -1,236 +1,272 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from "react-router-dom";
import Icon from '../components/Icon' import Icon from "../components/Icon";
import { requestOtp, verifyOtp } from '../services/authApi' import { requestOtp, verifyOtp } from "../services/authApi";
import { fetchMyCafes } from '../services/campaignApi' import { fetchMyCafes } from "../services/campaignApi";
import { storeAuthTokens } from '../utils/authToken' import { storeAuthTokens } from "../utils/authToken";
import { clearCafeUuid, setCafeUuid } from '../utils/cafe' import { clearCafeUuid, 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, setUserRole, UserRole } from '../utils/role' import { getUserRole, setUserRole, UserRole } from "../utils/role";
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
const OTP_LENGTH = 5 const OTP_LENGTH = 5;
const DEFAULT_RESEND_SECONDS = 120 const DEFAULT_RESEND_SECONDS = 120;
// not yet in TS's DOM lib — minimal shape for the WebOTP API result // not yet in TS's DOM lib — minimal shape for the WebOTP API result
interface OTPCredential extends Credential { interface OTPCredential extends Credential {
code: string code: string;
} }
function formatCountdown(seconds: number) { function formatCountdown(seconds: number) {
const mm = String(Math.floor(seconds / 60)).padStart(2, '0') const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, '0') const ss = String(seconds % 60).padStart(2, "0");
return toPersianDigits(`${mm}:${ss}`) return toPersianDigits(`${mm}:${ss}`);
} }
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; role?: UserRole } | null const state = location.state as {
const phone = state?.phone ?? '' phone?: string;
ttl?: number;
role?: UserRole;
} | null;
const phone = state?.phone ?? "";
// the role this OTP attempt is for — passed explicitly from Login.tsx rather than // 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 // 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 // overwriting it in between) and silently route a cafe owner through the customer path
const role = state?.role ?? getUserRole() const role = state?.role ?? getUserRole();
const loginPath = role === 'cafe_owner' ? '/cafe/login' : '/login' 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(
const [resendCycle, setResendCycle] = useState(0) Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS),
const [error, setError] = useState('') );
const [isSubmitting, setIsSubmitting] = useState(false) const [resendCycle, setResendCycle] = useState(0);
const [isResending, setIsResending] = useState(false) const [error, setError] = useState("");
const inputRefs = useRef<(HTMLInputElement | null)[]>([]) const [isSubmitting, setIsSubmitting] = useState(false);
const [isResending, setIsResending] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// fills consecutive boxes starting at startIndex — used by both the WebOTP // fills consecutive boxes starting at startIndex — used by both the WebOTP
// listener below and a multi-digit autofill landing in a single box (e.g. iOS's // listener below and a multi-digit autofill landing in a single box (e.g. iOS's
// autocomplete="one-time-code" suggestion pastes the whole code into one input) // autocomplete="one-time-code" suggestion pastes the whole code into one input)
const fillOtp = (startIndex: number, rawValue: string) => { const fillOtp = (startIndex: number, rawValue: string) => {
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex) const digits = toEnglishDigits(rawValue)
if (!digits) return .replace(/[^0-9]/g, "")
.slice(0, OTP_LENGTH - startIndex);
if (!digits) return;
setOtp((prev) => { setOtp((prev) => {
const next = [...prev] const next = [...prev];
digits.split('').forEach((d, i) => { digits.split("").forEach((d, i) => {
next[startIndex + i] = d next[startIndex + i] = d;
}) });
return next return next;
}) });
setError('') setError("");
inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus() inputRefs.current[
} Math.min(startIndex + digits.length, OTP_LENGTH) - 1
]?.focus();
};
useEffect(() => { useEffect(() => {
if (!phone) { if (!phone) {
navigate(loginPath, { replace: true }) navigate(loginPath, { replace: true });
} }
}, [phone, loginPath, navigate]) }, [phone, loginPath, navigate]);
useEffect(() => { useEffect(() => {
inputRefs.current[0]?.focus() inputRefs.current[0]?.focus();
}, []) }, []);
// Android/Chrome: WebOTP lets the browser read the code straight off an incoming // Android/Chrome: WebOTP lets the browser read the code straight off an incoming
// SMS without the user opening the messaging app — only fires if the SMS body ends // SMS without the user opening the messaging app — only fires if the SMS body ends
// with a line like "@<this site's domain> #<code>", which the backend's SMS // with a line like "@<this site's domain> #<code>", which the backend's SMS
// template doesn't currently send, so this is a no-op until that's added. // template doesn't currently send, so this is a no-op until that's added.
useEffect(() => { useEffect(() => {
if (!('OTPCredential' in window)) return if (!("OTPCredential" in window)) return;
const abortController = new AbortController() const abortController = new AbortController();
navigator.credentials navigator.credentials
.get({ otp: { transport: ['sms'] }, signal: abortController.signal } as unknown as CredentialRequestOptions) .get({
otp: { transport: ["sms"] },
signal: abortController.signal,
} as unknown as CredentialRequestOptions)
.then((credential) => { .then((credential) => {
const code = (credential as OTPCredential | null)?.code const code = (credential as OTPCredential | null)?.code;
if (code) fillOtp(0, code) if (code) fillOtp(0, code);
}) })
.catch(() => {}) .catch(() => {});
return () => abortController.abort() return () => abortController.abort();
}, []) }, []);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
setSecondsLeft((s) => { setSecondsLeft((s) => {
if (s <= 1) { if (s <= 1) {
clearInterval(timer) clearInterval(timer);
return 0 return 0;
} }
return s - 1 return s - 1;
}) });
}, 1000) }, 1000);
return () => clearInterval(timer) return () => clearInterval(timer);
}, [resendCycle]) }, [resendCycle]);
const handleOtpChange = (index: number, rawValue: string) => { const handleOtpChange = (index: number, rawValue: string) => {
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '') const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, "");
// a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion // a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion
if (digits.length > 1) { if (digits.length > 1) {
fillOtp(index, digits) fillOtp(index, digits);
return return;
} }
setOtp((prev) => { setOtp((prev) => {
const next = [...prev] const next = [...prev];
next[index] = digits next[index] = digits;
return next return next;
}) });
setError('') setError("");
if (digits && index < OTP_LENGTH - 1) { if (digits && index < OTP_LENGTH - 1) {
inputRefs.current[index + 1]?.focus() inputRefs.current[index + 1]?.focus();
} }
} };
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => { const handleOtpKeyDown = (
if (e.key === 'Backspace' && !otp[index] && index > 0) { index: number,
inputRefs.current[index - 1]?.focus() e: React.KeyboardEvent<HTMLInputElement>,
) => {
if (e.key === "Backspace" && !otp[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
} }
} };
const handleResend = async () => { const handleResend = async () => {
setError('') setError("");
setIsResending(true) setIsResending(true);
try { try {
const { ttl } = await requestOtp(toE164(phone)) const { ttl } = await requestOtp(toE164(phone));
setOtp(Array(OTP_LENGTH).fill('')) setOtp(Array(OTP_LENGTH).fill(""));
inputRefs.current[0]?.focus() inputRefs.current[0]?.focus();
setSecondsLeft(Math.round(ttl)) setSecondsLeft(Math.round(ttl));
setResendCycle((c) => c + 1) setResendCycle((c) => c + 1);
} catch { } catch {
setError('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.') setError("ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
} finally { } finally {
setIsResending(false) setIsResending(false);
} }
} };
const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting const canSubmit = otp.every((digit) => digit !== "") && !isSubmitting;
const handleSubmit = async () => { const handleSubmit = async () => {
setError('') setError("");
setIsSubmitting(true) setIsSubmitting(true);
try { try {
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);
// re-sync in case the global value drifted since Login.tsx set it — `role` (from // 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() // route state) is the source of truth for this submission, not getUserRole()
setUserRole(role) setUserRole(role);
if (role === 'cafe_owner') { if (role === "cafe_owner") {
const myCafes = await fetchMyCafes().catch(() => []) const myCafes = await fetchMyCafes().catch(() => []);
if (myCafes.length > 0) { if (myCafes.length > 0) {
setCafeUuid(myCafes[0].uuid) setCafeUuid(myCafes[0].uuid);
navigate('/owner/panel') navigate("/owner/panel");
} else { } else {
clearCafeUuid() clearCafeUuid();
navigate('/owner/register') navigate("/owner/register");
} }
} else { } else {
navigate('/home') navigate("/home");
} }
} catch { } catch {
setError('کد وارد شده صحیح نیست') setError("کد وارد شده صحیح نیست");
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false);
} }
} };
return ( return (
<form <div className="min-h-screen relative overflow-hidden">
onSubmit={(e) => { <img
e.preventDefault() src={role === "customer" ? GuestBanner : HostBanner}
if (canSubmit) handleSubmit() alt="بنر"
}} className="w-full"
className="flex min-h-screen flex-col justify-center px-6" />
> <form
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p> onSubmit={(e) => {
<div className="mt-2 flex items-center justify-start gap-2"> e.preventDefault();
<span className="text-[#223D5D]">{phone}</span> if (canSubmit) handleSubmit();
<button type="button" onClick={() => navigate(loginPath, { state: { phone } })} aria-label="ویرایش شماره تماس"> }}
<Icon name="edit" className="h-5 w-5 text-[#223D5D]" /> className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
</button>
</div>
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
{otp.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
enterKeyHint="go"
// no maxLength — the browser would truncate a pasted/autofilled multi-digit
// code to 1 char before onChange ever sees it; handleOtpChange enforces the
// single-digit display for normal typing instead
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(index, e)}
className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none"
/>
))}
</div>
<div className="mt-4 text-center">
{secondsLeft > 0 ? (
<p className="text-gray-500">
ارسال مجدد کد: {formatCountdown(secondsLeft)}
</p>
) : (
<button type="button" onClick={handleResend} disabled={isResending} className="text-[#376195] underline disabled:opacity-50">
{isResending ? 'در حال ارسال...' : 'ارسال مجدد کد'}
</button>
)}
</div>
{error && <p className="mt-2 text-center text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={!canSubmit}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
> >
{isSubmitting ? 'در حال ورود...' : 'ورود'} <p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
</button> <div className="mt-2 flex items-center justify-start gap-2">
</form> <span className="text-[#223D5D]">{phone}</span>
) <button
type="button"
onClick={() => navigate(loginPath, { state: { phone } })}
aria-label="ویرایش شماره تماس"
>
<Icon name="edit" className="h-5 w-5 text-[#223D5D]" />
</button>
</div>
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
{otp.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
enterKeyHint="go"
// no maxLength — the browser would truncate a pasted/autofilled multi-digit
// code to 1 char before onChange ever sees it; handleOtpChange enforces the
// single-digit display for normal typing instead
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(index, e)}
className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none"
/>
))}
</div>
<div className="mt-4 text-center">
{secondsLeft > 0 ? (
<p className="text-gray-500">
ارسال مجدد کد: {formatCountdown(secondsLeft)}
</p>
) : (
<button
type="button"
onClick={handleResend}
disabled={isResending}
className="text-[#376195] underline disabled:opacity-50"
>
{isResending ? "در حال ارسال..." : "ارسال مجدد کد"}
</button>
)}
</div>
{error && (
<p className="mt-2 text-center text-sm text-red-500">{error}</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال ورود..." : "ورود"}
</button>
</form>
</div>
);
} }

View file

@ -1,30 +1,44 @@
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 { useNavigate } from "react-router-dom";
import Icon from '../components/Icon' import Icon from "../components/Icon";
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi' import {
import { formatPersianNumber } from '../utils/formatNumber' fetchMyReservationByUuid,
import GuestBanner from '../assets/images/banners/Guest-banner.jpg' fetchMyReservations,
MyReservation,
} from "../services/campaignApi";
import { formatPersianNumber } from "../utils/formatNumber";
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
function formatReservationDateTime(iso: string): string { function formatReservationDateTime(iso: string): string {
return new Intl.DateTimeFormat('fa-IR', { return new Intl.DateTimeFormat("fa-IR", {
weekday: 'long', weekday: "long",
day: 'numeric', day: "numeric",
month: 'long', month: "long",
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
}).format(new Date(iso)) }).format(new Date(iso));
} }
function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) { function MyReservationCard({
reservation,
onClick,
}: {
reservation: MyReservation;
onClick: () => void;
}) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex w-full flex-col items-start gap-3 rounded-2xl p-6 text-right shadow-[0px_4px_12px_0px_#0000001F]" 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.event_title}</h3> <h3 className="font-medium text-xl text-gray-900">
<p className="text-lg text-gray-500">{formatReservationDateTime(reservation.event_datetime)}</p> {reservation.event_title}
</h3>
<p className="text-lg text-gray-500">
{formatReservationDateTime(reservation.event_datetime)}
</p>
<div className="flex items-center gap-2"> <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"> <span className="font-normal rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
{formatPersianNumber(reservation.ticket_count)} نفر {formatPersianNumber(reservation.ticket_count)} نفر
@ -34,7 +48,7 @@ function MyReservationCard({ reservation, onClick }: { reservation: MyReservatio
</span> </span>
</div> </div>
</button> </button>
) );
} }
function DetailRow({ label, value }: { label: string; value: string }) { function DetailRow({ label, value }: { label: string; value: string }) {
@ -43,43 +57,60 @@ function DetailRow({ label, value }: { label: string; value: string }) {
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
<span className="text-gray-900">{value}</span> <span className="text-gray-900">{value}</span>
</div> </div>
) );
} }
function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) { function ReservationDetailModal({
const [reservation, setReservation] = useState<MyReservation | null>(null) uuid,
onClose,
}: {
uuid: string;
onClose: () => void;
}) {
const [reservation, setReservation] = useState<MyReservation | null>(null);
useEffect(() => { useEffect(() => {
const previousOverflow = document.body.style.overflow const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden' document.body.style.overflow = "hidden";
return () => { return () => {
document.body.style.overflow = previousOverflow document.body.style.overflow = previousOverflow;
} };
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchMyReservationByUuid(uuid) fetchMyReservationByUuid(uuid)
.then(setReservation) .then(setReservation)
.catch(() => {}) .catch(() => {});
}, [uuid]) }, [uuid]);
return createPortal( return createPortal(
<div className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4" onClick={onClose}> <div
className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4"
onClick={onClose}
>
<div <div
className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6" className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Icon name="success" className="mx-auto h-24 w-24" /> <Icon name="success" className="mx-auto h-24 w-24" />
<h2 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2> <h2 className="mt-4 text-right font-semibold text-gray-900">
جزئیات رزرو
</h2>
{reservation && ( {reservation && (
<div className="mt-2"> <div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} /> <DetailRow
label="تعداد"
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
/>
<DetailRow label="به نام" value={reservation.full_name} /> <DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={reservation.phone_number} /> <DetailRow label="شماره تماس" value={reservation.phone_number} />
<DetailRow label="کافه" value={reservation.cafe_name} /> <DetailRow label="کافه" value={reservation.cafe_name} />
<DetailRow label="بازی" value={reservation.event_title} /> <DetailRow label="بازی" value={reservation.event_title} />
<DetailRow label="تاریخ و ساعت" value={formatReservationDateTime(reservation.event_datetime)} /> <DetailRow
label="تاریخ و ساعت"
value={formatReservationDateTime(reservation.event_datetime)}
/>
<DetailRow label="کد رزرو" value={reservation.reservation_code} /> <DetailRow label="کد رزرو" value={reservation.reservation_code} />
</div> </div>
)} )}
@ -87,48 +118,67 @@ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () =
<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">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" /> <Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-500"> <p className="text-right text-sm text-gray-500">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک می‌شود. کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود در
بخش رزروها قابل مشاهده است.
</p> </p>
</div> </div>
<button type="button" onClick={onClose} className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"> <button
type="button"
onClick={onClose}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
بازگشت بازگشت
</button> </button>
</div> </div>
</div>, </div>,
document.body, document.body,
) );
} }
export default function MyReservations() { export default function MyReservations() {
const navigate = useNavigate() const navigate = useNavigate();
const [reservations, setReservations] = useState<MyReservation[]>([]) const [reservations, setReservations] = useState<MyReservation[]>([]);
const [selectedUuid, setSelectedUuid] = useState<string | null>(null) const [selectedUuid, setSelectedUuid] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchMyReservations() fetchMyReservations()
.then(setReservations) .then(setReservations)
.catch(() => {}) .catch(() => {});
}, []) }, []);
return ( return (
<div className="min-h-screen bg-white pb-24"> <div className="min-h-screen bg-white pb-24">
<img src={GuestBanner} alt="" className="w-full" /> <img src={GuestBanner} alt="" className="w-full" />
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4"> <button
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" /> type="button"
onClick={() => navigate(-1)}
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
>
<Icon
name="arrowRight"
className="h-5 w-5 -scale-x-100 text-gray-900"
/>
<span className="text-lg font-bold text-gray-900">رزروهای شما</span> <span className="text-lg font-bold text-gray-900">رزروهای شما</span>
</button> </button>
<div className="mt-4 flex flex-col gap-3 px-4"> <div className="mt-4 flex flex-col gap-3 px-4">
{reservations.map((reservation) => ( {reservations.map((reservation) => (
<MyReservationCard key={reservation.uuid} reservation={reservation} onClick={() => setSelectedUuid(reservation.uuid)} /> <MyReservationCard
key={reservation.uuid}
reservation={reservation}
onClick={() => setSelectedUuid(reservation.uuid)}
/>
))} ))}
</div> </div>
{selectedUuid && ( {selectedUuid && (
<ReservationDetailModal uuid={selectedUuid} onClose={() => setSelectedUuid(null)} /> <ReservationDetailModal
uuid={selectedUuid}
onClose={() => setSelectedUuid(null)}
/>
)} )}
</div> </div>
) );
} }

View file

@ -1,50 +1,52 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from "react-router-dom";
import Icon from '../components/Icon' import Icon from "../components/Icon";
import PaymentFailModal from '../components/PaymentFailModal' import PaymentFailModal from "../components/PaymentFailModal";
import { import {
createReservation, createReservation,
fetchMyReservationByUuid, fetchMyReservationByUuid,
MyReservation, MyReservation,
} from '../services/campaignApi' } from "../services/campaignApi";
import { formatPersianNumber } from '../utils/formatNumber' import { formatPersianNumber } from "../utils/formatNumber";
import { fromE164 } from '../utils/phone' import { fromE164 } from "../utils/phone";
import { takePendingReservation } from '../utils/pendingReservation' import { takePendingReservation } from "../utils/pendingReservation";
import { formatMatchDatetime } from '../utils/iranDate' import { formatMatchDatetime } from "../utils/iranDate";
// the IPG gateway redirects back here as `/payment/success?state=<code>` regardless of // 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 // whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far
const SUCCESS_STATE = '20' const SUCCESS_STATE = "20";
function DetailRow({ label, value }: { label: string; value: string }) { function DetailRow({ label, value }: { label: string; value: string }) {
return ( return (
<div className="flex items-start justify-between gap-4 py-2"> <div className="flex items-start justify-between gap-4 py-2">
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">{value}</span> <span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">
{value}
</span>
<span className="text-right text-gray-500 shrink-0">{label}</span> <span className="text-right text-gray-500 shrink-0">{label}</span>
</div> </div>
) );
} }
export default function PaymentSuccess() { export default function PaymentSuccess() {
const navigate = useNavigate() const navigate = useNavigate();
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams();
const isSuccess = searchParams.get('state') === SUCCESS_STATE const isSuccess = searchParams.get("state") === SUCCESS_STATE;
const [reservation, setReservation] = useState<MyReservation | null>(null) const [reservation, setReservation] = useState<MyReservation | null>(null);
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false);
const isFail = !isSuccess || hasError const isFail = !isSuccess || hasError;
// React 18 StrictMode double-invokes effects; the ref persists across the simulated // React 18 StrictMode double-invokes effects; the ref persists across the simulated
// remount so the second invocation sees it as true and exits early, preventing // remount so the second invocation sees it as true and exits early, preventing
// takePendingReservation() from returning null on the second run and triggering hasError // takePendingReservation() from returning null on the second run and triggering hasError
const reservationAttempted = useRef(false) const reservationAttempted = useRef(false);
useEffect(() => { useEffect(() => {
if (!isSuccess || reservationAttempted.current) return if (!isSuccess || reservationAttempted.current) return;
reservationAttempted.current = true reservationAttempted.current = true;
const pending = takePendingReservation() const pending = takePendingReservation();
if (!pending) { if (!pending) {
setHasError(true) setHasError(true);
return return;
} }
createReservation(pending) createReservation(pending)
.then((result) => .then((result) =>
@ -58,15 +60,15 @@ export default function PaymentSuccess() {
ticket_count: result.people_count, ticket_count: result.people_count,
full_name: result.full_name, full_name: result.full_name,
phone_number: result.phone_number, phone_number: result.phone_number,
cafe_name: '', cafe_name: "",
event_title: '', event_title: "",
event_datetime: '', event_datetime: "",
status: result.status, status: result.status,
}) });
}), }),
) )
.catch(() => setHasError(true)) .catch(() => setHasError(true));
}, [isSuccess]) }, [isSuccess]);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
@ -77,11 +79,19 @@ export default function PaymentSuccess() {
{reservation ? ( {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 divide-y divide-gray-100"> <div className="mt-2 divide-y divide-gray-100">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} /> <DetailRow
label="تعداد"
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
/>
<DetailRow label="به نام" value={reservation.full_name} /> <DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} /> <DetailRow
label="شماره تماس"
value={fromE164(reservation.phone_number)}
/>
{reservation.cafe_name && ( {reservation.cafe_name && (
<DetailRow label="کافه" value={reservation.cafe_name} /> <DetailRow label="کافه" value={reservation.cafe_name} />
)} )}
@ -89,25 +99,37 @@ export default function PaymentSuccess() {
<DetailRow label="بازی" value={reservation.event_title} /> <DetailRow label="بازی" value={reservation.event_title} />
)} )}
{reservation.event_datetime && ( {reservation.event_datetime && (
<DetailRow label="تاریخ و ساعت" value={formatMatchDatetime(reservation.event_datetime)} /> <DetailRow
label="تاریخ و ساعت"
value={formatMatchDatetime(reservation.event_datetime)}
/>
)} )}
<DetailRow label="کد رزرو" value={reservation.reservation_code} /> <DetailRow
label="کد رزرو"
value={reservation.reservation_code}
/>
</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">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" /> <Icon
name="info"
className="h-5 w-5 shrink-0 text-gray-400"
/>
<p className="text-right text-sm text-gray-600"> <p className="text-right text-sm text-gray-600">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود. کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد
ورود در بخش رزروها قابل مشاهده است.
</p> </p>
</div> </div>
</> </>
) : ( ) : (
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p> <p className="mt-6 text-center text-gray-500">
در حال ثبت رزرو...
</p>
)} )}
<button <button
type="button" type="button"
onClick={() => navigate('/home')} 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"
> >
صفحه اصلی صفحه اصلی
@ -116,7 +138,7 @@ export default function PaymentSuccess() {
</div> </div>
)} )}
{isFail && <PaymentFailModal onGoHome={() => navigate('/home')} />} {isFail && <PaymentFailModal onGoHome={() => navigate("/home")} />}
</div> </div>
) );
} }