diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 84e5185..3132629 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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 ( -
+ + { + 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 && ( +{error}
+ )} + + + ++ + قوانین و شرایط + {" "} + را میپذیرم. +
+", 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) => {
- if (e.key === 'Backspace' && !otp[index] && index > 0) {
- inputRefs.current[index - 1]?.focus()
+ const handleOtpKeyDown = (
+ index: number,
+ e: React.KeyboardEvent,
+ ) => {
+ 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 (
-
- )
+ کد ارسال شده را وارد کنید:
+
+ {phone}
+
+
+
+
+ {otp.map((digit, index) => (
+ {
+ 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"
+ />
+ ))}
+
+
+
+ {secondsLeft > 0 ? (
+
+ ارسال مجدد کد: {formatCountdown(secondsLeft)}
+
+ ) : (
+
+ )}
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+
+ );
}
diff --git a/src/pages/MyReservations.tsx b/src/pages/MyReservations.tsx
index f8975d5..dfcfd6e 100644
--- a/src/pages/MyReservations.tsx
+++ b/src/pages/MyReservations.tsx
@@ -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 (
- )
+ );
}
function DetailRow({ label, value }: { label: string; value: string }) {
@@ -43,43 +57,60 @@ function DetailRow({ label, value }: { label: string; value: string }) {
{label}
{value}
- )
+ );
}
-function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) {
- const [reservation, setReservation] = useState(null)
+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'
+ 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(
-
+
e.stopPropagation()}
>
- جزئیات رزرو
+
+ جزئیات رزرو
+
{reservation && (
-
+
-
+
)}
@@ -87,48 +118,67 @@ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () =
- کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک میشود.
+ کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود در
+ بخش رزروها قابل مشاهده است.
-
,
document.body,
- )
+ );
}
export default function MyReservations() {
- const navigate = useNavigate()
- const [reservations, setReservations] = useState([])
- const [selectedUuid, setSelectedUuid] = useState(null)
+ const navigate = useNavigate();
+ const [reservations, setReservations] = useState([]);
+ const [selectedUuid, setSelectedUuid] = useState(null);
useEffect(() => {
fetchMyReservations()
.then(setReservations)
- .catch(() => {})
- }, [])
+ .catch(() => {});
+ }, []);
return (
- navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
-
+ navigate(-1)}
+ className="mt-4 flex w-full items-center justify-start gap-2 px-4"
+ >
+
رزروهای شما
{reservations.map((reservation) => (
- setSelectedUuid(reservation.uuid)} />
+ setSelectedUuid(reservation.uuid)}
+ />
))}
{selectedUuid && (
- setSelectedUuid(null)} />
+ setSelectedUuid(null)}
+ />
)}
- )
+ );
}
diff --git a/src/pages/PaymentSuccess.tsx b/src/pages/PaymentSuccess.tsx
index 32ec741..43fdeb2 100644
--- a/src/pages/PaymentSuccess.tsx
+++ b/src/pages/PaymentSuccess.tsx
@@ -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=` 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 (
- {value}
+
+ {value}
+
{label}
- )
+ );
}
export default function PaymentSuccess() {
- const navigate = useNavigate()
- const [searchParams] = useSearchParams()
- const isSuccess = searchParams.get('state') === SUCCESS_STATE
- const [reservation, setReservation] = useState(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(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 (
@@ -77,11 +79,19 @@ export default function PaymentSuccess() {
{reservation ? (
<>
- جزئیات رزرو
+
+ جزئیات رزرو
+
-
+
-
+
{reservation.cafe_name && (
)}
@@ -89,25 +99,37 @@ export default function PaymentSuccess() {
)}
{reservation.event_datetime && (
-
+
)}
-
+
-
+
- کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک میشود.
+ کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد
+ ورود در بخش رزروها قابل مشاهده است.
>
) : (
- در حال ثبت رزرو...
+
+ در حال ثبت رزرو...
+
)}
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() {
)}
- {isFail && navigate('/home')} />}
+ {isFail && navigate("/home")} />}
- )
+ );
}