Compare commits

..

No commits in common. "develop" and "master" have entirely different histories.

27 changed files with 597 additions and 1396 deletions

View file

@ -14,7 +14,6 @@
"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
View file

@ -14,9 +14,6 @@ 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
@ -945,9 +942,6 @@ 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==}
@ -2251,8 +2245,6 @@ 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:

View file

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 785 B

View file

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 685 B

View file

@ -5,8 +5,6 @@
// 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'
@ -30,8 +28,6 @@ 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,

View file

@ -1,29 +0,0 @@
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,
)
}

View file

@ -4,11 +4,7 @@ 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 { import { formatPersianNumber } from "../utils/formatNumber";
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";
@ -20,15 +16,11 @@ 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 = [ const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
"e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d",
"af7d967f-30c0-409b-9066-2549f2da5e5e",
];
function PaymentRow({ function PaymentRow({
label, label,
@ -43,8 +35,6 @@ 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" : ""}`}
@ -58,14 +48,7 @@ function PaymentRow({
{label} {label}
</span> </span>
{infoIconParam && ( {infoIconParam && (
<button <Icon name="info" className="h-5 w-5 shrink-0 text-[#376195]" />
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
@ -73,31 +56,6 @@ 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>
); );
} }
@ -108,32 +66,17 @@ 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 [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const [error, setError] = useState("");
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 () => {
@ -152,16 +95,12 @@ 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 () => {
setPayError(""); setError("");
const errors: Record<string, string> = {}; if (!fullName.trim()) {
if (!fullName.trim()) errors.name = "نام و نام خانوادگی را وارد کنید"; setError("لطفاً نام و نام خانوادگی را وارد کنید.");
if (phone.length !== 11) errors.phone = "شماره تلفن باید ۱۱ رقم باشد";
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return; return;
} }
@ -171,16 +110,14 @@ 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(phone), phone_number: toE164(getUserPhone() ?? ""),
}; };
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 = ( const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
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`,
@ -192,9 +129,7 @@ export default function ReservationModal({
navigate("/payment/success?state=20"); navigate("/payment/success?state=20");
} }
} catch { } catch {
setPayError( setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
);
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@ -220,20 +155,10 @@ export default function ReservationModal({
<input <input
type="text" type="text"
value={fullName} value={fullName}
onChange={(e) => { onChange={(e) => setFullName(e.target.value)}
setFullName(e.target.value);
clearFieldError("name");
}}
placeholder="نام و نام خانوادگی" placeholder="نام و نام خانوادگی"
className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${ className="w-full rounded-2xl border border-gray-200 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">
@ -242,26 +167,11 @@ export default function ReservationModal({
</label> </label>
<input <input
type="text" type="text"
dir="ltr" value={getUserPhone() ?? ""}
inputMode="numeric" readOnly
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 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${ className="w-full rounded-2xl border border-gray-200 bg-gray-50 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">
@ -289,24 +199,12 @@ export default function ReservationModal({
</span> </span>
<button <button
type="button" type="button"
onClick={() => onClick={() => setCount((prev) => prev + 1)}
setCount((prev) => Math.min(remainingTickets, prev + 1)) className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
}
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">
@ -331,16 +229,14 @@ 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">
{payError && ( {error && <p className="mb-2 text-center text-sm text-red-500">{error}</p>}
<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}

View file

@ -1,93 +0,0 @@
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,
)}
</>
);
}

View file

@ -21,13 +21,11 @@ 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)
@ -40,7 +38,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 px-4 text-right disabled:opacity-50 ${error ? 'border-red-400' : 'border-gray-200'}`} className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right disabled:opacity-50"
> >
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}> <span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
{selectedMatch {selectedMatch
@ -72,7 +70,6 @@ function MatchSelect({
</div> </div>
</> </>
)} )}
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
</div> </div>
) )
} }
@ -174,24 +171,8 @@ 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() ?? '', {
@ -214,17 +195,11 @@ 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 <MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
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>

View file

@ -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"

View file

@ -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"

View file

@ -1,5 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, 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";
@ -18,6 +17,56 @@ 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,
@ -37,182 +86,50 @@ function Pill({
function ImageCarousel({ images }: { images: string[] }) { function ImageCarousel({ images }: { images: string[] }) {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false); const slideCount = Math.max(images.length, 1);
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">
<div <button
className="relative h-[35vh] w-full cursor-pointer bg-gray-200" type="button"
onClick={() => setLightboxOpen(true)} onClick={() => setIndex((i) => (i - 1 + slideCount) % slideCount)}
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"
> >
<img <Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
src={images[index]} </button>
alt="" <button
className="h-full w-full object-cover" type="button"
/> onClick={() => setIndex((i) => (i + 1) % slideCount)}
{slideCount > 1 && ( aria-label="بعدی"
<> 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 >
type="button" <Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
aria-label="قبلی" </button>
onClick={(e) => { <div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
e.stopPropagation(); {Array.from({ length: slideCount }).map((_, i) => (
setIndex((i) => (i - 1 + slideCount) % slideCount); <span
}} key={i}
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" className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-[#223D5D]" : "bg-white/70"}`}
> />
<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,
)}
</>
); );
} }
function NavigationSheet({ async function openNavigationPicker(name: string, lat: number, lng: number) {
isOpen, const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
onClose,
lat,
lng,
}: {
isOpen: boolean;
onClose: () => void;
lat: number;
lng: number;
}) {
if (!isOpen) return null;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); if (navigator.share) {
try {
const apps: { label: string; url: string }[] = [ await navigator.share({ title: name, url: mapsUrl });
{ label: "نشان", url: `https://neshan.org/maps/@${lat},${lng},15z` }, } catch {
{ label: "بلد", url: `https://balad.ir/p?lat=${lat}&lng=${lng}` }, // user dismissed the native share sheet
{ label: "Waze", url: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes` }, }
{ label: "Google Maps", url: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}` }, } else {
...(isIOS ? [{ label: "Apple Maps", url: `maps://?daddr=${lat},${lng}` }] : []), window.open(mapsUrl, "_blank");
]; }
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() {
@ -220,29 +137,19 @@ 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] = useState(""); const [selectedEventUuid, setSelectedEventUuid] = useState("");
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>( const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(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( .filter((s) => new Date(s.match_event.match_datetime).getTime() > now.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());
)
.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 = const effectiveEventUuid = selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
useEffect(() => { useEffect(() => {
if (!cafe) return; if (!cafe) return;
@ -266,14 +173,9 @@ export default function CafeDetail() {
); );
} }
const images = (schedule?.cafe_media ?? cafe.media) const images = cafe.media.filter((m) => m.type === "image").map((m) => m.url);
.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] = [ const mapPosition: [number, number] = [cafe.location.coordinates[1], cafe.location.coordinates[0]];
cafe.location.coordinates[1],
cafe.location.coordinates[0],
];
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
@ -283,9 +185,9 @@ export default function CafeDetail() {
type="button" type="button"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
aria-label="بازگشت" aria-label="بازگشت"
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow" className="absolute left-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
> >
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" /> <Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
</button> </button>
</div> </div>
@ -293,22 +195,21 @@ 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>
{upcomingSchedules[0] && ( <MatchPicker
<p className="mt-1 text-sm text-gray-500"> value={effectiveEventUuid}
{formatMatchLabel(upcomingSchedules[0].match_event, now)} options={upcomingSchedules}
</p> now={now}
)} 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>
</> </>
)} )}
@ -319,13 +220,6 @@ 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>
@ -366,7 +260,7 @@ export default function CafeDetail() {
<button <button
type="button" type="button"
onClick={() => setIsNavigationOpen(true)} onClick={() => openNavigationPicker(cafe.name, mapPosition[0], mapPosition[1])}
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"
> >
مسیریابی مسیریابی
@ -376,8 +270,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>
@ -399,13 +293,6 @@ 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>
); );

View file

@ -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="chevronRight" className="h-5 w-5 text-gray-700" /> <Icon name="arrowRight" className="h-5 w-5 text-gray-700" />
</button> </button>
<MapContainer <MapContainer

View file

@ -1,136 +1,80 @@
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 SupportButton from "../components/SupportButton"; import { useNow } from '../hooks/useNow'
import LogoutModal from "../components/LogoutModal"; import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
import { logout } from "../utils/logout"; import { getCafeUuid } from '../utils/cafe'
import { useNow } from "../hooks/useNow"; import HostBanner from '../assets/images/banners/Host-banner.jpg'
import { CafeSchedule, fetchCafeSchedules } from "../services/campaignApi"; import { formatPersianNumber } from '../utils/formatNumber'
import { getCafeUuid } from "../utils/cafe"; import { formatMatchLabel } from '../utils/matchCountdown'
import HostBanner from "../assets/images/banners/Host-banner.jpg";
import { formatPersianNumber } from "../utils/formatNumber";
import { formatMatchLabel } from "../utils/matchCountdown";
function PanelRow({ function PanelRow({ icon, label, onClick }: { icon: ReactNode; label: string; onClick?: () => void }) {
icon,
label,
onClick,
}: {
icon: ReactNode;
label: string;
onClick?: () => void;
}) {
return ( return (
<button <button type="button" onClick={onClick} className="flex w-full items-center justify-between py-4">
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 className="mt-4 text-right text-lg font-bold text-gray-900">پنل کافه</h1>
پنل کافه
</h1>
<div className="mt-2 divide-y divide-gray-100"> <div className="mt-2 divide-y divide-gray-100">
<PanelRow <PanelRow
icon={ icon={<Icon name="profileCircle" className="h-5 w-5 text-[#4D4D4D]" />}
<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 className="mt-6 text-right font-semibold text-gray-900">رویدادهای فعال شما</h2>
رویدادهای فعال شما
</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 = const isPast = new Date(schedule.match_event.match_datetime).getTime() <= now.getTime()
new Date(schedule.match_event.match_datetime).getTime() <= const matchLabel = formatMatchLabel(schedule.match_event, now)
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 className="shrink-0 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500">برگزار شده</span>
برگزار شده
</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.remaining_tickets)} صندلی از {formatPersianNumber(schedule.total_tickets)} باقی مانده
{formatPersianNumber(schedule.total_tickets)} باقی مانده
</span> </span>
</div> </div>
<div> <div>
<PanelRow <PanelRow
icon={ icon={<Icon name="calendar" className="h-5 w-5 text-[#4D4D4D]" />}
<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`, {
@ -147,12 +91,14 @@ export default function CafeOwnerPanel() {
/> />
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" /> <button type="button" className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg">
پشتیبانی
</button>
</div> </div>
); )
} }

View file

@ -1,12 +1,10 @@
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,
@ -36,16 +34,12 @@ 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">
@ -54,18 +48,14 @@ 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 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999] ${ className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]"
error ? "border-red-400" : "border-gray-200"
}`}
/> />
{error {hint && (
? <p className="mt-1 text-right text-sm text-red-500">{error}</p> <p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
: hint && <p className="mt-1 text-right text-sm text-gray-500">{hint}</p> )}
}
</div> </div>
); );
} }
@ -198,23 +188,6 @@ 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,
@ -259,12 +232,10 @@ 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);
@ -359,7 +330,6 @@ function PhotoUpload({
e.target.value = ""; e.target.value = "";
}} }}
/> />
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
</div> </div>
); );
} }
@ -368,12 +338,10 @@ 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);
@ -453,7 +421,6 @@ function DocumentUpload({
e.target.value = ""; e.target.value = "";
}} }}
/> />
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
</div> </div>
); );
} }
@ -506,16 +473,7 @@ 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 [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const [error, setError] = useState("");
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);
@ -606,14 +564,12 @@ export default function CafeOwnerRegister() {
}; };
const handleAddPhotos = async (files: File[]) => { const handleAddPhotos = async (files: File[]) => {
const newPhotos: UploadedPhoto[] = await Promise.all( const newPhotos: UploadedPhoto[] = files.map((file) => ({
files.map(async (file) => ({ id: crypto.randomUUID(),
id: crypto.randomUUID(), file,
file, localUrl: URL.createObjectURL(file),
localUrl: await createPreviewUrl(file), status: "uploading",
status: "uploading" as const, }));
})),
);
setPhotos((prev) => [...prev, ...newPhotos]); setPhotos((prev) => [...prev, ...newPhotos]);
await Promise.all( await Promise.all(
@ -653,7 +609,7 @@ export default function CafeOwnerRegister() {
const handleAddLicense = async (file: File) => { const handleAddLicense = async (file: File) => {
const newDoc: UploadedDocument = { const newDoc: UploadedDocument = {
file, file,
localUrl: await createPreviewUrl(file), localUrl: URL.createObjectURL(file),
status: "uploading", status: "uploading",
}; };
setLicenseDoc(newDoc); setLicenseDoc(newDoc);
@ -683,33 +639,24 @@ 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) {
errors.photos = "لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود"; setError("لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود.");
} 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) {
setFieldErrors({}); if (!photos.some((p) => p.status === "done")) {
setSubmitError(""); setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید.");
return;
}
if (!licenseDoc || licenseDoc.status !== "done") {
setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید.");
return;
}
}
setError("");
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const mediaItems: CafeMediaItem[] = [ const mediaItems: CafeMediaItem[] = [
@ -749,7 +696,7 @@ export default function CafeOwnerRegister() {
if (!isEditMode) { if (!isEditMode) {
navigate("/owner/register/fail"); navigate("/owner/register/fail");
} else { } else {
setSubmitError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید."); setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
} }
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -765,8 +712,7 @@ export default function CafeOwnerRegister() {
label="نام کافه" label="نام کافه"
placeholder="نام کافه را وارد کنید" placeholder="نام کافه را وارد کنید"
value={cafeName} value={cafeName}
onChange={(v) => { setCafeName(v); clearFieldError("cafeName"); }} onChange={setCafeName}
error={fieldErrors.cafeName}
/> />
<CitySelectField <CitySelectField
value={effectiveCityUuid} value={effectiveCityUuid}
@ -777,8 +723,7 @@ export default function CafeOwnerRegister() {
label="آدرس" label="آدرس"
placeholder="موقعیت کافه را با جزئیات وارد کنید" placeholder="موقعیت کافه را با جزئیات وارد کنید"
value={address} value={address}
onChange={(v) => { setAddress(v); clearFieldError("address"); }} onChange={setAddress}
error={fieldErrors.address}
/> />
<CafeLocationMap <CafeLocationMap
label="موقعیت روی نقشه" label="موقعیت روی نقشه"
@ -787,24 +732,20 @@ export default function CafeOwnerRegister() {
/> />
<FormField <FormField
label="شماره تماس کافه" label="شماره تماس کافه"
placeholder={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`} placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
hint="شماره را همراه با پیش‌شماره شهر وارد کنید." hint="شماره را همراه با پیش‌شماره شهر وارد کنید."
value={cafePhone} value={cafePhone}
onChange={(v) => { setCafePhone(v); clearFieldError("cafePhone"); }} onChange={setCafePhone}
inputDir="ltr"
error={fieldErrors.cafePhone}
/> />
<PhotoUpload <PhotoUpload
photos={photos} photos={photos}
onAdd={(files) => { clearFieldError("photos"); handleAddPhotos(files); }} onAdd={handleAddPhotos}
onRemove={handleRemovePhoto} onRemove={handleRemovePhoto}
error={fieldErrors.photos}
/> />
<DocumentUpload <DocumentUpload
doc={licenseDoc} doc={licenseDoc}
onAdd={(file) => { clearFieldError("licenseDoc"); handleAddLicense(file); }} onAdd={handleAddLicense}
onRemove={handleRemoveLicense} onRemove={handleRemoveLicense}
error={fieldErrors.licenseDoc}
/> />
<div className="mt-5"> <div className="mt-5">
@ -817,22 +758,24 @@ 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); clearFieldError("features"); }} onToggle={() => toggleFeature(feature.uuid)}
/> />
))} ))}
</div> </div>
{fieldErrors.features && (
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.features}</p>
)}
</div> </div>
{submitError && ( {error && (
<p className="mt-4 text-right text-sm text-red-500">{submitError}</p> <p className="mt-4 text-right text-sm text-red-500">{error}</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">
<SupportButton className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white" /> <button
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}

View file

@ -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"

View file

@ -2,15 +2,10 @@ 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 { import { EventReservation, fetchEventReservations } from "../services/campaignApi";
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 = "۰۱۲۳۴۵۶۷۸۹";
@ -52,14 +47,13 @@ function downloadReservationsCsv(reservations: EventReservation[]) {
function ReservationCard({ function ReservationCard({
reservation, reservation,
isToggling, selected,
onToggle, onToggle,
}: { }: {
reservation: EventReservation; reservation: EventReservation;
isToggling: boolean; selected: 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">
@ -72,19 +66,15 @@ 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 dir="ltr" className="text-left text-gray-500">{fromE164(reservation.phone_number)}</p> <p className="text-gray-500">{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>
); );
} }
@ -94,7 +84,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 [togglingIds, setTogglingIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [reservations, setReservations] = useState<EventReservation[]>([]); const [reservations, setReservations] = useState<EventReservation[]>([]);
const event: CafeEvent | undefined = const event: CafeEvent | undefined =
@ -117,28 +107,20 @@ export default function EventReservations() {
[reservations, query], [reservations, query],
); );
const handleParticipate = async (reservation: EventReservation) => { const toggleSelected = (reservationId: string) => {
if (togglingIds.has(reservation.uuid)) return; setSelectedIds((prev) =>
const newValue = !reservation.is_participated; prev.includes(reservationId)
setTogglingIds((prev) => new Set([...prev, reservation.uuid])); ? prev.filter((id) => id !== reservationId)
try { : [...prev, reservationId],
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 = () => {
downloadReservationsCsv(reservations); const selectedReservations =
selectedIds.length > 0
? reservations.filter((r) => selectedIds.includes(r.uuid))
: reservations;
downloadReservationsCsv(selectedReservations);
}; };
return ( return (
@ -150,10 +132,7 @@ 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 <Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
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>
@ -178,8 +157,8 @@ export default function EventReservations() {
<ReservationCard <ReservationCard
key={reservation.uuid} key={reservation.uuid}
reservation={reservation} reservation={reservation}
isToggling={togglingIds.has(reservation.uuid)} selected={selectedIds.includes(reservation.uuid)}
onToggle={() => handleParticipate(reservation)} onToggle={() => toggleSelected(reservation.uuid)}
/> />
))} ))}
</div> </div>

View file

@ -1,14 +1,10 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, 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'
@ -81,7 +77,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>
@ -100,26 +96,15 @@ 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="mt-4 px-4"> <div className="relative px-4 mt-4">
<p className="mb-1 text-sm text-gray-500">{label}</p> <p className="text-sm text-gray-500 mb-1">{label}</p>
<button <button
ref={buttonRef}
type="button" type="button"
onClick={handleOpen} onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3" className="w-full flex 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
@ -128,28 +113,26 @@ function CitySelectorRow({
/> />
</button> </button>
{open && {open && (
createPortal( <>
<> <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="fixed inset-0 z-9998" 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 {options.map((city) => (
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg" <button
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }} key={city.uuid}
> type="button"
{options.map((city) => ( onClick={() => {
<button onChange(city.uuid)
key={city.uuid} setOpen(false)
type="button" }}
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"
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}
{city.city_name} </button>
</button> ))}
))} </div>
</div> </>
</>, )}
document.body,
)}
</div> </div>
) )
} }
@ -168,28 +151,17 @@ 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="mt-4 px-4"> <div className="relative px-4 mt-4">
<p className="mb-1 text-sm text-gray-500">{label}</p> <p className="text-sm text-gray-500 mb-1">{label}</p>
<button <button
ref={buttonRef}
type="button" type="button"
disabled={!hasMatches} disabled={!hasMatches}
onClick={handleOpen} onClick={() => setOpen((o) => !o)}
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" 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"
> >
<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 ? 'بازی فعالی موجود نیست' : ''}
@ -200,28 +172,26 @@ function MatchSelectorRow({
/> />
</button> </button>
{open && hasMatches && {open && hasMatches && (
createPortal( <>
<> <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="fixed inset-0 z-9998" 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 {options.map((match) => (
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg" <button
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }} key={match.uuid}
> type="button"
{options.map((match) => ( onClick={() => {
<button onChange(match.uuid)
key={match.uuid} setOpen(false)
type="button" }}
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"
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)}
{formatMatchLabel(match, now)} </button>
</button> ))}
))} </div>
</div> </>
</>, )}
document.body,
)}
</div> </div>
) )
} }
@ -247,7 +217,6 @@ 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
@ -255,11 +224,7 @@ 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 overflow-hidden rounded-xl bg-gray-200"> <div className="h-32 w-36 shrink-0 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 && (
@ -273,12 +238,10 @@ 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[]>([])
@ -305,24 +268,7 @@ export default function Home() {
return ( return (
<div className="min-h-screen bg-white pb-24"> <div className="min-h-screen bg-white pb-24">
<div className="relative"> <HomeBanner />
<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 />
@ -373,7 +319,12 @@ export default function Home() {
</div> </div>
)} )}
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" /> <button
type="button"
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
>
پشتیبانی
</button>
</div> </div>
) )
} }

View file

@ -2,29 +2,14 @@ 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 { toEnglishDigits, toPersianDigits } from "../utils/formatNumber"; import { 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 = const initialPhone = (location.state as { phone?: string } | null)?.phone ?? "";
(location.state as { phone?: string } | null)?.phone ?? "";
const isOwnerFlow = location.pathname.startsWith("/cafe/login"); const isOwnerFlow = location.pathname.startsWith("/cafe/login");
useEffect(() => { useEffect(() => {
@ -36,113 +21,77 @@ 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.length === 11 && acceptedTerms && !isSubmitting; const canContinue = phone.trim().length > 0 && 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", { navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" }, } catch {
}); setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
} catch (err) {
setError(extractApiError(err));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<div className="min-h-screen relative overflow-hidden"> <form
<img onSubmit={(e) => {
src={!isOwnerFlow ? GuestBanner : HostBanner} e.preventDefault();
alt="بنر" if (canContinue) handleContinue();
className="w-full" }}
/> className="flex min-h-screen flex-col justify-center px-6"
{isOwnerFlow && ( >
<div className="mt-8 flex items-center justify-end gap-2 leading-relaxed rounded-2xl bg-[#ECF2F8] px-4 py-3 mx-4"> <h1 className="text-right text-2xl font-bold text-gray-900">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" /> ورود به وینسو
<p className="text-right text-sm text-gray-600"> </h1>
این شماره موبایل به‌عنوان حساب نماینده کافه در اپلیکیشن وینسو ثبت <p className="mt-2 text-right text-gray-500">
می‌شود و پرداخت‌ها به کیف پول آن واریز خواهد شد. لطفا شماره تماس خود را وارد کنید.
<br /> </p>
توجه داشته باشید برای برداشت شماره موبایل، کد ملی و کارت بانکی باید
متعلق به یک نفر باشند. <input
</p> type="text"
</div> dir="ltr"
)} inputMode="numeric"
<form enterKeyHint="go"
onSubmit={(e) => { value={phone}
e.preventDefault(); onChange={(e) => {
if (canContinue) handleContinue(); setPhone(toPersianDigits(e.target.value));
setError("");
}} }}
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6" 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"
> >
<h1 className="text-right text-2xl font-bold text-gray-900"> {isSubmitting ? "در حال ارسال..." : "ادامه"}
ورود به وینسو </button>
</h1>
<p className="mt-2 text-right text-gray-500">
لطفا شماره تماس خود را وارد کنید.
</p>
<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>
)}
<div className="mt-4 flex items-center justify-center gap-2">
<button <button
type="submit" type="button"
disabled={!canContinue} onClick={() => setAcceptedTerms((v) => !v)}
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" className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
}`}
> >
{isSubmitting ? "در حال ارسال..." : "ادامه"} {acceptedTerms && <Icon name="check" className="h-3 w-3 text-white" />}
</button> </button>
<p className="text-sm text-gray-600">
<div className="mt-4 flex items-center justify-center gap-2"> <a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
<button قوانین و شرایط
type="button" </a>{" "}
onClick={() => setAcceptedTerms((v) => !v)} را می‌پذیرم.
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${ </p>
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent" </div>
}`} </form>
>
{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>
); );
} }

View file

@ -1,272 +1,236 @@
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 { const state = location.state as { phone?: string; ttl?: number; role?: UserRole } | null
phone?: string; const phone = state?.phone ?? ''
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( const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS))
Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS), const [resendCycle, setResendCycle] = useState(0)
); const [error, setError] = useState('')
const [resendCycle, setResendCycle] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState(""); const [isResending, setIsResending] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false); const inputRefs = useRef<(HTMLInputElement | null)[]>([])
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) const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex)
.replace(/[^0-9]/g, "") if (!digits) return
.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[ inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus()
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({ .get({ otp: { transport: ['sms'] }, signal: abortController.signal } as unknown as CredentialRequestOptions)
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 = ( const handleOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
index: number, if (e.key === 'Backspace' && !otp[index] && index > 0) {
e: React.KeyboardEvent<HTMLInputElement>, inputRefs.current[index - 1]?.focus()
) => {
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 (
<div className="min-h-screen relative overflow-hidden"> <form
<img onSubmit={(e) => {
src={role === "customer" ? GuestBanner : HostBanner} e.preventDefault()
alt="بنر" if (canSubmit) handleSubmit()
className="w-full" }}
/> className="flex min-h-screen flex-col justify-center px-6"
<form >
onSubmit={(e) => { <p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
e.preventDefault(); <div className="mt-2 flex items-center justify-start gap-2">
if (canSubmit) handleSubmit(); <span className="text-[#223D5D]">{phone}</span>
}} <button type="button" onClick={() => navigate(loginPath, { state: { phone } })} aria-label="ویرایش شماره تماس">
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6" <Icon name="edit" className="h-5 w-5 text-[#223D5D]" />
>
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
<div className="mt-2 flex items-center justify-start gap-2">
<span className="text-[#223D5D]">{phone}</span>
<button
type="button"
onClick={() => navigate(loginPath, { state: { phone } })}
aria-label="ویرایش شماره تماس"
>
<Icon name="edit" className="h-5 w-5 text-[#223D5D]" />
</button>
</div>
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
{otp.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
enterKeyHint="go"
// no maxLength — the browser would truncate a pasted/autofilled multi-digit
// code to 1 char before onChange ever sees it; handleOtpChange enforces the
// single-digit display for normal typing instead
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(index, e)}
className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none"
/>
))}
</div>
<div className="mt-4 text-center">
{secondsLeft > 0 ? (
<p className="text-gray-500">
ارسال مجدد کد: {formatCountdown(secondsLeft)}
</p>
) : (
<button
type="button"
onClick={handleResend}
disabled={isResending}
className="text-[#376195] underline disabled:opacity-50"
>
{isResending ? "در حال ارسال..." : "ارسال مجدد کد"}
</button>
)}
</div>
{error && (
<p className="mt-2 text-center text-sm text-red-500">{error}</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال ورود..." : "ورود"}
</button> </button>
</form> </div>
</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>
)
} }

View file

@ -1,44 +1,30 @@
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 { import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
fetchMyReservationByUuid, import { formatPersianNumber } from '../utils/formatNumber'
fetchMyReservations, import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
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({ function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) {
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"> <h3 className="font-medium text-xl text-gray-900">{reservation.event_title}</h3>
{reservation.event_title} <p className="text-lg text-gray-500">{formatReservationDateTime(reservation.event_datetime)}</p>
</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)} نفر
@ -48,7 +34,7 @@ function MyReservationCard({
</span> </span>
</div> </div>
</button> </button>
); )
} }
function DetailRow({ label, value }: { label: string; value: string }) { function DetailRow({ label, value }: { label: string; value: string }) {
@ -57,60 +43,43 @@ 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({ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) {
uuid, const [reservation, setReservation] = useState<MyReservation | null>(null)
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 <div className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
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 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2>
جزئیات رزرو
</h2>
{reservation && ( {reservation && (
<div className="mt-2"> <div className="mt-2">
<DetailRow <DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
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 <DetailRow label="تاریخ و ساعت" value={formatReservationDateTime(reservation.event_datetime)} />
label="تاریخ و ساعت"
value={formatReservationDateTime(reservation.event_datetime)}
/>
<DetailRow label="کد رزرو" value={reservation.reservation_code} /> <DetailRow label="کد رزرو" value={reservation.reservation_code} />
</div> </div>
)} )}
@ -118,67 +87,48 @@ function ReservationDetailModal({
<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 <button type="button" onClick={onClose} className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white">
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 <button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
type="button" <Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
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 <MyReservationCard key={reservation.uuid} reservation={reservation} onClick={() => setSelectedUuid(reservation.uuid)} />
key={reservation.uuid}
reservation={reservation}
onClick={() => setSelectedUuid(reservation.uuid)}
/>
))} ))}
</div> </div>
{selectedUuid && ( {selectedUuid && (
<ReservationDetailModal <ReservationDetailModal uuid={selectedUuid} onClose={() => setSelectedUuid(null)} />
uuid={selectedUuid}
onClose={() => setSelectedUuid(null)}
/>
)} )}
</div> </div>
); )
} }

View file

@ -1,74 +1,46 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, 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 { import { createReservation, ReservationResult } from '../services/campaignApi'
createReservation, import { formatPersianNumber } from '../utils/formatNumber'
fetchMyReservationByUuid, import { fromE164 } from '../utils/phone'
MyReservation, import { takePendingReservation } from '../utils/pendingReservation'
} 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-start justify-between gap-4 py-2"> <div className="flex items-center justify-between py-2">
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0"> <span className="text-gray-500">{label}</span>
{value} <span className="font-medium text-gray-900">{value}</span>
</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<MyReservation | null>(null); const [reservation, setReservation] = useState<ReservationResult | null>(null)
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false)
const isFail = !isSuccess || hasError; // gateway-reported failure, or a successful payment whose reservation couldn't be created —
// both land on the same fail modal
// React 18 StrictMode double-invokes effects; the ref persists across the simulated const isFail = !isSuccess || hasError
// 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 || reservationAttempted.current) return; if (!isSuccess) return
reservationAttempted.current = true; const pending = takePendingReservation()
const pending = takePendingReservation();
if (!pending) { if (!pending) {
setHasError(true); Promise.resolve().then(() => setHasError(true))
return; return
} }
createReservation(pending) createReservation(pending)
.then((result) => .then(setReservation)
fetchMyReservationByUuid(result.uuid) .catch(() => setHasError(true))
.then(setReservation) }, [isSuccess])
.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">
@ -79,57 +51,28 @@ export default function PaymentSuccess() {
{reservation ? ( {reservation ? (
<> <>
<h2 className="mt-6 text-right font-bold text-gray-900"> <h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
جزئیات رزرو <div className="mt-2">
</h2> <DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
<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 <DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
label="شماره تماس" <DetailRow label="کد رزرو" value={reservation.reserve_code} />
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 <Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
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 className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
در حال ثبت رزرو...
</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"
> >
صفحه اصلی صفحه اصلی
@ -138,7 +81,7 @@ export default function PaymentSuccess() {
</div> </div>
)} )}
{isFail && <PaymentFailModal onGoHome={() => navigate("/home")} />} {isFail && <PaymentFailModal onGoHome={() => navigate('/home')} />}
</div> </div>
); )
} }

View file

@ -207,7 +207,6 @@ 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
} }
@ -216,10 +215,6 @@ 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

View file

@ -66,16 +66,3 @@ 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}`
}

View file

@ -1,12 +0,0 @@
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()
}

View file

@ -10,7 +10,3 @@ 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)
}

View file

@ -7,7 +7,3 @@ 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)
}