feat: login banners and info box added
This commit is contained in:
parent
43a6f05045
commit
9382cdb666
4 changed files with 446 additions and 303 deletions
|
|
@ -5,6 +5,8 @@ import { requestOtp } from "../services/authApi";
|
|||
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
|
||||
import { toE164 } from "../utils/phone";
|
||||
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 {
|
||||
status_message?: string;
|
||||
|
|
@ -12,7 +14,8 @@ interface ApiErrorResponse {
|
|||
}
|
||||
|
||||
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;
|
||||
return "ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
|
||||
}
|
||||
|
|
@ -20,7 +23,8 @@ function extractApiError(error: unknown): string {
|
|||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
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");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -43,7 +47,9 @@ export default function Login() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
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) {
|
||||
setError(extractApiError(err));
|
||||
} finally {
|
||||
|
|
@ -52,62 +58,91 @@ export default function Login() {
|
|||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canContinue) handleContinue();
|
||||
}}
|
||||
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]"
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
<img
|
||||
src={!isOwnerFlow ? GuestBanner : HostBanner}
|
||||
alt="بنر"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{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"
|
||||
{isOwnerFlow && (
|
||||
<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" />
|
||||
<p className="text-right text-sm text-gray-600">
|
||||
این شماره موبایل بهعنوان حساب نماینده کافه در اپلیکیشن وینسو ثبت
|
||||
میشود و پرداختها به کیف پول آن واریز خواهد شد.
|
||||
<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 ? "در حال ارسال..." : "ادامه"}
|
||||
</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>{" "}
|
||||
را میپذیرم.
|
||||
<h1 className="text-right text-2xl font-bold text-gray-900">
|
||||
ورود به وینسو
|
||||
</h1>
|
||||
<p className="mt-2 text-right text-gray-500">
|
||||
لطفا شماره تماس خود را وارد کنید.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,236 +1,272 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import Icon from '../components/Icon'
|
||||
import { requestOtp, verifyOtp } from '../services/authApi'
|
||||
import { fetchMyCafes } from '../services/campaignApi'
|
||||
import { storeAuthTokens } from '../utils/authToken'
|
||||
import { clearCafeUuid, setCafeUuid } from '../utils/cafe'
|
||||
import { setUserPhone } from '../utils/userPhone'
|
||||
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
|
||||
import { toE164 } from '../utils/phone'
|
||||
import { getUserRole, setUserRole, UserRole } from '../utils/role'
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Icon from "../components/Icon";
|
||||
import { requestOtp, verifyOtp } from "../services/authApi";
|
||||
import { fetchMyCafes } from "../services/campaignApi";
|
||||
import { storeAuthTokens } from "../utils/authToken";
|
||||
import { clearCafeUuid, setCafeUuid } from "../utils/cafe";
|
||||
import { setUserPhone } from "../utils/userPhone";
|
||||
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
|
||||
import { toE164 } from "../utils/phone";
|
||||
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 DEFAULT_RESEND_SECONDS = 120
|
||||
const OTP_LENGTH = 5;
|
||||
const DEFAULT_RESEND_SECONDS = 120;
|
||||
|
||||
// not yet in TS's DOM lib — minimal shape for the WebOTP API result
|
||||
interface OTPCredential extends Credential {
|
||||
code: string
|
||||
code: string;
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
|
||||
const ss = String(seconds % 60).padStart(2, '0')
|
||||
return toPersianDigits(`${mm}:${ss}`)
|
||||
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||
const ss = String(seconds % 60).padStart(2, "0");
|
||||
return toPersianDigits(`${mm}:${ss}`);
|
||||
}
|
||||
|
||||
export default function LoginOtp() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const state = location.state as { phone?: string; ttl?: number; role?: UserRole } | null
|
||||
const phone = state?.phone ?? ''
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const state = location.state as {
|
||||
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
|
||||
// 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 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))
|
||||
const [resendCycle, setResendCycle] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(""));
|
||||
const [secondsLeft, setSecondsLeft] = useState(
|
||||
Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS),
|
||||
);
|
||||
const [resendCycle, setResendCycle] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
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
|
||||
// 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)
|
||||
const fillOtp = (startIndex: number, rawValue: string) => {
|
||||
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex)
|
||||
if (!digits) return
|
||||
const digits = toEnglishDigits(rawValue)
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, OTP_LENGTH - startIndex);
|
||||
if (!digits) return;
|
||||
setOtp((prev) => {
|
||||
const next = [...prev]
|
||||
digits.split('').forEach((d, i) => {
|
||||
next[startIndex + i] = d
|
||||
})
|
||||
return next
|
||||
})
|
||||
setError('')
|
||||
inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus()
|
||||
}
|
||||
const next = [...prev];
|
||||
digits.split("").forEach((d, i) => {
|
||||
next[startIndex + i] = d;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setError("");
|
||||
inputRefs.current[
|
||||
Math.min(startIndex + digits.length, OTP_LENGTH) - 1
|
||||
]?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!phone) {
|
||||
navigate(loginPath, { replace: true })
|
||||
navigate(loginPath, { replace: true });
|
||||
}
|
||||
}, [phone, loginPath, navigate])
|
||||
}, [phone, loginPath, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus()
|
||||
}, [])
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!('OTPCredential' in window)) return
|
||||
const abortController = new AbortController()
|
||||
if (!("OTPCredential" in window)) return;
|
||||
const abortController = new AbortController();
|
||||
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) => {
|
||||
const code = (credential as OTPCredential | null)?.code
|
||||
if (code) fillOtp(0, code)
|
||||
const code = (credential as OTPCredential | null)?.code;
|
||||
if (code) fillOtp(0, code);
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => abortController.abort()
|
||||
}, [])
|
||||
.catch(() => {});
|
||||
return () => abortController.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setSecondsLeft((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [resendCycle])
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [resendCycle]);
|
||||
|
||||
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
|
||||
if (digits.length > 1) {
|
||||
fillOtp(index, digits)
|
||||
return
|
||||
fillOtp(index, digits);
|
||||
return;
|
||||
}
|
||||
setOtp((prev) => {
|
||||
const next = [...prev]
|
||||
next[index] = digits
|
||||
return next
|
||||
})
|
||||
setError('')
|
||||
const next = [...prev];
|
||||
next[index] = digits;
|
||||
return next;
|
||||
});
|
||||
setError("");
|
||||
if (digits && index < OTP_LENGTH - 1) {
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Backspace' && !otp[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
const handleOtpKeyDown = (
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setError('')
|
||||
setIsResending(true)
|
||||
setError("");
|
||||
setIsResending(true);
|
||||
try {
|
||||
const { ttl } = await requestOtp(toE164(phone))
|
||||
setOtp(Array(OTP_LENGTH).fill(''))
|
||||
inputRefs.current[0]?.focus()
|
||||
setSecondsLeft(Math.round(ttl))
|
||||
setResendCycle((c) => c + 1)
|
||||
const { ttl } = await requestOtp(toE164(phone));
|
||||
setOtp(Array(OTP_LENGTH).fill(""));
|
||||
inputRefs.current[0]?.focus();
|
||||
setSecondsLeft(Math.round(ttl));
|
||||
setResendCycle((c) => c + 1);
|
||||
} catch {
|
||||
setError('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
||||
setError("ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
setIsResending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting
|
||||
const canSubmit = otp.every((digit) => digit !== "") && !isSubmitting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const tokens = await verifyOtp(toE164(phone), otp.join(''))
|
||||
storeAuthTokens(tokens.access_token, tokens.refresh_token)
|
||||
setUserPhone(phone)
|
||||
const tokens = await verifyOtp(toE164(phone), otp.join(""));
|
||||
storeAuthTokens(tokens.access_token, tokens.refresh_token);
|
||||
setUserPhone(phone);
|
||||
// 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)
|
||||
setUserRole(role);
|
||||
|
||||
if (role === 'cafe_owner') {
|
||||
const myCafes = await fetchMyCafes().catch(() => [])
|
||||
if (role === "cafe_owner") {
|
||||
const myCafes = await fetchMyCafes().catch(() => []);
|
||||
if (myCafes.length > 0) {
|
||||
setCafeUuid(myCafes[0].uuid)
|
||||
navigate('/owner/panel')
|
||||
setCafeUuid(myCafes[0].uuid);
|
||||
navigate("/owner/panel");
|
||||
} else {
|
||||
clearCafeUuid()
|
||||
navigate('/owner/register')
|
||||
clearCafeUuid();
|
||||
navigate("/owner/register");
|
||||
}
|
||||
} else {
|
||||
navigate('/home')
|
||||
navigate("/home");
|
||||
}
|
||||
} catch {
|
||||
setError('کد وارد شده صحیح نیست')
|
||||
setError("کد وارد شده صحیح نیست");
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSubmit) handleSubmit()
|
||||
}}
|
||||
className="flex min-h-screen flex-col justify-center px-6"
|
||||
>
|
||||
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
|
||||
<div className="mt-2 flex items-center justify-start gap-2">
|
||||
<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"
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
<img
|
||||
src={role === "customer" ? GuestBanner : HostBanner}
|
||||
alt="بنر"
|
||||
className="w-full"
|
||||
/>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSubmit) handleSubmit();
|
||||
}}
|
||||
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
|
||||
>
|
||||
{isSubmitting ? 'در حال ورود...' : 'ورود'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
|
||||
<div className="mt-2 flex items-center justify-start gap-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,44 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Icon from '../components/Icon'
|
||||
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Icon from "../components/Icon";
|
||||
import {
|
||||
fetchMyReservationByUuid,
|
||||
fetchMyReservations,
|
||||
MyReservation,
|
||||
} from "../services/campaignApi";
|
||||
import { formatPersianNumber } from "../utils/formatNumber";
|
||||
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
|
||||
|
||||
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))
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) {
|
||||
function MyReservationCard({
|
||||
reservation,
|
||||
onClick,
|
||||
}: {
|
||||
reservation: MyReservation;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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.event_title}</h3>
|
||||
<p className="text-lg text-gray-500">{formatReservationDateTime(reservation.event_datetime)}</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.ticket_count)} نفر
|
||||
|
|
@ -34,7 +48,7 @@ function MyReservationCard({ reservation, onClick }: { reservation: MyReservatio
|
|||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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-900">{value}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) {
|
||||
const [reservation, setReservation] = useState<MyReservation | null>(null)
|
||||
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'
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [])
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyReservationByUuid(uuid)
|
||||
.then(setReservation)
|
||||
.catch(() => {})
|
||||
}, [uuid])
|
||||
.catch(() => {});
|
||||
}, [uuid]);
|
||||
|
||||
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
|
||||
className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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 && (
|
||||
<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.phone_number} />
|
||||
<DetailRow label="کافه" value={reservation.cafe_name} />
|
||||
<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} />
|
||||
</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">
|
||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||
<p className="text-right text-sm text-gray-500">
|
||||
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک میشود.
|
||||
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود در
|
||||
بخش رزروها قابل مشاهده است.
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyReservations() {
|
||||
const navigate = useNavigate()
|
||||
const [reservations, setReservations] = useState<MyReservation[]>([])
|
||||
const [selectedUuid, setSelectedUuid] = useState<string | null>(null)
|
||||
const navigate = useNavigate();
|
||||
const [reservations, setReservations] = useState<MyReservation[]>([]);
|
||||
const [selectedUuid, setSelectedUuid] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyReservations()
|
||||
.then(setReservations)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<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">
|
||||
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 px-4">
|
||||
{reservations.map((reservation) => (
|
||||
<MyReservationCard key={reservation.uuid} reservation={reservation} onClick={() => setSelectedUuid(reservation.uuid)} />
|
||||
<MyReservationCard
|
||||
key={reservation.uuid}
|
||||
reservation={reservation}
|
||||
onClick={() => setSelectedUuid(reservation.uuid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedUuid && (
|
||||
<ReservationDetailModal uuid={selectedUuid} onClose={() => setSelectedUuid(null)} />
|
||||
<ReservationDetailModal
|
||||
uuid={selectedUuid}
|
||||
onClose={() => setSelectedUuid(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,52 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import Icon from '../components/Icon'
|
||||
import PaymentFailModal from '../components/PaymentFailModal'
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import Icon from "../components/Icon";
|
||||
import PaymentFailModal from "../components/PaymentFailModal";
|
||||
import {
|
||||
createReservation,
|
||||
fetchMyReservationByUuid,
|
||||
MyReservation,
|
||||
} from '../services/campaignApi'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
import { fromE164 } from '../utils/phone'
|
||||
import { takePendingReservation } from '../utils/pendingReservation'
|
||||
import { formatMatchDatetime } from '../utils/iranDate'
|
||||
} from "../services/campaignApi";
|
||||
import { formatPersianNumber } from "../utils/formatNumber";
|
||||
import { fromE164 } from "../utils/phone";
|
||||
import { takePendingReservation } from "../utils/pendingReservation";
|
||||
import { formatMatchDatetime } from "../utils/iranDate";
|
||||
|
||||
// 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'
|
||||
const SUCCESS_STATE = "20";
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentSuccess() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const isSuccess = searchParams.get('state') === SUCCESS_STATE
|
||||
const [reservation, setReservation] = useState<MyReservation | null>(null)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const isFail = !isSuccess || hasError
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isSuccess = searchParams.get("state") === SUCCESS_STATE;
|
||||
const [reservation, setReservation] = useState<MyReservation | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const isFail = !isSuccess || hasError;
|
||||
|
||||
// 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
|
||||
// takePendingReservation() from returning null on the second run and triggering hasError
|
||||
const reservationAttempted = useRef(false)
|
||||
const reservationAttempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess || reservationAttempted.current) return
|
||||
reservationAttempted.current = true
|
||||
const pending = takePendingReservation()
|
||||
if (!isSuccess || reservationAttempted.current) return;
|
||||
reservationAttempted.current = true;
|
||||
const pending = takePendingReservation();
|
||||
if (!pending) {
|
||||
setHasError(true)
|
||||
return
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
createReservation(pending)
|
||||
.then((result) =>
|
||||
|
|
@ -58,15 +60,15 @@ export default function PaymentSuccess() {
|
|||
ticket_count: result.people_count,
|
||||
full_name: result.full_name,
|
||||
phone_number: result.phone_number,
|
||||
cafe_name: '',
|
||||
event_title: '',
|
||||
event_datetime: '',
|
||||
cafe_name: "",
|
||||
event_title: "",
|
||||
event_datetime: "",
|
||||
status: result.status,
|
||||
})
|
||||
});
|
||||
}),
|
||||
)
|
||||
.catch(() => setHasError(true))
|
||||
}, [isSuccess])
|
||||
.catch(() => setHasError(true));
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
|
@ -77,11 +79,19 @@ export default function PaymentSuccess() {
|
|||
|
||||
{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">
|
||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
|
||||
<DetailRow
|
||||
label="تعداد"
|
||||
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
|
||||
/>
|
||||
<DetailRow label="به نام" value={reservation.full_name} />
|
||||
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
|
||||
<DetailRow
|
||||
label="شماره تماس"
|
||||
value={fromE164(reservation.phone_number)}
|
||||
/>
|
||||
{reservation.cafe_name && (
|
||||
<DetailRow label="کافه" value={reservation.cafe_name} />
|
||||
)}
|
||||
|
|
@ -89,25 +99,37 @@ export default function PaymentSuccess() {
|
|||
<DetailRow label="بازی" value={reservation.event_title} />
|
||||
)}
|
||||
{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 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>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
|
||||
<p className="mt-6 text-center text-gray-500">
|
||||
در حال ثبت رزرو...
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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"
|
||||
>
|
||||
صفحه اصلی
|
||||
|
|
@ -116,7 +138,7 @@ export default function PaymentSuccess() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isFail && <PaymentFailModal onGoHome={() => navigate('/home')} />}
|
||||
{isFail && <PaymentFailModal onGoHome={() => navigate("/home")} />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue