diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg new file mode 100644 index 0000000..8a0cd6f --- /dev/null +++ b/src/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/logout.svg b/src/assets/icons/logout.svg new file mode 100644 index 0000000..710c9e7 --- /dev/null +++ b/src/assets/icons/logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index 2042abd..f82b00b 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -5,6 +5,8 @@ // icons are referenced and tree-shake the rest — this file is the *only* place all 17 // icons are ever imported, so an icon that no call site uses never makes it into a chunk. import ArrowRight from '../../assets/icons/arrow-right.svg?react' +import Copy from '../../assets/icons/copy.svg?react' +import Logout from '../../assets/icons/logout.svg?react' import Book from '../../assets/icons/book.svg?react' import Calendar from '../../assets/icons/calendar.svg?react' import Call from '../../assets/icons/call.svg?react' @@ -28,6 +30,8 @@ import Success from '../../assets/icons/success.svg?react' // is used, with no second list to keep in sync. export const icons = { arrowRight: ArrowRight, + copy: Copy, + logout: Logout, book: Book, calendar: Calendar, call: Call, diff --git a/src/components/LogoutModal.tsx b/src/components/LogoutModal.tsx new file mode 100644 index 0000000..3aadf4b --- /dev/null +++ b/src/components/LogoutModal.tsx @@ -0,0 +1,29 @@ +import { createPortal } from 'react-dom' + +export default function LogoutModal({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) { + return createPortal( + <> +
+
+

می‌خواهید از حساب کاربری خود خارج شوید؟

+
+ + +
+
+ , + document.body, + ) +} diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx index 39c6b29..922f490 100644 --- a/src/components/ReservationModal.tsx +++ b/src/components/ReservationModal.tsx @@ -4,7 +4,11 @@ import { useNavigate } from "react-router-dom"; import { Cafe } from "../services/campaignApi"; import { chargePageUrl, createCharge } from "../services/ipgApi"; import { fetchWalletBalances } from "../services/walletApi"; -import { toEnglishDigits, toPersianDigits, formatPersianNumber } from "../utils/formatNumber"; +import { + toEnglishDigits, + toPersianDigits, + formatPersianNumber, +} from "../utils/formatNumber"; import { toE164 } from "../utils/phone"; import { setPendingReservation } from "../utils/pendingReservation"; import { getUserPhone } from "../utils/userPhone"; @@ -39,6 +43,8 @@ function PaymentRow({ bottomBorder?: boolean; infoIconParam?: boolean; }) { + const [showInfo, setShowInfo] = useState(false); + return (
{infoIconParam && ( - + )}
{formatPersianNumber(amount)} تومان + + {showInfo && + createPortal( + <> +
setShowInfo(false)} + /> +
+ +

+ مطابق مقررات نظام مالیاتی کشور، کارمزد درگاه پرداخت در تمامی + درگاه‌های پرداخت اعمال می‌شود. +

+
+ , + document.body, + )}
); } @@ -182,14 +220,19 @@ export default function ReservationModal({ { setFullName(e.target.value); clearFieldError("name"); }} + onChange={(e) => { + setFullName(e.target.value); + clearFieldError("name"); + }} placeholder="نام و نام خانوادگی" className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${ fieldErrors.name ? "border-red-400" : "border-gray-200" }`} /> {fieldErrors.name && ( -

{fieldErrors.name}

+

+ {fieldErrors.name} +

)}
@@ -203,7 +246,9 @@ export default function ReservationModal({ inputMode="numeric" value={phone} onChange={(e) => { - const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11); + const digits = toEnglishDigits(e.target.value) + .replace(/\D/g, "") + .slice(0, 11); setPhone(toPersianDigits(digits)); clearFieldError("phone"); }} @@ -213,7 +258,9 @@ export default function ReservationModal({ }`} /> {fieldErrors.phone && ( -

{fieldErrors.phone}

+

+ {fieldErrors.phone} +

)} @@ -242,10 +289,14 @@ export default function ReservationModal({ + + {open && + createPortal( + <> +
setOpen(false)} + /> +
+
+

+ {copied ? ( + "کپی شد!" + ) : ( +

+ شماره تماس پشتیبانی:{" "} + {SUPPORT_PHONE_DISPLAY} + +
+ )} +

+ +
+
+ , + document.body, + )} + + ); +} diff --git a/src/pages/AddEvent.tsx b/src/pages/AddEvent.tsx index 9b68dbc..c3861d5 100644 --- a/src/pages/AddEvent.tsx +++ b/src/pages/AddEvent.tsx @@ -214,7 +214,7 @@ export default function AddEvent() {
diff --git a/src/pages/AddEventFail.tsx b/src/pages/AddEventFail.tsx index e7d7e27..48bc6e5 100644 --- a/src/pages/AddEventFail.tsx +++ b/src/pages/AddEventFail.tsx @@ -9,7 +9,7 @@ export default function AddEventFail() {
-

ثبت ایونت با خطا مواجه شد. لطفا دوباره تلاش کنید.

+

ثبت رویداد با خطا مواجه شد. لطفا دوباره تلاش کنید.

@@ -18,7 +18,7 @@ export default function AddEventFail() { onClick={() => navigate('/owner/events/new')} className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white" > - افزودن ایونت + افزودن رویداد
@@ -18,7 +18,7 @@ export default function AddEventSuccess() { onClick={() => navigate('/owner/events/new')} className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white" > - افزودن ایونت + افزودن رویداد - ) + ); } export default function CafeOwnerPanel() { - const navigate = useNavigate() - const now = useNow() - const [schedules, setSchedules] = useState([]) + const navigate = useNavigate(); + const now = useNow(); + const [schedules, setSchedules] = useState([]); + const [showLogout, setShowLogout] = useState(false); useEffect(() => { - const cafeUuid = getCafeUuid() - if (!cafeUuid) return + const cafeUuid = getCafeUuid(); + if (!cafeUuid) return; fetchCafeSchedules(cafeUuid) .then(setSchedules) - .catch(() => {}) - }, []) + .catch(() => {}); + }, []); return (
+
+ +
+ + {showLogout && ( + { + logout(); + navigate("/cafe/login"); + }} + onCancel={() => setShowLogout(false)} + /> + )}
-

پنل کافه

+

+ پنل کافه +

} + icon={ + + } label="اطلاعات کافه" - onClick={() => navigate('/owner/register', { state: { step: 2 } })} + onClick={() => navigate("/owner/register", { state: { step: 2 } })} + /> + } + label="افزودن رویداد" + onClick={() => navigate("/owner/events/new")} /> - } label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} />
-

رویدادهای فعال شما

+

+ رویدادهای فعال شما +

{schedules.map((schedule) => { - const isPast = new Date(schedule.match_event.match_datetime).getTime() <= now.getTime() - const matchLabel = formatMatchLabel(schedule.match_event, now) + const isPast = + new Date(schedule.match_event.match_datetime).getTime() <= + now.getTime(); + const matchLabel = formatMatchLabel(schedule.match_event, now); return (

{matchLabel}

{isPast && ( - برگزار شده + + برگزار شده + )}
- {formatPersianNumber(schedule.remaining_tickets)} صندلی از {formatPersianNumber(schedule.total_tickets)} باقی مانده + {formatPersianNumber(schedule.remaining_tickets)} صندلی از{" "} + {formatPersianNumber(schedule.total_tickets)} باقی مانده
} + icon={ + + } label="مشاهده رزروها" onClick={() => navigate(`/owner/events/${schedule.uuid}/reservations`, { @@ -91,14 +147,12 @@ export default function CafeOwnerPanel() { />
- ) + ); })}
- +
- ) + ); } diff --git a/src/pages/CafeOwnerRegister.tsx b/src/pages/CafeOwnerRegister.tsx index 6f296b8..d2dcd0c 100644 --- a/src/pages/CafeOwnerRegister.tsx +++ b/src/pages/CafeOwnerRegister.tsx @@ -6,6 +6,7 @@ import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "reac import "leaflet/dist/leaflet.css"; import "../utils/leafletIcons"; import Icon from "../components/Icon"; +import SupportButton from "../components/SupportButton"; import { CafeFeature, CafeMediaItem, @@ -831,12 +832,7 @@ export default function CafeOwnerRegister() {
- +
@@ -21,7 +21,7 @@ export default function CafeRegisterSuccess() { onClick={() => navigate('/owner/events/new')} className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white" > - افزودن ایونت + افزودن رویداد +

تایید ورود

-
); } @@ -84,7 +94,7 @@ export default function EventReservations() { const location = useLocation(); const navigate = useNavigate(); const [query, setQuery] = useState(""); - const [selectedIds, setSelectedIds] = useState([]); + const [togglingIds, setTogglingIds] = useState>(new Set()); const [reservations, setReservations] = useState([]); const event: CafeEvent | undefined = @@ -107,12 +117,24 @@ export default function EventReservations() { [reservations, query], ); - const toggleSelected = (reservationId: string) => { - setSelectedIds((prev) => - prev.includes(reservationId) - ? prev.filter((id) => id !== reservationId) - : [...prev, reservationId], - ); + const handleParticipate = async (reservation: EventReservation) => { + if (togglingIds.has(reservation.uuid)) return; + const newValue = !reservation.is_participated; + setTogglingIds((prev) => new Set([...prev, reservation.uuid])); + try { + await participateReservation(reservation.uuid, newValue); + setReservations((prev) => + prev.map((r) => (r.uuid === reservation.uuid ? { ...r, is_participated: newValue } : r)), + ); + } catch { + // leave checkbox at current state on failure + } finally { + setTogglingIds((prev) => { + const next = new Set(prev); + next.delete(reservation.uuid); + return next; + }); + } }; const handleDownload = () => { @@ -128,7 +150,10 @@ export default function EventReservations() { onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4" > - + رزروهای {event?.matchLabel ?? ""} @@ -153,8 +178,8 @@ export default function EventReservations() { toggleSelected(reservation.uuid)} + isToggling={togglingIds.has(reservation.uuid)} + onToggle={() => handleParticipate(reservation)} /> ))} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ddbd183..dbd4ca0 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from 'react' +import SupportButton from '../components/SupportButton' +import LogoutModal from '../components/LogoutModal' import { createPortal } from 'react-dom' import { useNavigate } from 'react-router-dom' import { MapContainer, TileLayer, Marker } from 'react-leaflet' @@ -6,6 +8,7 @@ import 'leaflet/dist/leaflet.css' import '../utils/leafletIcons' import Icon from '../components/Icon' import HomeBanner from '../components/HomeBanner' +import { logout } from '../utils/logout' import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi' import { useMatchEvents } from '../hooks/useMatchEvents' import { formatMatchLabel } from '../utils/matchCountdown' @@ -78,7 +81,7 @@ function CafesMapPreview({ cafes }: { cafes: Cafe[] }) { {/* transparent overlay — captures all clicks and opens the full-page map */}
navigate('/map', { state: { cafes } })} />
@@ -270,10 +273,12 @@ function CafeCard({ cafe }: { cafe: Cafe }) { } export default function Home() { + const navigate = useNavigate() const [view, setView] = useState<'list' | 'map'>('list') const [cities, setCities] = useState([]) const [selectedCityUuid, setSelectedCityUuid] = useState('') const [selectedMatchUuid, setSelectedMatchUuid] = useState('') + const [showLogout, setShowLogout] = useState(false) const { matches, now } = useMatchEvents() const [cafes, setCafes] = useState([]) @@ -300,7 +305,24 @@ export default function Home() { return (
- +
+ + +
+ + {showLogout && ( + { logout(); navigate('/login') }} + onCancel={() => setShowLogout(false)} + /> + )} @@ -351,12 +373,7 @@ export default function Home() {
)} - + ) } 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 ( -
{ - e.preventDefault(); - if (canContinue) handleContinue(); - }} - className="flex min-h-screen flex-col justify-center px-6" - > -

- ورود به وینسو -

-

- لطفا شماره تماس خود را وارد کنید. -

- - { - 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}

} - -
+ )} + { + e.preventDefault(); + if (canContinue) handleContinue(); + }} + className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6" > - {isSubmitting ? "در حال ارسال..." : "ادامه"} - - -
- -

- - قوانین و شرایط - {" "} - را می‌پذیرم. +

+ ورود به وینسو +

+

+ لطفا شماره تماس خود را وارد کنید.

-
-
+ + { + 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}

+ )} + + + +
+ +

+ + قوانین و شرایط + {" "} + را می‌پذیرم. +

+
+ + ); } diff --git a/src/pages/LoginOtp.tsx b/src/pages/LoginOtp.tsx index 3e4b704..2e09266 100644 --- a/src/pages/LoginOtp.tsx +++ b/src/pages/LoginOtp.tsx @@ -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(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(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 "@ #", 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 ( -
{ - e.preventDefault() - if (canSubmit) handleSubmit() - }} - className="flex min-h-screen flex-col justify-center px-6" - > -

کد ارسال شده را وارد کنید:

-
- {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}

} - - -
- ) +

کد ارسال شده را وارد کنید:

+
+ {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 (
-
{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 && ( - + )} - +
- +

- کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود. + کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد + ورود در بخش رزروها قابل مشاهده است.

) : ( -

در حال ثبت رزرو...

+

+ در حال ثبت رزرو... +

)}
)} - {isFail && navigate('/home')} />} + {isFail && navigate("/home")} />}
- ) + ); } diff --git a/src/services/campaignApi.ts b/src/services/campaignApi.ts index 3d60eab..01b31b1 100644 --- a/src/services/campaignApi.ts +++ b/src/services/campaignApi.ts @@ -207,6 +207,7 @@ export interface EventReservation { full_name: string phone_number: string status: string + is_participated: boolean created_at: string } @@ -215,6 +216,10 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string return data } +export async function participateReservation(uuid: string, isParticipated: boolean): Promise { + await api.patch(`/campaign/reservations/${uuid}/participate/`, { is_participated: isParticipated }) +} + export interface MyReservation { uuid: string reservation_code: string diff --git a/src/utils/logout.ts b/src/utils/logout.ts new file mode 100644 index 0000000..ae583dc --- /dev/null +++ b/src/utils/logout.ts @@ -0,0 +1,12 @@ +import { clearAuthTokens } from './authToken' +import { clearCafeUuid } from './cafe' +import { clearUserRole } from './role' +import { clearUserPhone } from './userPhone' + +export function logout() { + clearAuthTokens() + clearUserRole() + clearUserPhone() + clearCafeUuid() + sessionStorage.clear() +} diff --git a/src/utils/role.ts b/src/utils/role.ts index 4db59dd..56315aa 100644 --- a/src/utils/role.ts +++ b/src/utils/role.ts @@ -10,3 +10,7 @@ export function setUserRole(role: UserRole) { export function getUserRole(): UserRole { return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer' } + +export function clearUserRole() { + localStorage.removeItem(ROLE_STORAGE_KEY) +} diff --git a/src/utils/userPhone.ts b/src/utils/userPhone.ts index b5b8b58..9a82fb4 100644 --- a/src/utils/userPhone.ts +++ b/src/utils/userPhone.ts @@ -7,3 +7,7 @@ export function setUserPhone(phone: string) { export function getUserPhone(): string | null { return localStorage.getItem(USER_PHONE_STORAGE_KEY) } + +export function clearUserPhone() { + localStorage.removeItem(USER_PHONE_STORAGE_KEY) +}