Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b89b34fbca | |||
|
|
ae70edf99b | ||
|
|
434b5b5235 | ||
|
|
594cc75165 | ||
|
|
2e26ef067f | ||
|
|
18996426ac | ||
|
|
9382cdb666 | ||
| 43a6f05045 | |||
|
|
c4b4780a4c | ||
|
|
88386ea77e | ||
|
|
e6e4c690ff | ||
| b4c8c1a66f | |||
|
|
79db6c625a | ||
|
|
165c7fd0eb | ||
|
|
5b7f904dbe |
27 changed files with 1399 additions and 600 deletions
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.9.0",
|
"@tanstack/react-query": "^5.9.0",
|
||||||
"axios": "^1.6.6",
|
"axios": "^1.6.6",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
|
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -14,6 +14,9 @@ importers:
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.6.6
|
specifier: ^1.6.6
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
|
heic2any:
|
||||||
|
specifier: ^0.0.4
|
||||||
|
version: 0.0.4
|
||||||
leaflet:
|
leaflet:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
|
|
@ -942,6 +945,9 @@ packages:
|
||||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
heic2any@0.0.4:
|
||||||
|
resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
|
||||||
|
|
||||||
hermes-estree@0.25.1:
|
hermes-estree@0.25.1:
|
||||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||||
|
|
||||||
|
|
@ -2245,6 +2251,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
heic2any@0.0.4: {}
|
||||||
|
|
||||||
hermes-estree@0.25.1: {}
|
hermes-estree@0.25.1: {}
|
||||||
|
|
||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
|
|
|
||||||
4
src/assets/icons/copy.svg
Normal file
4
src/assets/icons/copy.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.3327 10.7493V14.2493C13.3327 17.166 12.166 18.3327 9.24935 18.3327H5.74935C2.83268 18.3327 1.66602 17.166 1.66602 14.2493V10.7493C1.66602 7.83268 2.83268 6.66602 5.74935 6.66602H9.24935C12.166 6.66602 13.3327 7.83268 13.3327 10.7493Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.3327 5.74935V9.24935C18.3327 12.166 17.166 13.3327 14.2493 13.3327H13.3327V10.7493C13.3327 7.83268 12.166 6.66602 9.24935 6.66602H6.66602V5.74935C6.66602 2.83268 7.83268 1.66602 10.7493 1.66602H14.2493C17.166 1.66602 18.3327 2.83268 18.3327 5.74935Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 785 B |
5
src/assets/icons/logout.svg
Normal file
5
src/assets/icons/logout.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6.67578 5.66969C6.90828 2.96969 8.29578 1.86719 11.3333 1.86719H11.4308C14.7833 1.86719 16.1258 3.20969 16.1258 6.56219V11.4522C16.1258 14.8047 14.7833 16.1472 11.4308 16.1472H11.3333C8.31828 16.1472 6.93078 15.0597 6.68328 12.4047" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.2498 9H2.71484" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.3875 6.48828L1.875 9.00078L4.3875 11.5133" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 685 B |
|
|
@ -5,6 +5,8 @@
|
||||||
// icons are referenced and tree-shake the rest — this file is the *only* place all 17
|
// 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.
|
// 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 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 Book from '../../assets/icons/book.svg?react'
|
||||||
import Calendar from '../../assets/icons/calendar.svg?react'
|
import Calendar from '../../assets/icons/calendar.svg?react'
|
||||||
import Call from '../../assets/icons/call.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.
|
// is used, with no second list to keep in sync.
|
||||||
export const icons = {
|
export const icons = {
|
||||||
arrowRight: ArrowRight,
|
arrowRight: ArrowRight,
|
||||||
|
copy: Copy,
|
||||||
|
logout: Logout,
|
||||||
book: Book,
|
book: Book,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
call: Call,
|
call: Call,
|
||||||
|
|
|
||||||
29
src/components/LogoutModal.tsx
Normal file
29
src/components/LogoutModal.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export default function LogoutModal({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) {
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-9998 bg-black/40" onClick={onCancel} />
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-9999 rounded-t-2xl bg-white px-5 pb-8 pt-6 shadow-xl">
|
||||||
|
<p className="mb-6 text-center font-semibold text-gray-900">میخواهید از حساب کاربری خود خارج شوید؟</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="flex-1 rounded-xl bg-[#223D5D] py-3 font-semibold text-white"
|
||||||
|
>
|
||||||
|
بله
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 rounded-xl border border-[#223D5D] py-3 font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
خیر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,11 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { Cafe } from "../services/campaignApi";
|
import { Cafe } from "../services/campaignApi";
|
||||||
import { chargePageUrl, createCharge } from "../services/ipgApi";
|
import { chargePageUrl, createCharge } from "../services/ipgApi";
|
||||||
import { fetchWalletBalances } from "../services/walletApi";
|
import { fetchWalletBalances } from "../services/walletApi";
|
||||||
import { formatPersianNumber } from "../utils/formatNumber";
|
import {
|
||||||
|
toEnglishDigits,
|
||||||
|
toPersianDigits,
|
||||||
|
formatPersianNumber,
|
||||||
|
} from "../utils/formatNumber";
|
||||||
import { toE164 } from "../utils/phone";
|
import { toE164 } from "../utils/phone";
|
||||||
import { setPendingReservation } from "../utils/pendingReservation";
|
import { setPendingReservation } from "../utils/pendingReservation";
|
||||||
import { getUserPhone } from "../utils/userPhone";
|
import { getUserPhone } from "../utils/userPhone";
|
||||||
|
|
@ -16,11 +20,15 @@ interface ReservationModalProps {
|
||||||
cafe: Cafe;
|
cafe: Cafe;
|
||||||
ticketPrice: number;
|
ticketPrice: number;
|
||||||
cafeMatchScheduleUuid: string;
|
cafeMatchScheduleUuid: string;
|
||||||
|
remainingTickets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
|
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
|
||||||
// these are fixed test wallet ids given directly for now, replace once that exists
|
// these are fixed test wallet ids given directly for now, replace once that exists
|
||||||
const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
|
const WALLET_UUIDS = [
|
||||||
|
"e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d",
|
||||||
|
"af7d967f-30c0-409b-9066-2549f2da5e5e",
|
||||||
|
];
|
||||||
|
|
||||||
function PaymentRow({
|
function PaymentRow({
|
||||||
label,
|
label,
|
||||||
|
|
@ -35,6 +43,8 @@ function PaymentRow({
|
||||||
bottomBorder?: boolean;
|
bottomBorder?: boolean;
|
||||||
infoIconParam?: boolean;
|
infoIconParam?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between py-3 ${bottomBorder ? "border-b border-gray-100" : ""}`}
|
className={`flex items-center justify-between py-3 ${bottomBorder ? "border-b border-gray-100" : ""}`}
|
||||||
|
|
@ -48,7 +58,14 @@ function PaymentRow({
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{infoIconParam && (
|
{infoIconParam && (
|
||||||
<Icon name="info" className="h-5 w-5 shrink-0 text-[#376195]" />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInfo(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="توضیحات کارمزد"
|
||||||
|
>
|
||||||
|
<Icon name="info" className="h-5 w-5 text-[#376195]" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
|
@ -56,6 +73,31 @@ function PaymentRow({
|
||||||
>
|
>
|
||||||
{formatPersianNumber(amount)} تومان
|
{formatPersianNumber(amount)} تومان
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{showInfo &&
|
||||||
|
createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-9998 bg-black/40"
|
||||||
|
onClick={() => setShowInfo(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-x-6 top-1/2 z-9999 -translate-y-1/2 rounded-2xl bg-white px-5 py-5 shadow-xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInfo(false)}
|
||||||
|
className="absolute left-4 top-4 flex h-7 w-7 items-center justify-center text-gray-400 hover:text-gray-600"
|
||||||
|
aria-label="بستن"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<p className="pt-4 text-right text-sm leading-7 text-gray-800">
|
||||||
|
مطابق مقررات نظام مالیاتی کشور، کارمزد درگاه پرداخت در تمامی
|
||||||
|
درگاههای پرداخت اعمال میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -66,17 +108,32 @@ export default function ReservationModal({
|
||||||
cafe,
|
cafe,
|
||||||
ticketPrice,
|
ticketPrice,
|
||||||
cafeMatchScheduleUuid,
|
cafeMatchScheduleUuid,
|
||||||
|
remainingTickets,
|
||||||
}: ReservationModalProps) {
|
}: ReservationModalProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [phone, setPhone] = useState(() => getUserPhone() ?? "");
|
||||||
const [count, setCount] = useState(1);
|
const [count, setCount] = useState(1);
|
||||||
const [walletBalance, setWalletBalance] = useState(0);
|
const [walletBalance, setWalletBalance] = useState(0);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [payError, setPayError] = useState("");
|
||||||
|
|
||||||
|
const clearFieldError = (field: string) =>
|
||||||
|
setFieldErrors((prev) => {
|
||||||
|
if (!prev[field]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
setFullName("");
|
||||||
|
setPhone(getUserPhone() ?? "");
|
||||||
|
setCount(1);
|
||||||
|
setFieldErrors({});
|
||||||
|
setPayError("");
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -95,12 +152,16 @@ export default function ReservationModal({
|
||||||
|
|
||||||
const reservationAmount = ticketPrice * count;
|
const reservationAmount = ticketPrice * count;
|
||||||
const payableAmount = Math.max(0, reservationAmount - walletBalance);
|
const payableAmount = Math.max(0, reservationAmount - walletBalance);
|
||||||
|
const atMaxCount = remainingTickets > 0 && count >= remainingTickets;
|
||||||
|
|
||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
setError("");
|
setPayError("");
|
||||||
|
|
||||||
if (!fullName.trim()) {
|
const errors: Record<string, string> = {};
|
||||||
setError("لطفاً نام و نام خانوادگی را وارد کنید.");
|
if (!fullName.trim()) errors.name = "نام و نام خانوادگی را وارد کنید";
|
||||||
|
if (phone.length !== 11) errors.phone = "شماره تلفن باید ۱۱ رقم باشد";
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFieldErrors(errors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,14 +171,16 @@ export default function ReservationModal({
|
||||||
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
|
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
|
||||||
people_count: count,
|
people_count: count,
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
phone_number: toE164(getUserPhone() ?? ""),
|
phone_number: toE164(phone),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (payableAmount > 0) {
|
if (payableAmount > 0) {
|
||||||
// reservation creation happens on /payment/success, only once the gateway confirms state=20
|
// reservation creation happens on /payment/success, only once the gateway confirms state=20
|
||||||
setPendingReservation(payload);
|
setPendingReservation(payload);
|
||||||
const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
|
const appBaseUrl = (
|
||||||
|
import.meta.env.VITE_APP_BASE_URL || window.location.origin
|
||||||
|
).replace(/\/$/, "");
|
||||||
const { uuid } = await createCharge({
|
const { uuid } = await createCharge({
|
||||||
amount: payableAmount,
|
amount: payableAmount,
|
||||||
callback_url: `${appBaseUrl}/payment/success`,
|
callback_url: `${appBaseUrl}/payment/success`,
|
||||||
|
|
@ -129,7 +192,9 @@ export default function ReservationModal({
|
||||||
navigate("/payment/success?state=20");
|
navigate("/payment/success?state=20");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
setPayError(
|
||||||
|
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
|
||||||
|
);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -155,10 +220,20 @@ export default function ReservationModal({
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFullName(e.target.value);
|
||||||
|
clearFieldError("name");
|
||||||
|
}}
|
||||||
placeholder="نام و نام خانوادگی"
|
placeholder="نام و نام خانوادگی"
|
||||||
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]"
|
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 && (
|
||||||
|
<p className="mt-1 text-right text-sm text-red-500">
|
||||||
|
{fieldErrors.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
@ -167,11 +242,26 @@ export default function ReservationModal({
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={getUserPhone() ?? ""}
|
dir="ltr"
|
||||||
readOnly
|
inputMode="numeric"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
const digits = toEnglishDigits(e.target.value)
|
||||||
|
.replace(/\D/g, "")
|
||||||
|
.slice(0, 11);
|
||||||
|
setPhone(toPersianDigits(digits));
|
||||||
|
clearFieldError("phone");
|
||||||
|
}}
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
className="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]"
|
className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${
|
||||||
|
fieldErrors.phone ? "border-red-400" : "border-gray-200"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{fieldErrors.phone && (
|
||||||
|
<p className="mt-1 text-right text-sm text-red-500">
|
||||||
|
{fieldErrors.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
@ -199,12 +289,24 @@ export default function ReservationModal({
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCount((prev) => prev + 1)}
|
onClick={() =>
|
||||||
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
|
setCount((prev) => Math.min(remainingTickets, prev + 1))
|
||||||
|
}
|
||||||
|
disabled={atMaxCount}
|
||||||
|
className={`flex h-6 w-6 items-center justify-center ${
|
||||||
|
atMaxCount
|
||||||
|
? "cursor-not-allowed text-[#999999]"
|
||||||
|
: "text-[#223D5D]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Icon name="plus" className="h-4 w-4" />
|
<Icon name="plus" className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{remainingTickets > 0 && (
|
||||||
|
<p className="mt-1 text-right text-sm text-gray-400">
|
||||||
|
حداکثر {formatPersianNumber(remainingTickets)} نفر
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
|
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
|
||||||
|
|
@ -229,14 +331,16 @@ export default function ReservationModal({
|
||||||
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||||
<p className="text-right text-sm text-gray-500">
|
<p className="text-right text-sm text-gray-500">
|
||||||
توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
|
پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت
|
||||||
کاربر است.
|
تمایل به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
|
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
|
||||||
{error && <p className="mb-2 text-center text-sm text-red-500">{error}</p>}
|
{payError && (
|
||||||
|
<p className="mb-2 text-center text-sm text-red-500">{payError}</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting || !cafeMatchScheduleUuid}
|
disabled={isSubmitting || !cafeMatchScheduleUuid}
|
||||||
|
|
|
||||||
93
src/components/SupportButton.tsx
Normal file
93
src/components/SupportButton.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const SUPPORT_PHONE = "02111111111";
|
||||||
|
const SUPPORT_PHONE_DISPLAY = "۰۲۱-۱۱۱۱۱۱۱۱";
|
||||||
|
|
||||||
|
function isMobileDevice() {
|
||||||
|
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupportButton({ className }: { className?: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
window.location.href = `tel:${SUPPORT_PHONE}`
|
||||||
|
} else {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(SUPPORT_PHONE);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// silent fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={
|
||||||
|
className ??
|
||||||
|
"rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
پشتیبانی
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-9998 bg-black/40"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="fixed inset-x-6 top-1/2 z-9999 -translate-y-1/2 rounded-2xl bg-white px-5 shadow-xl"
|
||||||
|
style={{ height: "72px" }}
|
||||||
|
>
|
||||||
|
<div className="flex h-full items-center justify-start gap-3">
|
||||||
|
<p className="text-right text-gray-900">
|
||||||
|
{copied ? (
|
||||||
|
"کپی شد!"
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
شماره تماس پشتیبانی:{" "}
|
||||||
|
<span dir="ltr">{SUPPORT_PHONE_DISPLAY}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label="کپی شماره"
|
||||||
|
className="shrink-0 text-gray-400 transition-colors hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Icon name="copy" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="absolute left-3 top-1/3 -translate-y-1/2 text-black hover:text-gray-600"
|
||||||
|
aria-label="بستن"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,11 +21,13 @@ function MatchSelect({
|
||||||
options,
|
options,
|
||||||
now,
|
now,
|
||||||
onChange,
|
onChange,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
options: MatchEvent[]
|
options: MatchEvent[]
|
||||||
now: Date
|
now: Date
|
||||||
onChange: (uuid: string) => void
|
onChange: (uuid: string) => void
|
||||||
|
error?: string
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const selectedMatch = options.find((match) => match.uuid === value)
|
const selectedMatch = options.find((match) => match.uuid === value)
|
||||||
|
|
@ -38,7 +40,7 @@ function MatchSelect({
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!hasMatches}
|
disabled={!hasMatches}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right disabled:opacity-50"
|
className={`flex h-14 w-full items-center justify-between rounded-2xl border px-4 text-right disabled:opacity-50 ${error ? 'border-red-400' : 'border-gray-200'}`}
|
||||||
>
|
>
|
||||||
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
||||||
{selectedMatch
|
{selectedMatch
|
||||||
|
|
@ -70,6 +72,7 @@ function MatchSelect({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -171,8 +174,24 @@ export default function AddEvent() {
|
||||||
const [entryFee, setEntryFee] = useState(100000)
|
const [entryFee, setEntryFee] = useState(100000)
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const clearFieldError = (field: string) =>
|
||||||
|
setFieldErrors((prev) => {
|
||||||
|
if (!prev[field]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[field]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
if (!selectedMatchUuid) errors.match = 'بازی را انتخاب کنید'
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFieldErrors(errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFieldErrors({})
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await createCafeSchedule(getCafeUuid() ?? '', {
|
await createCafeSchedule(getCafeUuid() ?? '', {
|
||||||
|
|
@ -195,11 +214,17 @@ export default function AddEvent() {
|
||||||
|
|
||||||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
<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" />
|
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||||
<span className="text-lg font-bold text-gray-900">افزودن ایونت</span>
|
<span className="text-lg font-bold text-gray-900">افزودن رویداد</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
|
<MatchSelect
|
||||||
|
value={selectedMatchUuid}
|
||||||
|
options={matches}
|
||||||
|
now={now}
|
||||||
|
onChange={(uuid) => { setSelectedMatchUuid(uuid); clearFieldError('match') }}
|
||||||
|
error={fieldErrors.match}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default function AddEventFail() {
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||||
<Icon name="fail" className="mx-auto h-28 w-28" />
|
<Icon name="fail" className="mx-auto h-28 w-28" />
|
||||||
<h1 className="mt-6 text-xl font-light text-gray-900">ثبت ایونت با خطا مواجه شد. لطفا دوباره تلاش کنید.</h1>
|
<h1 className="mt-6 text-xl font-light text-gray-900">ثبت رویداد با خطا مواجه شد. لطفا دوباره تلاش کنید.</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 flex flex-col gap-3 bg-white px-4 py-4">
|
<div className="fixed inset-x-0 bottom-0 flex flex-col gap-3 bg-white px-4 py-4">
|
||||||
|
|
@ -18,7 +18,7 @@ export default function AddEventFail() {
|
||||||
onClick={() => navigate('/owner/events/new')}
|
onClick={() => navigate('/owner/events/new')}
|
||||||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||||
>
|
>
|
||||||
افزودن ایونت
|
افزودن رویداد
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default function AddEventSuccess() {
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||||
<Icon name="success" className="mx-auto h-28 w-28" />
|
<Icon name="success" className="mx-auto h-28 w-28" />
|
||||||
<h1 className="mt-6 text-xl font-bold text-gray-900">ایونت با موفقیت ثبت شد!</h1>
|
<h1 className="mt-6 text-xl font-bold text-gray-900">رویداد با موفقیت ثبت شد!</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 flex flex-col gap-3 bg-white px-4 py-4">
|
<div className="fixed inset-x-0 bottom-0 flex flex-col gap-3 bg-white px-4 py-4">
|
||||||
|
|
@ -18,7 +18,7 @@ export default function AddEventSuccess() {
|
||||||
onClick={() => navigate('/owner/events/new')}
|
onClick={() => navigate('/owner/events/new')}
|
||||||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||||
>
|
>
|
||||||
افزودن ایونت
|
افزودن رویداد
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
@ -17,56 +18,6 @@ import { fromE164 } from "../utils/phone";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import ReservationModal from "../components/ReservationModal";
|
import ReservationModal from "../components/ReservationModal";
|
||||||
|
|
||||||
function MatchPicker({
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
now,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
options: CafeSchedule[];
|
|
||||||
now: Date;
|
|
||||||
onChange: (uuid: string) => void;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const selected = options.find((s) => s.match_event.uuid === value);
|
|
||||||
|
|
||||||
if (options.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative mt-1 inline-block">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
className="flex items-center gap-1 text-right text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
<span>{selected ? formatMatchLabel(selected.match_event, now) : "بازی را انتخاب کنید"}</span>
|
|
||||||
<Icon name="chevronDown" className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
|
||||||
<div className="absolute right-0 top-full z-20 mt-2 max-h-60 w-64 overflow-y-auto rounded-xl bg-white shadow-lg">
|
|
||||||
{options.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.match_event.uuid}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(s.match_event.uuid);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
{formatMatchLabel(s.match_event, now)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pill({
|
function Pill({
|
||||||
children,
|
children,
|
||||||
|
|
@ -86,50 +37,182 @@ function Pill({
|
||||||
|
|
||||||
function ImageCarousel({ images }: { images: string[] }) {
|
function ImageCarousel({ images }: { images: string[] }) {
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const slideCount = Math.max(images.length, 1);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
|
const lightboxScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const slideCount = images.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lightboxOpen) return;
|
||||||
|
setLightboxIndex(index);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (lightboxScrollRef.current) {
|
||||||
|
lightboxScrollRef.current.scrollLeft =
|
||||||
|
index * lightboxScrollRef.current.clientWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lightboxOpen]);
|
||||||
|
|
||||||
|
const handleLightboxScroll = () => {
|
||||||
|
if (!lightboxScrollRef.current) return;
|
||||||
|
const i = Math.round(
|
||||||
|
lightboxScrollRef.current.scrollLeft /
|
||||||
|
lightboxScrollRef.current.clientWidth,
|
||||||
|
);
|
||||||
|
setLightboxIndex(i);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (slideCount === 0) {
|
||||||
|
return <div className="h-[35vh] w-full bg-gray-200" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[35vh] w-full bg-gray-200">
|
<>
|
||||||
<button
|
<div
|
||||||
type="button"
|
className="relative h-[35vh] w-full cursor-pointer bg-gray-200"
|
||||||
onClick={() => setIndex((i) => (i - 1 + slideCount) % slideCount)}
|
onClick={() => setLightboxOpen(true)}
|
||||||
aria-label="قبلی"
|
|
||||||
className="absolute left-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
|
||||||
>
|
>
|
||||||
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
|
<img
|
||||||
</button>
|
src={images[index]}
|
||||||
<button
|
alt=""
|
||||||
type="button"
|
className="h-full w-full object-cover"
|
||||||
onClick={() => setIndex((i) => (i + 1) % slideCount)}
|
/>
|
||||||
aria-label="بعدی"
|
{slideCount > 1 && (
|
||||||
className="absolute right-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
<>
|
||||||
>
|
<button
|
||||||
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
type="button"
|
||||||
</button>
|
aria-label="قبلی"
|
||||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
|
onClick={(e) => {
|
||||||
{Array.from({ length: slideCount }).map((_, i) => (
|
e.stopPropagation();
|
||||||
<span
|
setIndex((i) => (i - 1 + slideCount) % slideCount);
|
||||||
key={i}
|
}}
|
||||||
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-[#223D5D]" : "bg-white/70"}`}
|
className="absolute left-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
||||||
/>
|
>
|
||||||
))}
|
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="بعدی"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIndex((i) => (i + 1) % slideCount);
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
||||||
|
>
|
||||||
|
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-white" : "bg-white/50"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{lightboxOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div className="fixed inset-0 z-9999 flex flex-col bg-black">
|
||||||
|
<div className="flex shrink-0 items-center justify-between px-4 py-3">
|
||||||
|
<span className="text-sm text-white/60">
|
||||||
|
{lightboxIndex + 1} / {slideCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-lg text-white"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={lightboxScrollRef}
|
||||||
|
dir="ltr"
|
||||||
|
className="flex flex-1 snap-x snap-mandatory overflow-x-auto"
|
||||||
|
style={{ scrollbarWidth: "none" }}
|
||||||
|
onScroll={handleLightboxScroll}
|
||||||
|
>
|
||||||
|
{images.map((url, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex h-full w-full shrink-0 snap-start items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{slideCount > 1 && (
|
||||||
|
<div className="flex shrink-0 items-center justify-center gap-2 py-4">
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
i === lightboxIndex ? "w-4 bg-white" : "w-1.5 bg-white/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNavigationPicker(name: string, lat: number, lng: number) {
|
function NavigationSheet({
|
||||||
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
if (navigator.share) {
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
try {
|
|
||||||
await navigator.share({ title: name, url: mapsUrl });
|
const apps: { label: string; url: string }[] = [
|
||||||
} catch {
|
{ label: "نشان", url: `https://neshan.org/maps/@${lat},${lng},15z` },
|
||||||
// user dismissed the native share sheet
|
{ label: "بلد", url: `https://balad.ir/p?lat=${lat}&lng=${lng}` },
|
||||||
}
|
{ label: "Waze", url: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes` },
|
||||||
} else {
|
{ label: "Google Maps", url: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}` },
|
||||||
window.open(mapsUrl, "_blank");
|
...(isIOS ? [{ label: "Apple Maps", url: `maps://?daddr=${lat},${lng}` }] : []),
|
||||||
}
|
];
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-9998 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-9999 rounded-t-3xl bg-white px-5 pb-8 pt-4">
|
||||||
|
<div className="mx-auto mb-4 h-1 w-10 rounded-full bg-gray-200" />
|
||||||
|
<h3 className="mb-4 text-right font-semibold text-gray-900">مسیریابی با</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<button
|
||||||
|
key={app.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { window.open(app.url, "_blank"); onClose(); }}
|
||||||
|
className="w-full rounded-2xl border border-gray-200 py-3 text-center text-gray-900 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
{app.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CafeDetail() {
|
export default function CafeDetail() {
|
||||||
|
|
@ -137,19 +220,29 @@ export default function CafeDetail() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const now = useNow();
|
const now = useNow();
|
||||||
const [isReservationOpen, setIsReservationOpen] = useState(false);
|
const [isReservationOpen, setIsReservationOpen] = useState(false);
|
||||||
|
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||||
const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
|
const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
|
||||||
const [selectedEventUuid, setSelectedEventUuid] = useState("");
|
const [selectedEventUuid] = useState("");
|
||||||
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(undefined);
|
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const state = location.state as { cafe?: Cafe } | null;
|
const state = location.state as { cafe?: Cafe } | null;
|
||||||
const cafe = state?.cafe;
|
const cafe = state?.cafe;
|
||||||
|
|
||||||
// only ever offer upcoming matches — picking a past one to "reserve" makes no sense
|
// only ever offer upcoming matches — picking a past one to "reserve" makes no sense
|
||||||
const upcomingSchedules = schedules
|
const upcomingSchedules = schedules
|
||||||
.filter((s) => new Date(s.match_event.match_datetime).getTime() > now.getTime())
|
.filter(
|
||||||
.sort((a, b) => new Date(a.match_event.match_datetime).getTime() - new Date(b.match_event.match_datetime).getTime());
|
(s) => new Date(s.match_event.match_datetime).getTime() > now.getTime(),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.match_event.match_datetime).getTime() -
|
||||||
|
new Date(b.match_event.match_datetime).getTime(),
|
||||||
|
);
|
||||||
// defaults to the closest upcoming match, but the user can override via MatchPicker
|
// defaults to the closest upcoming match, but the user can override via MatchPicker
|
||||||
const effectiveEventUuid = selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
|
const effectiveEventUuid =
|
||||||
|
selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cafe) return;
|
if (!cafe) return;
|
||||||
|
|
@ -173,9 +266,14 @@ export default function CafeDetail() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const images = cafe.media.filter((m) => m.type === "image").map((m) => m.url);
|
const images = (schedule?.cafe_media ?? cafe.media)
|
||||||
|
.filter((m) => m.type === "image")
|
||||||
|
.map((m) => m.url);
|
||||||
const tags = cafe.features.map((f) => f.name);
|
const tags = cafe.features.map((f) => f.name);
|
||||||
const mapPosition: [number, number] = [cafe.location.coordinates[1], cafe.location.coordinates[0]];
|
const mapPosition: [number, number] = [
|
||||||
|
cafe.location.coordinates[1],
|
||||||
|
cafe.location.coordinates[0],
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
|
|
@ -185,9 +283,9 @@ export default function CafeDetail() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
aria-label="بازگشت"
|
aria-label="بازگشت"
|
||||||
className="absolute left-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
|
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
|
||||||
>
|
>
|
||||||
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
|
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -195,21 +293,22 @@ export default function CafeDetail() {
|
||||||
<h1 className="text-right text-xl font-bold text-gray-900">
|
<h1 className="text-right text-xl font-bold text-gray-900">
|
||||||
{cafe.name}
|
{cafe.name}
|
||||||
</h1>
|
</h1>
|
||||||
<MatchPicker
|
{upcomingSchedules[0] && (
|
||||||
value={effectiveEventUuid}
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
options={upcomingSchedules}
|
{formatMatchLabel(upcomingSchedules[0].match_event, now)}
|
||||||
now={now}
|
</p>
|
||||||
onChange={setSelectedEventUuid}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2 ">
|
<div className="mt-3 flex flex-wrap gap-2 ">
|
||||||
{schedule && (
|
{schedule && (
|
||||||
<>
|
<>
|
||||||
<Pill className="bg-[#ECF2F8] text-gray-700">
|
<Pill className="bg-[#ECF2F8] text-gray-700">
|
||||||
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان
|
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))}{" "}
|
||||||
|
تومان
|
||||||
</Pill>
|
</Pill>
|
||||||
<Pill className="bg-[#ECF2F8] text-gray-700">
|
<Pill className="bg-[#ECF2F8] text-gray-700">
|
||||||
ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر
|
ظرفیت باقی مانده:{" "}
|
||||||
|
{formatPersianNumber(schedule.remaining_tickets)}نفر
|
||||||
</Pill>
|
</Pill>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -220,6 +319,13 @@ export default function CafeDetail() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{schedule?.description && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h2 className="mb-2 text-right font-semibold text-gray-900">توضیحات</h2>
|
||||||
|
<p className="text-right leading-relaxed text-gray-600">{schedule.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2 className="mt-4 mb-2 text-right font-semibold text-gray-900">
|
<h2 className="mt-4 mb-2 text-right font-semibold text-gray-900">
|
||||||
آدرس
|
آدرس
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -260,7 +366,7 @@ export default function CafeDetail() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openNavigationPicker(cafe.name, mapPosition[0], mapPosition[1])}
|
onClick={() => setIsNavigationOpen(true)}
|
||||||
className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900"
|
className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900"
|
||||||
>
|
>
|
||||||
مسیریابی
|
مسیریابی
|
||||||
|
|
@ -270,8 +376,8 @@ export default function CafeDetail() {
|
||||||
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||||
<p className="text-right text-sm text-gray-600">
|
<p className="text-right text-sm text-gray-600">
|
||||||
توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
|
پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت تمایل
|
||||||
کاربر است.
|
به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -293,6 +399,13 @@ export default function CafeDetail() {
|
||||||
cafe={cafe}
|
cafe={cafe}
|
||||||
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
|
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
|
||||||
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
|
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
|
||||||
|
remainingTickets={schedule?.remaining_tickets ?? 0}
|
||||||
|
/>
|
||||||
|
<NavigationSheet
|
||||||
|
isOpen={isNavigationOpen}
|
||||||
|
onClose={() => setIsNavigationOpen(false)}
|
||||||
|
lat={mapPosition[0]}
|
||||||
|
lng={mapPosition[1]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default function CafeMapPage() {
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="absolute right-4 top-4 z-[1000] flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-lg"
|
className="absolute right-4 top-4 z-[1000] flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-lg"
|
||||||
>
|
>
|
||||||
<Icon name="arrowRight" className="h-5 w-5 text-gray-700" />
|
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,136 @@
|
||||||
import { ReactNode, useEffect, useState } from 'react'
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from "react-router-dom";
|
||||||
import Icon from '../components/Icon'
|
import Icon from "../components/Icon";
|
||||||
import { useNow } from '../hooks/useNow'
|
import SupportButton from "../components/SupportButton";
|
||||||
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
|
import LogoutModal from "../components/LogoutModal";
|
||||||
import { getCafeUuid } from '../utils/cafe'
|
import { logout } from "../utils/logout";
|
||||||
import HostBanner from '../assets/images/banners/Host-banner.jpg'
|
import { useNow } from "../hooks/useNow";
|
||||||
import { formatPersianNumber } from '../utils/formatNumber'
|
import { CafeSchedule, fetchCafeSchedules } from "../services/campaignApi";
|
||||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
import { getCafeUuid } from "../utils/cafe";
|
||||||
|
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||||
|
import { formatPersianNumber } from "../utils/formatNumber";
|
||||||
|
import { formatMatchLabel } from "../utils/matchCountdown";
|
||||||
|
|
||||||
function PanelRow({ icon, label, onClick }: { icon: ReactNode; label: string; onClick?: () => void }) {
|
function PanelRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} className="flex w-full items-center justify-between py-4">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center justify-between py-4"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<span className="text-gray-900">{label}</span>
|
<span className="text-gray-900">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Icon name="arrowRight" className="h-5 w-5 text-gray-400" />
|
<Icon name="arrowRight" className="h-5 w-5 text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CafeOwnerPanel() {
|
export default function CafeOwnerPanel() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const now = useNow()
|
const now = useNow();
|
||||||
const [schedules, setSchedules] = useState<CafeSchedule[]>([])
|
const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
|
||||||
|
const [showLogout, setShowLogout] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cafeUuid = getCafeUuid()
|
const cafeUuid = getCafeUuid();
|
||||||
if (!cafeUuid) return
|
if (!cafeUuid) return;
|
||||||
fetchCafeSchedules(cafeUuid)
|
fetchCafeSchedules(cafeUuid)
|
||||||
.then(setSchedules)
|
.then(setSchedules)
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white pb-20">
|
<div className="min-h-screen bg-white pb-20">
|
||||||
<img src={HostBanner} alt="" className="w-full" />
|
<img src={HostBanner} alt="" className="w-full" />
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLogout(true)}
|
||||||
|
className="absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-white/90 px-3 py-1.5 text-sm font-semibold text-gray-800 shadow"
|
||||||
|
>
|
||||||
|
<Icon name="logout" className="h-4 w-4 font-medium" />
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLogout && (
|
||||||
|
<LogoutModal
|
||||||
|
onConfirm={() => {
|
||||||
|
logout();
|
||||||
|
navigate("/cafe/login");
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowLogout(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<h1 className="mt-4 text-right text-lg font-bold text-gray-900">پنل کافه</h1>
|
<h1 className="mt-4 text-right text-lg font-bold text-gray-900">
|
||||||
|
پنل کافه
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2 divide-y divide-gray-100">
|
<div className="mt-2 divide-y divide-gray-100">
|
||||||
<PanelRow
|
<PanelRow
|
||||||
icon={<Icon name="profileCircle" className="h-5 w-5 text-[#4D4D4D]" />}
|
icon={
|
||||||
|
<Icon name="profileCircle" className="h-5 w-5 text-[#4D4D4D]" />
|
||||||
|
}
|
||||||
label="اطلاعات کافه"
|
label="اطلاعات کافه"
|
||||||
onClick={() => navigate('/owner/register', { state: { step: 2 } })}
|
onClick={() => navigate("/owner/register", { state: { step: 2 } })}
|
||||||
|
/>
|
||||||
|
<PanelRow
|
||||||
|
icon={<Icon name="book" className="h-5 w-5 text-[#4D4D4D]" />}
|
||||||
|
label="افزودن رویداد"
|
||||||
|
onClick={() => navigate("/owner/events/new")}
|
||||||
/>
|
/>
|
||||||
<PanelRow icon={<Icon name="book" className="h-5 w-5 text-[#4D4D4D]" />} label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mt-6 text-right font-semibold text-gray-900">رویدادهای فعال شما</h2>
|
<h2 className="mt-6 text-right font-semibold text-gray-900">
|
||||||
|
رویدادهای فعال شما
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-6">
|
<div className="mt-3 flex flex-col gap-6">
|
||||||
{schedules.map((schedule) => {
|
{schedules.map((schedule) => {
|
||||||
const isPast = new Date(schedule.match_event.match_datetime).getTime() <= now.getTime()
|
const isPast =
|
||||||
const matchLabel = formatMatchLabel(schedule.match_event, now)
|
new Date(schedule.match_event.match_datetime).getTime() <=
|
||||||
|
now.getTime();
|
||||||
|
const matchLabel = formatMatchLabel(schedule.match_event, now);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={schedule.uuid}
|
key={schedule.uuid}
|
||||||
className={`rounded-2xl px-4 shadow-[0px_4px_12px_0px_#0000001F] ${isPast ? 'opacity-50' : ''}`}
|
className={`rounded-2xl px-4 shadow-[0px_4px_12px_0px_#0000001F] ${isPast ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<div className="flex items-center justify-between pb-3">
|
<div className="flex items-center justify-between pb-3">
|
||||||
<p className="text-right text-gray-900">{matchLabel}</p>
|
<p className="text-right text-gray-900">{matchLabel}</p>
|
||||||
{isPast && (
|
{isPast && (
|
||||||
<span className="shrink-0 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500">برگزار شده</span>
|
<span className="shrink-0 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500">
|
||||||
|
برگزار شده
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-2 inline-block rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700">
|
<span className="mt-2 inline-block rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700">
|
||||||
{formatPersianNumber(schedule.remaining_tickets)} صندلی از {formatPersianNumber(schedule.total_tickets)} باقی مانده
|
{formatPersianNumber(schedule.remaining_tickets)} صندلی از{" "}
|
||||||
|
{formatPersianNumber(schedule.total_tickets)} باقی مانده
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<PanelRow
|
<PanelRow
|
||||||
icon={<Icon name="calendar" className="h-5 w-5 text-[#4D4D4D]" />}
|
icon={
|
||||||
|
<Icon
|
||||||
|
name="calendar"
|
||||||
|
className="h-5 w-5 text-[#4D4D4D]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="مشاهده رزروها"
|
label="مشاهده رزروها"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/owner/events/${schedule.uuid}/reservations`, {
|
navigate(`/owner/events/${schedule.uuid}/reservations`, {
|
||||||
|
|
@ -91,14 +147,12 @@ export default function CafeOwnerPanel() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg">
|
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
|
||||||
پشتیبانی
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import heic2any from "heic2any";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
|
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import "../utils/leafletIcons";
|
import "../utils/leafletIcons";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import SupportButton from "../components/SupportButton";
|
||||||
import {
|
import {
|
||||||
CafeFeature,
|
CafeFeature,
|
||||||
CafeMediaItem,
|
CafeMediaItem,
|
||||||
|
|
@ -34,12 +36,16 @@ function FormField({
|
||||||
hint,
|
hint,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
inputDir = "ltr",
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
inputDir?: "ltr" | "rtl";
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|
@ -48,14 +54,18 @@ function FormField({
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
dir={inputDir}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]"
|
className={`w-full rounded-2xl border px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999] ${
|
||||||
|
error ? "border-red-400" : "border-gray-200"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
{hint && (
|
{error
|
||||||
<p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
|
? <p className="mt-1 text-right text-sm text-red-500">{error}</p>
|
||||||
)}
|
: hint && <p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +198,23 @@ function CafeLocationMap({
|
||||||
|
|
||||||
const MAX_PHOTOS = 10;
|
const MAX_PHOTOS = 10;
|
||||||
|
|
||||||
|
async function createPreviewUrl(file: File): Promise<string> {
|
||||||
|
const isHeic =
|
||||||
|
/\.(heic|heif)$/i.test(file.name) ||
|
||||||
|
file.type === "image/heic" ||
|
||||||
|
file.type === "image/heif";
|
||||||
|
if (isHeic) {
|
||||||
|
try {
|
||||||
|
const converted = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.85 });
|
||||||
|
const blob = Array.isArray(converted) ? converted[0] : converted;
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
// fall back to original blob URL if conversion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
function ImgWithHeicFallback({
|
function ImgWithHeicFallback({
|
||||||
src,
|
src,
|
||||||
className,
|
className,
|
||||||
|
|
@ -232,10 +259,12 @@ function PhotoUpload({
|
||||||
photos,
|
photos,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
photos: UploadedPhoto[];
|
photos: UploadedPhoto[];
|
||||||
onAdd: (files: File[]) => void;
|
onAdd: (files: File[]) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
@ -330,6 +359,7 @@ function PhotoUpload({
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -338,10 +368,12 @@ function DocumentUpload({
|
||||||
doc,
|
doc,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
doc: UploadedDocument | null;
|
doc: UploadedDocument | null;
|
||||||
onAdd: (file: File) => void;
|
onAdd: (file: File) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
@ -421,6 +453,7 @@ function DocumentUpload({
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -473,7 +506,16 @@ export default function CafeOwnerRegister() {
|
||||||
const [photos, setPhotos] = useState<UploadedPhoto[]>([]);
|
const [photos, setPhotos] = useState<UploadedPhoto[]>([]);
|
||||||
const [licenseDoc, setLicenseDoc] = useState<UploadedDocument | null>(null);
|
const [licenseDoc, setLicenseDoc] = useState<UploadedDocument | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [submitError, setSubmitError] = useState("");
|
||||||
|
|
||||||
|
const clearFieldError = (field: string) =>
|
||||||
|
setFieldErrors((prev) => {
|
||||||
|
if (!prev[field]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// keep refs so the unmount cleanup can revoke any remaining object URLs
|
// keep refs so the unmount cleanup can revoke any remaining object URLs
|
||||||
const photosRef = useRef(photos);
|
const photosRef = useRef(photos);
|
||||||
|
|
@ -564,12 +606,14 @@ export default function CafeOwnerRegister() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddPhotos = async (files: File[]) => {
|
const handleAddPhotos = async (files: File[]) => {
|
||||||
const newPhotos: UploadedPhoto[] = files.map((file) => ({
|
const newPhotos: UploadedPhoto[] = await Promise.all(
|
||||||
id: crypto.randomUUID(),
|
files.map(async (file) => ({
|
||||||
file,
|
id: crypto.randomUUID(),
|
||||||
localUrl: URL.createObjectURL(file),
|
file,
|
||||||
status: "uploading",
|
localUrl: await createPreviewUrl(file),
|
||||||
}));
|
status: "uploading" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
setPhotos((prev) => [...prev, ...newPhotos]);
|
setPhotos((prev) => [...prev, ...newPhotos]);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|
@ -609,7 +653,7 @@ export default function CafeOwnerRegister() {
|
||||||
const handleAddLicense = async (file: File) => {
|
const handleAddLicense = async (file: File) => {
|
||||||
const newDoc: UploadedDocument = {
|
const newDoc: UploadedDocument = {
|
||||||
file,
|
file,
|
||||||
localUrl: URL.createObjectURL(file),
|
localUrl: await createPreviewUrl(file),
|
||||||
status: "uploading",
|
status: "uploading",
|
||||||
};
|
};
|
||||||
setLicenseDoc(newDoc);
|
setLicenseDoc(newDoc);
|
||||||
|
|
@ -639,24 +683,33 @@ export default function CafeOwnerRegister() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = async () => {
|
const handleContinue = async () => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!cafeName.trim()) errors.cafeName = "نام کافه الزامی است";
|
||||||
|
if (!address.trim()) errors.address = "آدرس الزامی است";
|
||||||
|
if (!cafePhone.trim()) errors.cafePhone = "شماره تماس الزامی است";
|
||||||
|
if (selectedFeatures.length === 0) errors.features = "حداقل یک ویژگی انتخاب کنید";
|
||||||
|
|
||||||
const stillUploading =
|
const stillUploading =
|
||||||
photos.some((p) => p.status === "uploading") ||
|
photos.some((p) => p.status === "uploading") ||
|
||||||
licenseDoc?.status === "uploading";
|
licenseDoc?.status === "uploading";
|
||||||
|
|
||||||
if (stillUploading) {
|
if (stillUploading) {
|
||||||
setError("لطفاً منتظر بمانید تا آپلود فایلها کامل شود.");
|
errors.photos = "لطفاً منتظر بمانید تا آپلود فایلها کامل شود";
|
||||||
|
} else {
|
||||||
|
if (!photos.some((p) => p.status === "done"))
|
||||||
|
errors.photos = "حداقل یک تصویر از کافه الزامی است";
|
||||||
|
if (!licenseDoc || licenseDoc.status !== "done")
|
||||||
|
errors.licenseDoc = "تصویر مجوز کسب و کار الزامی است";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFieldErrors(errors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isEditMode) {
|
|
||||||
if (!photos.some((p) => p.status === "done")) {
|
setFieldErrors({});
|
||||||
setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید.");
|
setSubmitError("");
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!licenseDoc || licenseDoc.status !== "done") {
|
|
||||||
setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const mediaItems: CafeMediaItem[] = [
|
const mediaItems: CafeMediaItem[] = [
|
||||||
|
|
@ -696,7 +749,7 @@ export default function CafeOwnerRegister() {
|
||||||
if (!isEditMode) {
|
if (!isEditMode) {
|
||||||
navigate("/owner/register/fail");
|
navigate("/owner/register/fail");
|
||||||
} else {
|
} else {
|
||||||
setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
setSubmitError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -712,7 +765,8 @@ export default function CafeOwnerRegister() {
|
||||||
label="نام کافه"
|
label="نام کافه"
|
||||||
placeholder="نام کافه را وارد کنید"
|
placeholder="نام کافه را وارد کنید"
|
||||||
value={cafeName}
|
value={cafeName}
|
||||||
onChange={setCafeName}
|
onChange={(v) => { setCafeName(v); clearFieldError("cafeName"); }}
|
||||||
|
error={fieldErrors.cafeName}
|
||||||
/>
|
/>
|
||||||
<CitySelectField
|
<CitySelectField
|
||||||
value={effectiveCityUuid}
|
value={effectiveCityUuid}
|
||||||
|
|
@ -723,7 +777,8 @@ export default function CafeOwnerRegister() {
|
||||||
label="آدرس"
|
label="آدرس"
|
||||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||||
value={address}
|
value={address}
|
||||||
onChange={setAddress}
|
onChange={(v) => { setAddress(v); clearFieldError("address"); }}
|
||||||
|
error={fieldErrors.address}
|
||||||
/>
|
/>
|
||||||
<CafeLocationMap
|
<CafeLocationMap
|
||||||
label="موقعیت روی نقشه"
|
label="موقعیت روی نقشه"
|
||||||
|
|
@ -732,20 +787,24 @@ export default function CafeOwnerRegister() {
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="شماره تماس کافه"
|
label="شماره تماس کافه"
|
||||||
placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
|
placeholder={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`}
|
||||||
hint="شماره را همراه با پیششماره شهر وارد کنید."
|
hint="شماره را همراه با پیششماره شهر وارد کنید."
|
||||||
value={cafePhone}
|
value={cafePhone}
|
||||||
onChange={setCafePhone}
|
onChange={(v) => { setCafePhone(v); clearFieldError("cafePhone"); }}
|
||||||
|
inputDir="ltr"
|
||||||
|
error={fieldErrors.cafePhone}
|
||||||
/>
|
/>
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
photos={photos}
|
photos={photos}
|
||||||
onAdd={handleAddPhotos}
|
onAdd={(files) => { clearFieldError("photos"); handleAddPhotos(files); }}
|
||||||
onRemove={handleRemovePhoto}
|
onRemove={handleRemovePhoto}
|
||||||
|
error={fieldErrors.photos}
|
||||||
/>
|
/>
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
doc={licenseDoc}
|
doc={licenseDoc}
|
||||||
onAdd={handleAddLicense}
|
onAdd={(file) => { clearFieldError("licenseDoc"); handleAddLicense(file); }}
|
||||||
onRemove={handleRemoveLicense}
|
onRemove={handleRemoveLicense}
|
||||||
|
error={fieldErrors.licenseDoc}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|
@ -758,24 +817,22 @@ export default function CafeOwnerRegister() {
|
||||||
key={feature.uuid}
|
key={feature.uuid}
|
||||||
label={feature.name}
|
label={feature.name}
|
||||||
checked={selectedFeatures.includes(feature.uuid)}
|
checked={selectedFeatures.includes(feature.uuid)}
|
||||||
onToggle={() => toggleFeature(feature.uuid)}
|
onToggle={() => { toggleFeature(feature.uuid); clearFieldError("features"); }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{fieldErrors.features && (
|
||||||
|
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.features}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{submitError && (
|
||||||
<p className="mt-4 text-right text-sm text-red-500">{error}</p>
|
<p className="mt-4 text-right text-sm text-red-500">{submitError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
|
||||||
<button
|
<SupportButton className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white" />
|
||||||
type="button"
|
|
||||||
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
|
||||||
>
|
|
||||||
پشتیبانی
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default function CafeRegisterSuccess() {
|
||||||
<Icon name="success" className="mx-auto h-28 w-28" />
|
<Icon name="success" className="mx-auto h-28 w-28" />
|
||||||
<h1 className="mt-6 text-xl font-bold text-gray-900">کافه شما با موفقیت ثبت شد!</h1>
|
<h1 className="mt-6 text-xl font-bold text-gray-900">کافه شما با موفقیت ثبت شد!</h1>
|
||||||
<p className="mt-3 leading-relaxed text-gray-500">
|
<p className="mt-3 leading-relaxed text-gray-500">
|
||||||
با اضافه کردن ایونت بازیها، کافه شما در کمپین نمایش داده میشود و کاربران میتوانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.
|
با اضافه کردن رویداد بازیها، کافه شما در کمپین نمایش داده میشود و کاربران میتوانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function CafeRegisterSuccess() {
|
||||||
onClick={() => navigate('/owner/events/new')}
|
onClick={() => navigate('/owner/events/new')}
|
||||||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||||
>
|
>
|
||||||
افزودن ایونت
|
افزودن رویداد
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
|
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
|
||||||
import { EventReservation, fetchEventReservations } from "../services/campaignApi";
|
import {
|
||||||
|
EventReservation,
|
||||||
|
fetchEventReservations,
|
||||||
|
participateReservation,
|
||||||
|
} from "../services/campaignApi";
|
||||||
import { getCafeUuid } from "../utils/cafe";
|
import { getCafeUuid } from "../utils/cafe";
|
||||||
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||||
import { formatPersianNumber } from "../utils/formatNumber";
|
import { formatPersianNumber } from "../utils/formatNumber";
|
||||||
|
import { fromE164 } from "../utils/phone";
|
||||||
|
|
||||||
function toEnglishDigits(value: string) {
|
function toEnglishDigits(value: string) {
|
||||||
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
||||||
|
|
@ -47,13 +52,14 @@ function downloadReservationsCsv(reservations: EventReservation[]) {
|
||||||
|
|
||||||
function ReservationCard({
|
function ReservationCard({
|
||||||
reservation,
|
reservation,
|
||||||
selected,
|
isToggling,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
reservation: EventReservation;
|
reservation: EventReservation;
|
||||||
selected: boolean;
|
isToggling: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const participated = reservation.is_participated;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] p-6">
|
<div className="flex items-center justify-between rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] p-6">
|
||||||
<div className="flex flex-col items-start gap-2 text-right">
|
<div className="flex flex-col items-start gap-2 text-right">
|
||||||
|
|
@ -66,15 +72,19 @@ function ReservationCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-900">{reservation.full_name}</p>
|
<p className="text-gray-900">{reservation.full_name}</p>
|
||||||
<p className="text-gray-500">{reservation.phone_number}</p>
|
<p dir="ltr" className="text-left text-gray-500">{fromE164(reservation.phone_number)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center self-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={isToggling}
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[1.5px] border-[#223D5D] transition-colors disabled:opacity-50 ${participated ? "bg-[#223D5D]" : "bg-transparent"}`}
|
||||||
|
>
|
||||||
|
{participated && <Icon name="check" className="h-3 w-3 text-white" />}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-black">تایید ورود</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${selected ? "bg-[#223D5D]" : "bg-transparent"}`}
|
|
||||||
>
|
|
||||||
{selected && <Icon name="check" className="h-3 w-3 text-white" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +94,7 @@ export default function EventReservations() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
|
||||||
const [reservations, setReservations] = useState<EventReservation[]>([]);
|
const [reservations, setReservations] = useState<EventReservation[]>([]);
|
||||||
|
|
||||||
const event: CafeEvent | undefined =
|
const event: CafeEvent | undefined =
|
||||||
|
|
@ -107,20 +117,28 @@ export default function EventReservations() {
|
||||||
[reservations, query],
|
[reservations, query],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSelected = (reservationId: string) => {
|
const handleParticipate = async (reservation: EventReservation) => {
|
||||||
setSelectedIds((prev) =>
|
if (togglingIds.has(reservation.uuid)) return;
|
||||||
prev.includes(reservationId)
|
const newValue = !reservation.is_participated;
|
||||||
? prev.filter((id) => id !== reservationId)
|
setTogglingIds((prev) => new Set([...prev, reservation.uuid]));
|
||||||
: [...prev, reservationId],
|
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 = () => {
|
const handleDownload = () => {
|
||||||
const selectedReservations =
|
downloadReservationsCsv(reservations);
|
||||||
selectedIds.length > 0
|
|
||||||
? reservations.filter((r) => selectedIds.includes(r.uuid))
|
|
||||||
: reservations;
|
|
||||||
downloadReservationsCsv(selectedReservations);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -132,7 +150,10 @@ export default function EventReservations() {
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
|
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" />
|
<Icon
|
||||||
|
name="arrowRight"
|
||||||
|
className="h-5 w-5 -scale-x-100 text-gray-900"
|
||||||
|
/>
|
||||||
<span className="text-md font-semibold text-gray-900">
|
<span className="text-md font-semibold text-gray-900">
|
||||||
رزروهای {event?.matchLabel ?? ""}
|
رزروهای {event?.matchLabel ?? ""}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -157,8 +178,8 @@ export default function EventReservations() {
|
||||||
<ReservationCard
|
<ReservationCard
|
||||||
key={reservation.uuid}
|
key={reservation.uuid}
|
||||||
reservation={reservation}
|
reservation={reservation}
|
||||||
selected={selectedIds.includes(reservation.uuid)}
|
isToggling={togglingIds.has(reservation.uuid)}
|
||||||
onToggle={() => toggleSelected(reservation.uuid)}
|
onToggle={() => handleParticipate(reservation)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { useEffect, useState } from 'react'
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import '../utils/leafletIcons'
|
import '../utils/leafletIcons'
|
||||||
import Icon from '../components/Icon'
|
import Icon from '../components/Icon'
|
||||||
import HomeBanner from '../components/HomeBanner'
|
import HomeBanner from '../components/HomeBanner'
|
||||||
|
import { logout } from '../utils/logout'
|
||||||
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
|
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
|
||||||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||||
|
|
@ -77,7 +81,7 @@ function CafesMapPreview({ cafes }: { cafes: Cafe[] }) {
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
{/* transparent overlay — captures all clicks and opens the full-page map */}
|
{/* transparent overlay — captures all clicks and opens the full-page map */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-[1000] cursor-pointer"
|
className="absolute inset-0 z-1000 cursor-pointer"
|
||||||
onClick={() => navigate('/map', { state: { cafes } })}
|
onClick={() => navigate('/map', { state: { cafes } })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,15 +100,26 @@ function CitySelectorRow({
|
||||||
onChange: (uuid: string) => void
|
onChange: (uuid: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 })
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
const selectedCity = options.find((city) => city.uuid === value)
|
const selectedCity = options.find((city) => city.uuid === value)
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (!open && buttonRef.current) {
|
||||||
|
const r = buttonRef.current.getBoundingClientRect()
|
||||||
|
setDropPos({ top: r.bottom + 8, left: r.left, width: r.width })
|
||||||
|
}
|
||||||
|
setOpen((o) => !o)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative px-4 mt-4">
|
<div className="mt-4 px-4">
|
||||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
<p className="mb-1 text-sm text-gray-500">{label}</p>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={handleOpen}
|
||||||
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
|
<span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
|
||||||
<Icon
|
<Icon
|
||||||
|
|
@ -113,26 +128,28 @@ function CitySelectorRow({
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
<>
|
createPortal(
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
<>
|
||||||
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
|
||||||
{options.map((city) => (
|
<div
|
||||||
<button
|
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg"
|
||||||
key={city.uuid}
|
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
||||||
type="button"
|
>
|
||||||
onClick={() => {
|
{options.map((city) => (
|
||||||
onChange(city.uuid)
|
<button
|
||||||
setOpen(false)
|
key={city.uuid}
|
||||||
}}
|
type="button"
|
||||||
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
onClick={() => { onChange(city.uuid); setOpen(false) }}
|
||||||
>
|
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
||||||
{city.city_name}
|
>
|
||||||
</button>
|
{city.city_name}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -151,17 +168,28 @@ function MatchSelectorRow({
|
||||||
onChange: (uuid: string) => void
|
onChange: (uuid: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 })
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
const selectedMatch = options.find((match) => match.uuid === value)
|
const selectedMatch = options.find((match) => match.uuid === value)
|
||||||
const hasMatches = options.length > 0
|
const hasMatches = options.length > 0
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (!open && buttonRef.current) {
|
||||||
|
const r = buttonRef.current.getBoundingClientRect()
|
||||||
|
setDropPos({ top: r.bottom + 8, left: r.left, width: r.width })
|
||||||
|
}
|
||||||
|
setOpen((o) => !o)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative px-4 mt-4">
|
<div className="mt-4 px-4">
|
||||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
<p className="mb-1 text-sm text-gray-500">{label}</p>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!hasMatches}
|
disabled={!hasMatches}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={handleOpen}
|
||||||
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
|
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
|
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
|
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
|
||||||
|
|
@ -172,26 +200,28 @@ function MatchSelectorRow({
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && hasMatches && (
|
{open && hasMatches &&
|
||||||
<>
|
createPortal(
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
<>
|
||||||
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
|
||||||
{options.map((match) => (
|
<div
|
||||||
<button
|
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg"
|
||||||
key={match.uuid}
|
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
||||||
type="button"
|
>
|
||||||
onClick={() => {
|
{options.map((match) => (
|
||||||
onChange(match.uuid)
|
<button
|
||||||
setOpen(false)
|
key={match.uuid}
|
||||||
}}
|
type="button"
|
||||||
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
onClick={() => { onChange(match.uuid); setOpen(false) }}
|
||||||
>
|
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
||||||
{formatMatchLabel(match, now)}
|
>
|
||||||
</button>
|
{formatMatchLabel(match, now)}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +247,7 @@ function MyReservationsRow() {
|
||||||
function CafeCard({ cafe }: { cafe: Cafe }) {
|
function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
|
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
|
||||||
|
const firstImage = cafe.media.find((m) => m.type === 'image')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -224,7 +255,11 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||||
onClick={() => navigate(`/cafe/${cafe.uuid}`, { state: { cafe } })}
|
onClick={() => navigate(`/cafe/${cafe.uuid}`, { state: { cafe } })}
|
||||||
className="flex w-full items-center justify-start gap-4 rounded-2xl bg-white p-4 text-right shadow-sm"
|
className="flex w-full items-center justify-start gap-4 rounded-2xl bg-white p-4 text-right shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="h-32 w-36 shrink-0 rounded-xl bg-gray-200" />
|
<div className="h-32 w-36 shrink-0 overflow-hidden rounded-xl bg-gray-200">
|
||||||
|
{firstImage && (
|
||||||
|
<img src={firstImage.url} alt="" className="h-full w-full object-cover" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-col items-start gap-2">
|
<div className="flex flex-col items-start gap-2">
|
||||||
<h3 className="font-semibold text-gray-900">{cafe.name}</h3>
|
<h3 className="font-semibold text-gray-900">{cafe.name}</h3>
|
||||||
{remainingTickets !== undefined && (
|
{remainingTickets !== undefined && (
|
||||||
|
|
@ -238,10 +273,12 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [view, setView] = useState<'list' | 'map'>('list')
|
const [view, setView] = useState<'list' | 'map'>('list')
|
||||||
const [cities, setCities] = useState<City[]>([])
|
const [cities, setCities] = useState<City[]>([])
|
||||||
const [selectedCityUuid, setSelectedCityUuid] = useState('')
|
const [selectedCityUuid, setSelectedCityUuid] = useState('')
|
||||||
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||||
|
const [showLogout, setShowLogout] = useState(false)
|
||||||
const { matches, now } = useMatchEvents()
|
const { matches, now } = useMatchEvents()
|
||||||
const [cafes, setCafes] = useState<Cafe[]>([])
|
const [cafes, setCafes] = useState<Cafe[]>([])
|
||||||
|
|
||||||
|
|
@ -268,7 +305,24 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white pb-24">
|
<div className="min-h-screen bg-white pb-24">
|
||||||
<HomeBanner />
|
<div className="relative">
|
||||||
|
<HomeBanner />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLogout(true)}
|
||||||
|
className="absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-white/90 px-3 py-1.5 text-sm font-semibold text-gray-800 shadow"
|
||||||
|
>
|
||||||
|
<Icon name="logout" className="h-4 w-4" />
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLogout && (
|
||||||
|
<LogoutModal
|
||||||
|
onConfirm={() => { logout(); navigate('/login') }}
|
||||||
|
onCancel={() => setShowLogout(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<MyReservationsRow />
|
<MyReservationsRow />
|
||||||
|
|
||||||
|
|
@ -319,12 +373,7 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
|
||||||
type="button"
|
|
||||||
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
|
|
||||||
>
|
|
||||||
پشتیبانی
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,29 @@ import { useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { requestOtp } from "../services/authApi";
|
import { requestOtp } from "../services/authApi";
|
||||||
import { toPersianDigits } from "../utils/formatNumber";
|
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
|
||||||
import { toE164 } from "../utils/phone";
|
import { toE164 } from "../utils/phone";
|
||||||
import { setUserRole } from "../utils/role";
|
import { setUserRole } from "../utils/role";
|
||||||
|
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
|
||||||
|
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||||
|
|
||||||
|
interface ApiErrorResponse {
|
||||||
|
status_message?: string;
|
||||||
|
details?: { message?: Record<string, string[]> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiError(error: unknown): string {
|
||||||
|
const data = (error as { response?: { data?: ApiErrorResponse } })?.response
|
||||||
|
?.data;
|
||||||
|
if (data?.status_message) return data.status_message;
|
||||||
|
return "ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const initialPhone = (location.state as { phone?: string } | null)?.phone ?? "";
|
const initialPhone =
|
||||||
|
(location.state as { phone?: string } | null)?.phone ?? "";
|
||||||
const isOwnerFlow = location.pathname.startsWith("/cafe/login");
|
const isOwnerFlow = location.pathname.startsWith("/cafe/login");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -21,77 +36,113 @@ export default function Login() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const canContinue = phone.trim().length > 0 && acceptedTerms && !isSubmitting;
|
const canContinue = phone.length === 11 && acceptedTerms && !isSubmitting;
|
||||||
|
|
||||||
const handleContinue = async () => {
|
const handleContinue = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
|
if (phone.length !== 11) {
|
||||||
|
setError("شماره تلفن باید ۱۱ رقم باشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const { ttl } = await requestOtp(toE164(phone));
|
const { ttl } = await requestOtp(toE164(phone));
|
||||||
navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
|
navigate("/login/otp", {
|
||||||
} catch {
|
state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" },
|
||||||
setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div className="min-h-screen relative overflow-hidden">
|
||||||
onSubmit={(e) => {
|
<img
|
||||||
e.preventDefault();
|
src={!isOwnerFlow ? GuestBanner : HostBanner}
|
||||||
if (canContinue) handleContinue();
|
alt="بنر"
|
||||||
}}
|
className="w-full"
|
||||||
className="flex min-h-screen flex-col justify-center px-6"
|
|
||||||
>
|
|
||||||
<h1 className="text-right text-2xl font-bold text-gray-900">
|
|
||||||
ورود به وینسو
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-right text-gray-500">
|
|
||||||
لطفا شماره تماس خود را وارد کنید.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
dir="ltr"
|
|
||||||
inputMode="numeric"
|
|
||||||
enterKeyHint="go"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPhone(toPersianDigits(e.target.value));
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
|
||||||
className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]"
|
|
||||||
/>
|
/>
|
||||||
|
{isOwnerFlow && (
|
||||||
{error && <p className="mt-2 text-right text-sm text-red-500">{error}</p>}
|
<div className="mt-8 flex items-center justify-end gap-2 leading-relaxed rounded-2xl bg-[#ECF2F8] px-4 py-3 mx-4">
|
||||||
|
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||||
<button
|
<p className="text-right text-sm text-gray-600">
|
||||||
type="submit"
|
این شماره موبایل بهعنوان حساب نماینده کافه در اپلیکیشن وینسو ثبت
|
||||||
disabled={!canContinue}
|
میشود و پرداختها به کیف پول آن واریز خواهد شد.
|
||||||
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
<br />
|
||||||
|
توجه داشته باشید برای برداشت شماره موبایل، کد ملی و کارت بانکی باید
|
||||||
|
متعلق به یک نفر باشند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canContinue) handleContinue();
|
||||||
|
}}
|
||||||
|
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
|
||||||
>
|
>
|
||||||
{isSubmitting ? "در حال ارسال..." : "ادامه"}
|
<h1 className="text-right text-2xl font-bold text-gray-900">
|
||||||
</button>
|
ورود به وینسو
|
||||||
|
</h1>
|
||||||
<div className="mt-4 flex items-center justify-center gap-2">
|
<p className="mt-2 text-right text-gray-500">
|
||||||
<button
|
لطفا شماره تماس خود را وارد کنید.
|
||||||
type="button"
|
|
||||||
onClick={() => setAcceptedTerms((v) => !v)}
|
|
||||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
|
|
||||||
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{acceptedTerms && <Icon name="check" className="h-3 w-3 text-white" />}
|
|
||||||
</button>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
<a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
|
|
||||||
قوانین و شرایط
|
|
||||||
</a>{" "}
|
|
||||||
را میپذیرم.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</form>
|
<input
|
||||||
|
id="login-phone-number"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
inputMode="numeric"
|
||||||
|
enterKeyHint="go"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
const digits = toEnglishDigits(e.target.value)
|
||||||
|
.replace(/\D/g, "")
|
||||||
|
.slice(0, 11);
|
||||||
|
setPhone(toPersianDigits(digits));
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
|
className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-right text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canContinue}
|
||||||
|
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "در حال ارسال..." : "ادامه"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAcceptedTerms((v) => !v)}
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
|
||||||
|
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{acceptedTerms && (
|
||||||
|
<Icon name="check" className="h-3 w-3 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<a
|
||||||
|
href="https://winsoo.ir/terms/"
|
||||||
|
className="text-[#223D5D] underline"
|
||||||
|
>
|
||||||
|
قوانین و شرایط
|
||||||
|
</a>{" "}
|
||||||
|
را میپذیرم.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,236 +1,272 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import Icon from '../components/Icon'
|
import Icon from "../components/Icon";
|
||||||
import { requestOtp, verifyOtp } from '../services/authApi'
|
import { requestOtp, verifyOtp } from "../services/authApi";
|
||||||
import { fetchMyCafes } from '../services/campaignApi'
|
import { fetchMyCafes } from "../services/campaignApi";
|
||||||
import { storeAuthTokens } from '../utils/authToken'
|
import { storeAuthTokens } from "../utils/authToken";
|
||||||
import { clearCafeUuid, setCafeUuid } from '../utils/cafe'
|
import { clearCafeUuid, setCafeUuid } from "../utils/cafe";
|
||||||
import { setUserPhone } from '../utils/userPhone'
|
import { setUserPhone } from "../utils/userPhone";
|
||||||
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
|
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
|
||||||
import { toE164 } from '../utils/phone'
|
import { toE164 } from "../utils/phone";
|
||||||
import { getUserRole, setUserRole, UserRole } from '../utils/role'
|
import { getUserRole, setUserRole, UserRole } from "../utils/role";
|
||||||
|
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
|
||||||
|
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||||
|
|
||||||
const OTP_LENGTH = 5
|
const OTP_LENGTH = 5;
|
||||||
const DEFAULT_RESEND_SECONDS = 120
|
const DEFAULT_RESEND_SECONDS = 120;
|
||||||
|
|
||||||
// not yet in TS's DOM lib — minimal shape for the WebOTP API result
|
// not yet in TS's DOM lib — minimal shape for the WebOTP API result
|
||||||
interface OTPCredential extends Credential {
|
interface OTPCredential extends Credential {
|
||||||
code: string
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCountdown(seconds: number) {
|
function formatCountdown(seconds: number) {
|
||||||
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
|
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||||
const ss = String(seconds % 60).padStart(2, '0')
|
const ss = String(seconds % 60).padStart(2, "0");
|
||||||
return toPersianDigits(`${mm}:${ss}`)
|
return toPersianDigits(`${mm}:${ss}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginOtp() {
|
export default function LoginOtp() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const state = location.state as { phone?: string; ttl?: number; role?: UserRole } | null
|
const state = location.state as {
|
||||||
const phone = state?.phone ?? ''
|
phone?: string;
|
||||||
|
ttl?: number;
|
||||||
|
role?: UserRole;
|
||||||
|
} | null;
|
||||||
|
const phone = state?.phone ?? "";
|
||||||
// the role this OTP attempt is for — passed explicitly from Login.tsx rather than
|
// the role this OTP attempt is for — passed explicitly from Login.tsx rather than
|
||||||
// re-read from the global getUserRole(), which could've drifted (e.g. another tab/flow
|
// re-read from the global getUserRole(), which could've drifted (e.g. another tab/flow
|
||||||
// overwriting it in between) and silently route a cafe owner through the customer path
|
// overwriting it in between) and silently route a cafe owner through the customer path
|
||||||
const role = state?.role ?? getUserRole()
|
const role = state?.role ?? getUserRole();
|
||||||
const loginPath = role === 'cafe_owner' ? '/cafe/login' : '/login'
|
const loginPath = role === "cafe_owner" ? "/cafe/login" : "/login";
|
||||||
|
|
||||||
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''))
|
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(""));
|
||||||
const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS))
|
const [secondsLeft, setSecondsLeft] = useState(
|
||||||
const [resendCycle, setResendCycle] = useState(0)
|
Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS),
|
||||||
const [error, setError] = useState('')
|
);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [resendCycle, setResendCycle] = useState(0);
|
||||||
const [isResending, setIsResending] = useState(false)
|
const [error, setError] = useState("");
|
||||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isResending, setIsResending] = useState(false);
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
// fills consecutive boxes starting at startIndex — used by both the WebOTP
|
// fills consecutive boxes starting at startIndex — used by both the WebOTP
|
||||||
// listener below and a multi-digit autofill landing in a single box (e.g. iOS's
|
// listener below and a multi-digit autofill landing in a single box (e.g. iOS's
|
||||||
// autocomplete="one-time-code" suggestion pastes the whole code into one input)
|
// autocomplete="one-time-code" suggestion pastes the whole code into one input)
|
||||||
const fillOtp = (startIndex: number, rawValue: string) => {
|
const fillOtp = (startIndex: number, rawValue: string) => {
|
||||||
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex)
|
const digits = toEnglishDigits(rawValue)
|
||||||
if (!digits) return
|
.replace(/[^0-9]/g, "")
|
||||||
|
.slice(0, OTP_LENGTH - startIndex);
|
||||||
|
if (!digits) return;
|
||||||
setOtp((prev) => {
|
setOtp((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev];
|
||||||
digits.split('').forEach((d, i) => {
|
digits.split("").forEach((d, i) => {
|
||||||
next[startIndex + i] = d
|
next[startIndex + i] = d;
|
||||||
})
|
});
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
setError('')
|
setError("");
|
||||||
inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus()
|
inputRefs.current[
|
||||||
}
|
Math.min(startIndex + digits.length, OTP_LENGTH) - 1
|
||||||
|
]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
navigate(loginPath, { replace: true })
|
navigate(loginPath, { replace: true });
|
||||||
}
|
}
|
||||||
}, [phone, loginPath, navigate])
|
}, [phone, loginPath, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRefs.current[0]?.focus()
|
inputRefs.current[0]?.focus();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Android/Chrome: WebOTP lets the browser read the code straight off an incoming
|
// Android/Chrome: WebOTP lets the browser read the code straight off an incoming
|
||||||
// SMS without the user opening the messaging app — only fires if the SMS body ends
|
// SMS without the user opening the messaging app — only fires if the SMS body ends
|
||||||
// with a line like "@<this site's domain> #<code>", which the backend's SMS
|
// with a line like "@<this site's domain> #<code>", which the backend's SMS
|
||||||
// template doesn't currently send, so this is a no-op until that's added.
|
// template doesn't currently send, so this is a no-op until that's added.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!('OTPCredential' in window)) return
|
if (!("OTPCredential" in window)) return;
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController();
|
||||||
navigator.credentials
|
navigator.credentials
|
||||||
.get({ otp: { transport: ['sms'] }, signal: abortController.signal } as unknown as CredentialRequestOptions)
|
.get({
|
||||||
|
otp: { transport: ["sms"] },
|
||||||
|
signal: abortController.signal,
|
||||||
|
} as unknown as CredentialRequestOptions)
|
||||||
.then((credential) => {
|
.then((credential) => {
|
||||||
const code = (credential as OTPCredential | null)?.code
|
const code = (credential as OTPCredential | null)?.code;
|
||||||
if (code) fillOtp(0, code)
|
if (code) fillOtp(0, code);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
return () => abortController.abort()
|
return () => abortController.abort();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setSecondsLeft((s) => {
|
setSecondsLeft((s) => {
|
||||||
if (s <= 1) {
|
if (s <= 1) {
|
||||||
clearInterval(timer)
|
clearInterval(timer);
|
||||||
return 0
|
return 0;
|
||||||
}
|
}
|
||||||
return s - 1
|
return s - 1;
|
||||||
})
|
});
|
||||||
}, 1000)
|
}, 1000);
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer);
|
||||||
}, [resendCycle])
|
}, [resendCycle]);
|
||||||
|
|
||||||
const handleOtpChange = (index: number, rawValue: string) => {
|
const handleOtpChange = (index: number, rawValue: string) => {
|
||||||
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '')
|
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, "");
|
||||||
// a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion
|
// a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion
|
||||||
if (digits.length > 1) {
|
if (digits.length > 1) {
|
||||||
fillOtp(index, digits)
|
fillOtp(index, digits);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setOtp((prev) => {
|
setOtp((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev];
|
||||||
next[index] = digits
|
next[index] = digits;
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
setError('')
|
setError("");
|
||||||
if (digits && index < OTP_LENGTH - 1) {
|
if (digits && index < OTP_LENGTH - 1) {
|
||||||
inputRefs.current[index + 1]?.focus()
|
inputRefs.current[index + 1]?.focus();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleOtpKeyDown = (
|
||||||
if (e.key === 'Backspace' && !otp[index] && index > 0) {
|
index: number,
|
||||||
inputRefs.current[index - 1]?.focus()
|
e: React.KeyboardEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
||||||
|
inputRefs.current[index - 1]?.focus();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleResend = async () => {
|
const handleResend = async () => {
|
||||||
setError('')
|
setError("");
|
||||||
setIsResending(true)
|
setIsResending(true);
|
||||||
try {
|
try {
|
||||||
const { ttl } = await requestOtp(toE164(phone))
|
const { ttl } = await requestOtp(toE164(phone));
|
||||||
setOtp(Array(OTP_LENGTH).fill(''))
|
setOtp(Array(OTP_LENGTH).fill(""));
|
||||||
inputRefs.current[0]?.focus()
|
inputRefs.current[0]?.focus();
|
||||||
setSecondsLeft(Math.round(ttl))
|
setSecondsLeft(Math.round(ttl));
|
||||||
setResendCycle((c) => c + 1)
|
setResendCycle((c) => c + 1);
|
||||||
} catch {
|
} catch {
|
||||||
setError('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
setError("ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsResending(false)
|
setIsResending(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting
|
const canSubmit = otp.every((digit) => digit !== "") && !isSubmitting;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setError('')
|
setError("");
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const tokens = await verifyOtp(toE164(phone), otp.join(''))
|
const tokens = await verifyOtp(toE164(phone), otp.join(""));
|
||||||
storeAuthTokens(tokens.access_token, tokens.refresh_token)
|
storeAuthTokens(tokens.access_token, tokens.refresh_token);
|
||||||
setUserPhone(phone)
|
setUserPhone(phone);
|
||||||
// re-sync in case the global value drifted since Login.tsx set it — `role` (from
|
// re-sync in case the global value drifted since Login.tsx set it — `role` (from
|
||||||
// route state) is the source of truth for this submission, not getUserRole()
|
// route state) is the source of truth for this submission, not getUserRole()
|
||||||
setUserRole(role)
|
setUserRole(role);
|
||||||
|
|
||||||
if (role === 'cafe_owner') {
|
if (role === "cafe_owner") {
|
||||||
const myCafes = await fetchMyCafes().catch(() => [])
|
const myCafes = await fetchMyCafes().catch(() => []);
|
||||||
if (myCafes.length > 0) {
|
if (myCafes.length > 0) {
|
||||||
setCafeUuid(myCafes[0].uuid)
|
setCafeUuid(myCafes[0].uuid);
|
||||||
navigate('/owner/panel')
|
navigate("/owner/panel");
|
||||||
} else {
|
} else {
|
||||||
clearCafeUuid()
|
clearCafeUuid();
|
||||||
navigate('/owner/register')
|
navigate("/owner/register");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigate('/home')
|
navigate("/home");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('کد وارد شده صحیح نیست')
|
setError("کد وارد شده صحیح نیست");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div className="min-h-screen relative overflow-hidden">
|
||||||
onSubmit={(e) => {
|
<img
|
||||||
e.preventDefault()
|
src={role === "customer" ? GuestBanner : HostBanner}
|
||||||
if (canSubmit) handleSubmit()
|
alt="بنر"
|
||||||
}}
|
className="w-full"
|
||||||
className="flex min-h-screen flex-col justify-center px-6"
|
/>
|
||||||
>
|
<form
|
||||||
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
|
onSubmit={(e) => {
|
||||||
<div className="mt-2 flex items-center justify-start gap-2">
|
e.preventDefault();
|
||||||
<span className="text-[#223D5D]">{phone}</span>
|
if (canSubmit) handleSubmit();
|
||||||
<button type="button" onClick={() => navigate(loginPath, { state: { phone } })} aria-label="ویرایش شماره تماس">
|
}}
|
||||||
<Icon name="edit" className="h-5 w-5 text-[#223D5D]" />
|
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
|
|
||||||
{otp.map((digit, index) => (
|
|
||||||
<input
|
|
||||||
key={index}
|
|
||||||
ref={(el) => {
|
|
||||||
inputRefs.current[index] = el
|
|
||||||
}}
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
enterKeyHint="go"
|
|
||||||
// no maxLength — the browser would truncate a pasted/autofilled multi-digit
|
|
||||||
// code to 1 char before onChange ever sees it; handleOtpChange enforces the
|
|
||||||
// single-digit display for normal typing instead
|
|
||||||
value={digit}
|
|
||||||
onChange={(e) => handleOtpChange(index, e.target.value)}
|
|
||||||
onKeyDown={(e) => handleOtpKeyDown(index, e)}
|
|
||||||
className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
{secondsLeft > 0 ? (
|
|
||||||
<p className="text-gray-500">
|
|
||||||
ارسال مجدد کد: {formatCountdown(secondsLeft)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={handleResend} disabled={isResending} className="text-[#376195] underline disabled:opacity-50">
|
|
||||||
{isResending ? 'در حال ارسال...' : 'ارسال مجدد کد'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="mt-2 text-center text-sm text-red-500">{error}</p>}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'در حال ورود...' : 'ورود'}
|
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
|
||||||
</button>
|
<div className="mt-2 flex items-center justify-start gap-2">
|
||||||
</form>
|
<span className="text-[#223D5D]">{phone}</span>
|
||||||
)
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(loginPath, { state: { phone } })}
|
||||||
|
aria-label="ویرایش شماره تماس"
|
||||||
|
>
|
||||||
|
<Icon name="edit" className="h-5 w-5 text-[#223D5D]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
|
||||||
|
{otp.map((digit, index) => (
|
||||||
|
<input
|
||||||
|
key={index}
|
||||||
|
ref={(el) => {
|
||||||
|
inputRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
enterKeyHint="go"
|
||||||
|
// no maxLength — the browser would truncate a pasted/autofilled multi-digit
|
||||||
|
// code to 1 char before onChange ever sees it; handleOtpChange enforces the
|
||||||
|
// single-digit display for normal typing instead
|
||||||
|
value={digit}
|
||||||
|
onChange={(e) => handleOtpChange(index, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleOtpKeyDown(index, e)}
|
||||||
|
className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
{secondsLeft > 0 ? (
|
||||||
|
<p className="text-gray-500">
|
||||||
|
ارسال مجدد کد: {formatCountdown(secondsLeft)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={isResending}
|
||||||
|
className="text-[#376195] underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isResending ? "در حال ارسال..." : "ارسال مجدد کد"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-center text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "در حال ورود..." : "ورود"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,44 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from "react-router-dom";
|
||||||
import Icon from '../components/Icon'
|
import Icon from "../components/Icon";
|
||||||
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
|
import {
|
||||||
import { formatPersianNumber } from '../utils/formatNumber'
|
fetchMyReservationByUuid,
|
||||||
import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
|
fetchMyReservations,
|
||||||
|
MyReservation,
|
||||||
|
} from "../services/campaignApi";
|
||||||
|
import { formatPersianNumber } from "../utils/formatNumber";
|
||||||
|
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
|
||||||
|
|
||||||
function formatReservationDateTime(iso: string): string {
|
function formatReservationDateTime(iso: string): string {
|
||||||
return new Intl.DateTimeFormat('fa-IR', {
|
return new Intl.DateTimeFormat("fa-IR", {
|
||||||
weekday: 'long',
|
weekday: "long",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
}).format(new Date(iso))
|
}).format(new Date(iso));
|
||||||
}
|
}
|
||||||
|
|
||||||
function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) {
|
function MyReservationCard({
|
||||||
|
reservation,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
reservation: MyReservation;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex w-full flex-col items-start gap-3 rounded-2xl p-6 text-right shadow-[0px_4px_12px_0px_#0000001F]"
|
className="flex w-full flex-col items-start gap-3 rounded-2xl p-6 text-right shadow-[0px_4px_12px_0px_#0000001F]"
|
||||||
>
|
>
|
||||||
<h3 className="font-medium text-xl text-gray-900">{reservation.event_title}</h3>
|
<h3 className="font-medium text-xl text-gray-900">
|
||||||
<p className="text-lg text-gray-500">{formatReservationDateTime(reservation.event_datetime)}</p>
|
{reservation.event_title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-lg text-gray-500">
|
||||||
|
{formatReservationDateTime(reservation.event_datetime)}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-normal rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
|
<span className="font-normal rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
|
||||||
{formatPersianNumber(reservation.ticket_count)} نفر
|
{formatPersianNumber(reservation.ticket_count)} نفر
|
||||||
|
|
@ -34,7 +48,7 @@ function MyReservationCard({ reservation, onClick }: { reservation: MyReservatio
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
|
@ -43,43 +57,60 @@ function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
<span className="text-gray-500">{label}</span>
|
<span className="text-gray-500">{label}</span>
|
||||||
<span className="text-gray-900">{value}</span>
|
<span className="text-gray-900">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) {
|
function ReservationDetailModal({
|
||||||
const [reservation, setReservation] = useState<MyReservation | null>(null)
|
uuid,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
uuid: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [reservation, setReservation] = useState<MyReservation | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousOverflow = document.body.style.overflow
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow
|
document.body.style.overflow = previousOverflow;
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMyReservationByUuid(uuid)
|
fetchMyReservationByUuid(uuid)
|
||||||
.then(setReservation)
|
.then(setReservation)
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}, [uuid])
|
}, [uuid]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
|
<div
|
||||||
|
className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6"
|
className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Icon name="success" className="mx-auto h-24 w-24" />
|
<Icon name="success" className="mx-auto h-24 w-24" />
|
||||||
<h2 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2>
|
<h2 className="mt-4 text-right font-semibold text-gray-900">
|
||||||
|
جزئیات رزرو
|
||||||
|
</h2>
|
||||||
|
|
||||||
{reservation && (
|
{reservation && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
|
<DetailRow
|
||||||
|
label="تعداد"
|
||||||
|
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
|
||||||
|
/>
|
||||||
<DetailRow label="به نام" value={reservation.full_name} />
|
<DetailRow label="به نام" value={reservation.full_name} />
|
||||||
<DetailRow label="شماره تماس" value={reservation.phone_number} />
|
<DetailRow label="شماره تماس" value={reservation.phone_number} />
|
||||||
<DetailRow label="کافه" value={reservation.cafe_name} />
|
<DetailRow label="کافه" value={reservation.cafe_name} />
|
||||||
<DetailRow label="بازی" value={reservation.event_title} />
|
<DetailRow label="بازی" value={reservation.event_title} />
|
||||||
<DetailRow label="تاریخ و ساعت" value={formatReservationDateTime(reservation.event_datetime)} />
|
<DetailRow
|
||||||
|
label="تاریخ و ساعت"
|
||||||
|
value={formatReservationDateTime(reservation.event_datetime)}
|
||||||
|
/>
|
||||||
<DetailRow label="کد رزرو" value={reservation.reservation_code} />
|
<DetailRow label="کد رزرو" value={reservation.reservation_code} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -87,48 +118,67 @@ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () =
|
||||||
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||||
<p className="text-right text-sm text-gray-500">
|
<p className="text-right text-sm text-gray-500">
|
||||||
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک میشود.
|
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود در
|
||||||
|
بخش رزروها قابل مشاهده است.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" onClick={onClose} className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||||
|
>
|
||||||
بازگشت
|
بازگشت
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MyReservations() {
|
export default function MyReservations() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [reservations, setReservations] = useState<MyReservation[]>([])
|
const [reservations, setReservations] = useState<MyReservation[]>([]);
|
||||||
const [selectedUuid, setSelectedUuid] = useState<string | null>(null)
|
const [selectedUuid, setSelectedUuid] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMyReservations()
|
fetchMyReservations()
|
||||||
.then(setReservations)
|
.then(setReservations)
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white pb-24">
|
<div className="min-h-screen bg-white pb-24">
|
||||||
<img src={GuestBanner} alt="" className="w-full" />
|
<img src={GuestBanner} alt="" className="w-full" />
|
||||||
|
|
||||||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
<button
|
||||||
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="arrowRight"
|
||||||
|
className="h-5 w-5 -scale-x-100 text-gray-900"
|
||||||
|
/>
|
||||||
<span className="text-lg font-bold text-gray-900">رزروهای شما</span>
|
<span className="text-lg font-bold text-gray-900">رزروهای شما</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-3 px-4">
|
<div className="mt-4 flex flex-col gap-3 px-4">
|
||||||
{reservations.map((reservation) => (
|
{reservations.map((reservation) => (
|
||||||
<MyReservationCard key={reservation.uuid} reservation={reservation} onClick={() => setSelectedUuid(reservation.uuid)} />
|
<MyReservationCard
|
||||||
|
key={reservation.uuid}
|
||||||
|
reservation={reservation}
|
||||||
|
onClick={() => setSelectedUuid(reservation.uuid)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedUuid && (
|
{selectedUuid && (
|
||||||
<ReservationDetailModal uuid={selectedUuid} onClose={() => setSelectedUuid(null)} />
|
<ReservationDetailModal
|
||||||
|
uuid={selectedUuid}
|
||||||
|
onClose={() => setSelectedUuid(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,74 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import Icon from '../components/Icon'
|
import Icon from "../components/Icon";
|
||||||
import PaymentFailModal from '../components/PaymentFailModal'
|
import PaymentFailModal from "../components/PaymentFailModal";
|
||||||
import { createReservation, ReservationResult } from '../services/campaignApi'
|
import {
|
||||||
import { formatPersianNumber } from '../utils/formatNumber'
|
createReservation,
|
||||||
import { fromE164 } from '../utils/phone'
|
fetchMyReservationByUuid,
|
||||||
import { takePendingReservation } from '../utils/pendingReservation'
|
MyReservation,
|
||||||
|
} 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
|
// the IPG gateway redirects back here as `/payment/success?state=<code>` regardless of
|
||||||
// whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far
|
// whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far
|
||||||
const SUCCESS_STATE = '20'
|
const SUCCESS_STATE = "20";
|
||||||
|
|
||||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-start justify-between gap-4 py-2">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">
|
||||||
<span className="font-medium text-gray-900">{value}</span>
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-gray-500 shrink-0">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaymentSuccess() {
|
export default function PaymentSuccess() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams();
|
||||||
const isSuccess = searchParams.get('state') === SUCCESS_STATE
|
const isSuccess = searchParams.get("state") === SUCCESS_STATE;
|
||||||
const [reservation, setReservation] = useState<ReservationResult | null>(null)
|
const [reservation, setReservation] = useState<MyReservation | null>(null);
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false);
|
||||||
// gateway-reported failure, or a successful payment whose reservation couldn't be created —
|
const isFail = !isSuccess || hasError;
|
||||||
// both land on the same fail modal
|
|
||||||
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSuccess) return
|
if (!isSuccess || reservationAttempted.current) return;
|
||||||
const pending = takePendingReservation()
|
reservationAttempted.current = true;
|
||||||
|
const pending = takePendingReservation();
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
Promise.resolve().then(() => setHasError(true))
|
setHasError(true);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
createReservation(pending)
|
createReservation(pending)
|
||||||
.then(setReservation)
|
.then((result) =>
|
||||||
.catch(() => setHasError(true))
|
fetchMyReservationByUuid(result.uuid)
|
||||||
}, [isSuccess])
|
.then(setReservation)
|
||||||
|
.catch(() => {
|
||||||
|
// full detail fetch failed — synthesise what we have from the create response
|
||||||
|
setReservation({
|
||||||
|
uuid: result.uuid,
|
||||||
|
reservation_code: result.reserve_code,
|
||||||
|
ticket_count: result.people_count,
|
||||||
|
full_name: result.full_name,
|
||||||
|
phone_number: result.phone_number,
|
||||||
|
cafe_name: "",
|
||||||
|
event_title: "",
|
||||||
|
event_datetime: "",
|
||||||
|
status: result.status,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => setHasError(true));
|
||||||
|
}, [isSuccess]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
|
@ -51,28 +79,57 @@ export default function PaymentSuccess() {
|
||||||
|
|
||||||
{reservation ? (
|
{reservation ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
|
<h2 className="mt-6 text-right font-bold text-gray-900">
|
||||||
<div className="mt-2">
|
جزئیات رزرو
|
||||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
|
</h2>
|
||||||
|
<div className="mt-2 divide-y divide-gray-100">
|
||||||
|
<DetailRow
|
||||||
|
label="تعداد"
|
||||||
|
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
|
||||||
|
/>
|
||||||
<DetailRow label="به نام" value={reservation.full_name} />
|
<DetailRow label="به نام" value={reservation.full_name} />
|
||||||
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
|
<DetailRow
|
||||||
<DetailRow label="کد رزرو" value={reservation.reserve_code} />
|
label="شماره تماس"
|
||||||
|
value={fromE164(reservation.phone_number)}
|
||||||
|
/>
|
||||||
|
{reservation.cafe_name && (
|
||||||
|
<DetailRow label="کافه" value={reservation.cafe_name} />
|
||||||
|
)}
|
||||||
|
{reservation.event_title && (
|
||||||
|
<DetailRow label="بازی" value={reservation.event_title} />
|
||||||
|
)}
|
||||||
|
{reservation.event_datetime && (
|
||||||
|
<DetailRow
|
||||||
|
label="تاریخ و ساعت"
|
||||||
|
value={formatMatchDatetime(reservation.event_datetime)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DetailRow
|
||||||
|
label="کد رزرو"
|
||||||
|
value={reservation.reservation_code}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
<Icon
|
||||||
|
name="info"
|
||||||
|
className="h-5 w-5 shrink-0 text-gray-400"
|
||||||
|
/>
|
||||||
<p className="text-right text-sm text-gray-600">
|
<p className="text-right text-sm text-gray-600">
|
||||||
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک میشود.
|
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد
|
||||||
|
ورود در بخش رزروها قابل مشاهده است.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
|
<p className="mt-6 text-center text-gray-500">
|
||||||
|
در حال ثبت رزرو...
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/home')}
|
onClick={() => navigate("/home")}
|
||||||
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||||
>
|
>
|
||||||
صفحه اصلی
|
صفحه اصلی
|
||||||
|
|
@ -81,7 +138,7 @@ export default function PaymentSuccess() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFail && <PaymentFailModal onGoHome={() => navigate('/home')} />}
|
{isFail && <PaymentFailModal onGoHome={() => navigate("/home")} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ export interface EventReservation {
|
||||||
full_name: string
|
full_name: string
|
||||||
phone_number: string
|
phone_number: string
|
||||||
status: string
|
status: string
|
||||||
|
is_participated: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,6 +216,10 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function participateReservation(uuid: string, isParticipated: boolean): Promise<void> {
|
||||||
|
await api.patch(`/campaign/reservations/${uuid}/participate/`, { is_participated: isParticipated })
|
||||||
|
}
|
||||||
|
|
||||||
export interface MyReservation {
|
export interface MyReservation {
|
||||||
uuid: string
|
uuid: string
|
||||||
reservation_code: string
|
reservation_code: string
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,16 @@ export function formatIranKickoffTime(matchDatetimeIso: string): string {
|
||||||
})
|
})
|
||||||
return toPersianDigits(time)
|
return toPersianDigits(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// e.g. "پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰"
|
||||||
|
export function formatMatchDatetime(datetimeIso: string): string {
|
||||||
|
const date = new Date(datetimeIso)
|
||||||
|
const weekday = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
timeZone: IRAN_TIME_ZONE,
|
||||||
|
weekday: 'long',
|
||||||
|
}).format(date)
|
||||||
|
const { month, day } = getIranJalaliParts(date)
|
||||||
|
const monthName = PERSIAN_MONTHS[month - 1] ?? ''
|
||||||
|
const time = formatIranKickoffTime(datetimeIso)
|
||||||
|
return `${weekday} ${toPersianDigits(String(day))} ${monthName} - ساعت ${time}`
|
||||||
|
}
|
||||||
|
|
|
||||||
12
src/utils/logout.ts
Normal file
12
src/utils/logout.ts
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -10,3 +10,7 @@ export function setUserRole(role: UserRole) {
|
||||||
export function getUserRole(): UserRole {
|
export function getUserRole(): UserRole {
|
||||||
return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer'
|
return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearUserRole() {
|
||||||
|
localStorage.removeItem(ROLE_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,7 @@ export function setUserPhone(phone: string) {
|
||||||
export function getUserPhone(): string | null {
|
export function getUserPhone(): string | null {
|
||||||
return localStorage.getItem(USER_PHONE_STORAGE_KEY)
|
return localStorage.getItem(USER_PHONE_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearUserPhone() {
|
||||||
|
localStorage.removeItem(USER_PHONE_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue