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( + <> +
+میخواهید از حساب کاربری خود خارج شوید؟
++ مطابق مقررات نظام مالیاتی کشور، کارمزد درگاه پرداخت در تمامی + درگاههای پرداخت اعمال میشود. +
+{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({ - -- - قوانین و شرایط - {" "} - را میپذیرم. +
+ لطفا شماره تماس خود را وارد کنید.
-{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}
+ navigate(loginPath, { state: { phone } })}
+ aria-label="ویرایش شماره تماس"
+ >
+
+
+
+
+
+ {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)}
+
+ ) : (
+
+ {isResending ? "در حال ارسال..." : "ارسال مجدد کد"}
+
+ )}
+
+
+ {error && (
+ {error}
+ )}
+
+
+ {isSubmitting ? "در حال ورود..." : "ورود"}
+
+
+
+ );
}
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 (
- {reservation.event_title}
- {formatReservationDateTime(reservation.event_datetime)}
+
+ {reservation.event_title}
+
+
+ {formatReservationDateTime(reservation.event_datetime)}
+
{formatPersianNumber(reservation.ticket_count)} نفر
@@ -34,7 +48,7 @@ function MyReservationCard({ reservation, onClick }: { reservation: MyReservatio
- )
+ );
}
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")} />}
- )
+ );
}
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)
+}