feature/WNPT-301 #6

Merged
afra.khalaj merged 6 commits from feature/WNPT-301 into develop 2026-07-25 04:03:40 -04:00
22 changed files with 834 additions and 388 deletions

View file

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3327 10.7493V14.2493C13.3327 17.166 12.166 18.3327 9.24935 18.3327H5.74935C2.83268 18.3327 1.66602 17.166 1.66602 14.2493V10.7493C1.66602 7.83268 2.83268 6.66602 5.74935 6.66602H9.24935C12.166 6.66602 13.3327 7.83268 13.3327 10.7493Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.3327 5.74935V9.24935C18.3327 12.166 17.166 13.3327 14.2493 13.3327H13.3327V10.7493C13.3327 7.83268 12.166 6.66602 9.24935 6.66602H6.66602V5.74935C6.66602 2.83268 7.83268 1.66602 10.7493 1.66602H14.2493C17.166 1.66602 18.3327 2.83268 18.3327 5.74935Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View file

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.67578 5.66969C6.90828 2.96969 8.29578 1.86719 11.3333 1.86719H11.4308C14.7833 1.86719 16.1258 3.20969 16.1258 6.56219V11.4522C16.1258 14.8047 14.7833 16.1472 11.4308 16.1472H11.3333C8.31828 16.1472 6.93078 15.0597 6.68328 12.4047" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.2498 9H2.71484" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.3875 6.48828L1.875 9.00078L4.3875 11.5133" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 685 B

View file

@ -5,6 +5,8 @@
// icons are referenced and tree-shake the rest — this file is the *only* place all 17
// icons are ever imported, so an icon that no call site uses never makes it into a chunk.
import ArrowRight from '../../assets/icons/arrow-right.svg?react'
import Copy from '../../assets/icons/copy.svg?react'
import Logout from '../../assets/icons/logout.svg?react'
import Book from '../../assets/icons/book.svg?react'
import Calendar from '../../assets/icons/calendar.svg?react'
import Call from '../../assets/icons/call.svg?react'
@ -28,6 +30,8 @@ import Success from '../../assets/icons/success.svg?react'
// is used, with no second list to keep in sync.
export const icons = {
arrowRight: ArrowRight,
copy: Copy,
logout: Logout,
book: Book,
calendar: Calendar,
call: Call,

View file

@ -0,0 +1,29 @@
import { createPortal } from 'react-dom'
export default function LogoutModal({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) {
return createPortal(
<>
<div className="fixed inset-0 z-9998 bg-black/40" onClick={onCancel} />
<div className="fixed inset-x-0 bottom-0 z-9999 rounded-t-2xl bg-white px-5 pb-8 pt-6 shadow-xl">
<p className="mb-6 text-center font-semibold text-gray-900">می‌خواهید از حساب کاربری خود خارج شوید؟</p>
<div className="flex gap-3">
<button
type="button"
onClick={onConfirm}
className="flex-1 rounded-xl bg-[#223D5D] py-3 font-semibold text-white"
>
بله
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 rounded-xl border border-[#223D5D] py-3 font-semibold text-gray-900"
>
خیر
</button>
</div>
</div>
</>,
document.body,
)
}

View file

@ -4,7 +4,11 @@ import { useNavigate } from "react-router-dom";
import { Cafe } from "../services/campaignApi";
import { chargePageUrl, createCharge } from "../services/ipgApi";
import { fetchWalletBalances } from "../services/walletApi";
import { toEnglishDigits, toPersianDigits, formatPersianNumber } from "../utils/formatNumber";
import {
toEnglishDigits,
toPersianDigits,
formatPersianNumber,
} from "../utils/formatNumber";
import { toE164 } from "../utils/phone";
import { setPendingReservation } from "../utils/pendingReservation";
import { getUserPhone } from "../utils/userPhone";
@ -39,6 +43,8 @@ function PaymentRow({
bottomBorder?: boolean;
infoIconParam?: boolean;
}) {
const [showInfo, setShowInfo] = useState(false);
return (
<div
className={`flex items-center justify-between py-3 ${bottomBorder ? "border-b border-gray-100" : ""}`}
@ -52,7 +58,14 @@ function PaymentRow({
{label}
</span>
{infoIconParam && (
<Icon name="info" className="h-5 w-5 shrink-0 text-[#376195]" />
<button
type="button"
onClick={() => setShowInfo(true)}
className="shrink-0"
aria-label="توضیحات کارمزد"
>
<Icon name="info" className="h-5 w-5 text-[#376195]" />
</button>
)}
</div>
<span
@ -60,6 +73,31 @@ function PaymentRow({
>
{formatPersianNumber(amount)} تومان
</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>
);
}
@ -182,14 +220,19 @@ export default function ReservationModal({
<input
type="text"
value={fullName}
onChange={(e) => { setFullName(e.target.value); clearFieldError("name"); }}
onChange={(e) => {
setFullName(e.target.value);
clearFieldError("name");
}}
placeholder="نام و نام خانوادگی"
className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${
fieldErrors.name ? "border-red-400" : "border-gray-200"
}`}
/>
{fieldErrors.name && (
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.name}</p>
<p className="mt-1 text-right text-sm text-red-500">
{fieldErrors.name}
</p>
)}
</div>
@ -203,7 +246,9 @@ export default function ReservationModal({
inputMode="numeric"
value={phone}
onChange={(e) => {
const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11);
const digits = toEnglishDigits(e.target.value)
.replace(/\D/g, "")
.slice(0, 11);
setPhone(toPersianDigits(digits));
clearFieldError("phone");
}}
@ -213,7 +258,9 @@ export default function ReservationModal({
}`}
/>
{fieldErrors.phone && (
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.phone}</p>
<p className="mt-1 text-right text-sm text-red-500">
{fieldErrors.phone}
</p>
)}
</div>
@ -242,10 +289,14 @@ export default function ReservationModal({
</span>
<button
type="button"
onClick={() => setCount((prev) => Math.min(remainingTickets, prev + 1))}
onClick={() =>
setCount((prev) => Math.min(remainingTickets, prev + 1))
}
disabled={atMaxCount}
className={`flex h-6 w-6 items-center justify-center ${
atMaxCount ? "cursor-not-allowed text-[#999999]" : "text-[#223D5D]"
atMaxCount
? "cursor-not-allowed text-[#999999]"
: "text-[#223D5D]"
}`}
>
<Icon name="plus" className="h-4 w-4" />

View file

@ -0,0 +1,93 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
const SUPPORT_PHONE = "02111111111";
const SUPPORT_PHONE_DISPLAY = "۰۲۱-۱۱۱۱۱۱۱۱";
function isMobileDevice() {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}
export default function SupportButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const handleClick = () => {
if (isMobileDevice()) {
window.location.href = `tel:${SUPPORT_PHONE}`
} else {
setOpen(true);
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(SUPPORT_PHONE);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// silent fallback
}
};
return (
<>
<button
type="button"
onClick={handleClick}
className={
className ??
"rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
}
>
پشتیبانی
</button>
{open &&
createPortal(
<>
<div
className="fixed inset-0 z-9998 bg-black/40"
onClick={() => setOpen(false)}
/>
<div
dir="rtl"
className="fixed inset-x-6 top-1/2 z-9999 -translate-y-1/2 rounded-2xl bg-white px-5 shadow-xl"
style={{ height: "72px" }}
>
<div className="flex h-full items-center justify-start gap-3">
<p className="text-right text-gray-900">
{copied ? (
"کپی شد!"
) : (
<div className="flex items-center gap-2">
شماره تماس پشتیبانی:{" "}
<span dir="ltr">{SUPPORT_PHONE_DISPLAY}</span>
<button
type="button"
onClick={handleCopy}
aria-label="کپی شماره"
className="shrink-0 text-gray-400 transition-colors hover:text-gray-700"
>
<Icon name="copy" className="h-5 w-5" />
</button>
</div>
)}
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute left-3 top-1/3 -translate-y-1/2 text-black hover:text-gray-600"
aria-label="بستن"
>
✕
</button>
</div>
</div>
</>,
document.body,
)}
</>
);
}

View file

@ -214,7 +214,7 @@ export default function AddEvent() {
<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" />
<span className="text-lg font-bold text-gray-900">افزودن ایونت</span>
<span className="text-lg font-bold text-gray-900">افزودن رویداد</span>
</button>
<div className="px-4">

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">
<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 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')}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
افزودن ایونت
افزودن رویداد
</button>
<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">
<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 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')}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
افزودن ایونت
افزودن رویداد
</button>
<button
type="button"

View file

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

View file

@ -6,6 +6,7 @@ import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "reac
import "leaflet/dist/leaflet.css";
import "../utils/leafletIcons";
import Icon from "../components/Icon";
import SupportButton from "../components/SupportButton";
import {
CafeFeature,
CafeMediaItem,
@ -831,12 +832,7 @@ export default function CafeOwnerRegister() {
</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">
<button
type="button"
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
>
پشتیبانی
</button>
<SupportButton className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white" />
<button
type="button"
disabled={isSubmitting}

View file

@ -11,7 +11,7 @@ export default function CafeRegisterSuccess() {
<Icon name="success" className="mx-auto h-28 w-28" />
<h1 className="mt-6 text-xl font-bold text-gray-900">کافه شما با موفقیت ثبت شد!</h1>
<p className="mt-3 leading-relaxed text-gray-500">
با اضافه کردن ایونت بازی‌ها، کافه شما در کمپین نمایش داده می‌شود و کاربران می‌توانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.
با اضافه کردن رویداد بازی‌ها، کافه شما در کمپین نمایش داده می‌شود و کاربران می‌توانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.
</p>
</div>
@ -21,7 +21,7 @@ export default function CafeRegisterSuccess() {
onClick={() => navigate('/owner/events/new')}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
افزودن ایونت
افزودن رویداد
</button>
<button
type="button"

View file

@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import Icon from "../components/Icon";
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
import { EventReservation, fetchEventReservations } from "../services/campaignApi";
import {
EventReservation,
fetchEventReservations,
participateReservation,
} from "../services/campaignApi";
import { getCafeUuid } from "../utils/cafe";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
import { formatPersianNumber } from "../utils/formatNumber";
import { fromE164 } from "../utils/phone";
function toEnglishDigits(value: string) {
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
@ -47,13 +52,14 @@ function downloadReservationsCsv(reservations: EventReservation[]) {
function ReservationCard({
reservation,
selected,
isToggling,
onToggle,
}: {
reservation: EventReservation;
selected: boolean;
isToggling: boolean;
onToggle: () => void;
}) {
const participated = reservation.is_participated;
return (
<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">
@ -66,15 +72,19 @@ function ReservationCard({
</span>
</div>
<p className="text-gray-900">{reservation.full_name}</p>
<p className="text-gray-500">{reservation.phone_number}</p>
<p dir="ltr" className="text-left text-gray-500">{fromE164(reservation.phone_number)}</p>
</div>
<div className="flex flex-col items-center self-end gap-2">
<button
type="button"
onClick={onToggle}
disabled={isToggling}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[1.5px] border-[#223D5D] transition-colors disabled:opacity-50 ${participated ? "bg-[#223D5D]" : "bg-transparent"}`}
>
{participated && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
<p className="text-xs text-black">تایید ورود</p>
</div>
<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>
);
}
@ -84,7 +94,7 @@ export default function EventReservations() {
const location = useLocation();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
const [reservations, setReservations] = useState<EventReservation[]>([]);
const event: CafeEvent | undefined =
@ -107,12 +117,24 @@ export default function EventReservations() {
[reservations, query],
);
const toggleSelected = (reservationId: string) => {
setSelectedIds((prev) =>
prev.includes(reservationId)
? prev.filter((id) => id !== reservationId)
: [...prev, reservationId],
);
const handleParticipate = async (reservation: EventReservation) => {
if (togglingIds.has(reservation.uuid)) return;
const newValue = !reservation.is_participated;
setTogglingIds((prev) => new Set([...prev, reservation.uuid]));
try {
await participateReservation(reservation.uuid, newValue);
setReservations((prev) =>
prev.map((r) => (r.uuid === reservation.uuid ? { ...r, is_participated: newValue } : r)),
);
} catch {
// leave checkbox at current state on failure
} finally {
setTogglingIds((prev) => {
const next = new Set(prev);
next.delete(reservation.uuid);
return next;
});
}
};
const handleDownload = () => {
@ -128,7 +150,10 @@ export default function EventReservations() {
onClick={() => navigate(-1)}
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
>
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
<Icon
name="arrowRight"
className="h-5 w-5 -scale-x-100 text-gray-900"
/>
<span className="text-md font-semibold text-gray-900">
رزروهای {event?.matchLabel ?? ""}
</span>
@ -153,8 +178,8 @@ export default function EventReservations() {
<ReservationCard
key={reservation.uuid}
reservation={reservation}
selected={selectedIds.includes(reservation.uuid)}
onToggle={() => toggleSelected(reservation.uuid)}
isToggling={togglingIds.has(reservation.uuid)}
onToggle={() => handleParticipate(reservation)}
/>
))}
</div>

View file

@ -1,4 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import SupportButton from '../components/SupportButton'
import LogoutModal from '../components/LogoutModal'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
@ -6,6 +8,7 @@ import 'leaflet/dist/leaflet.css'
import '../utils/leafletIcons'
import Icon from '../components/Icon'
import HomeBanner from '../components/HomeBanner'
import { logout } from '../utils/logout'
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown'
@ -78,7 +81,7 @@ function CafesMapPreview({ cafes }: { cafes: Cafe[] }) {
</MapContainer>
{/* transparent overlay — captures all clicks and opens the full-page map */}
<div
className="absolute inset-0 z-[1000] cursor-pointer"
className="absolute inset-0 z-1000 cursor-pointer"
onClick={() => navigate('/map', { state: { cafes } })}
/>
</div>
@ -270,10 +273,12 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
}
export default function Home() {
const navigate = useNavigate()
const [view, setView] = useState<'list' | 'map'>('list')
const [cities, setCities] = useState<City[]>([])
const [selectedCityUuid, setSelectedCityUuid] = useState('')
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
const [showLogout, setShowLogout] = useState(false)
const { matches, now } = useMatchEvents()
const [cafes, setCafes] = useState<Cafe[]>([])
@ -300,7 +305,24 @@ export default function Home() {
return (
<div className="min-h-screen bg-white pb-24">
<HomeBanner />
<div className="relative">
<HomeBanner />
<button
type="button"
onClick={() => setShowLogout(true)}
className="absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-white/90 px-3 py-1.5 text-sm font-semibold text-gray-800 shadow"
>
<Icon name="logout" className="h-4 w-4" />
خروج
</button>
</div>
{showLogout && (
<LogoutModal
onConfirm={() => { logout(); navigate('/login') }}
onCancel={() => setShowLogout(false)}
/>
)}
<MyReservationsRow />
@ -351,12 +373,7 @@ export default function Home() {
</div>
)}
<button
type="button"
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
>
پشتیبانی
</button>
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
</div>
)
}

View file

@ -5,6 +5,8 @@ import { requestOtp } from "../services/authApi";
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
import { toE164 } from "../utils/phone";
import { setUserRole } from "../utils/role";
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
interface ApiErrorResponse {
status_message?: string;
@ -12,7 +14,8 @@ interface ApiErrorResponse {
}
function extractApiError(error: unknown): string {
const data = (error as { response?: { data?: ApiErrorResponse } })?.response?.data;
const data = (error as { response?: { data?: ApiErrorResponse } })?.response
?.data;
if (data?.status_message) return data.status_message;
return "ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
}
@ -20,7 +23,8 @@ function extractApiError(error: unknown): string {
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const initialPhone = (location.state as { phone?: string } | null)?.phone ?? "";
const initialPhone =
(location.state as { phone?: string } | null)?.phone ?? "";
const isOwnerFlow = location.pathname.startsWith("/cafe/login");
useEffect(() => {
@ -43,7 +47,9 @@ export default function Login() {
setIsSubmitting(true);
try {
const { ttl } = await requestOtp(toE164(phone));
navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
navigate("/login/otp", {
state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" },
});
} catch (err) {
setError(extractApiError(err));
} finally {
@ -52,62 +58,91 @@ export default function Login() {
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (canContinue) handleContinue();
}}
className="flex min-h-screen flex-col justify-center px-6"
>
<h1 className="text-right text-2xl font-bold text-gray-900">
ورود به وینسو
</h1>
<p className="mt-2 text-right text-gray-500">
لطفا شماره تماس خود را وارد کنید.
</p>
<input
type="text"
dir="ltr"
inputMode="numeric"
enterKeyHint="go"
value={phone}
onChange={(e) => {
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]"
<div className="min-h-screen relative overflow-hidden">
<img
src={!isOwnerFlow ? GuestBanner : HostBanner}
alt="بنر"
className="w-full"
/>
{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"
{isOwnerFlow && (
<div className="mt-8 flex items-center justify-end gap-2 leading-relaxed rounded-2xl bg-[#ECF2F8] px-4 py-3 mx-4">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-600">
این شماره موبایل به‌عنوان حساب نماینده کافه در اپلیکیشن وینسو ثبت
می‌شود و پرداخت‌ها به کیف پول آن واریز خواهد شد.
<br />
توجه داشته باشید برای برداشت شماره موبایل، کد ملی و کارت بانکی باید
متعلق به یک نفر باشند.
</p>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (canContinue) handleContinue();
}}
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
>
{isSubmitting ? "در حال ارسال..." : "ادامه"}
</button>
<div className="mt-4 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAcceptedTerms((v) => !v)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
}`}
>
{acceptedTerms && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
<p className="text-sm text-gray-600">
<a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
قوانین و شرایط
</a>{" "}
را می‌پذیرم.
<h1 className="text-right text-2xl font-bold text-gray-900">
ورود به وینسو
</h1>
<p className="mt-2 text-right text-gray-500">
لطفا شماره تماس خود را وارد کنید.
</p>
</div>
</form>
<input
id="login-phone-number"
type="text"
dir="ltr"
inputMode="numeric"
enterKeyHint="go"
value={phone}
onChange={(e) => {
const digits = toEnglishDigits(e.target.value)
.replace(/\D/g, "")
.slice(0, 11);
setPhone(toPersianDigits(digits));
setError("");
}}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]"
/>
{error && (
<p className="mt-2 text-right text-sm text-red-500">{error}</p>
)}
<button
type="submit"
disabled={!canContinue}
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال ارسال..." : "ادامه"}
</button>
<div className="mt-4 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAcceptedTerms((v) => !v)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
}`}
>
{acceptedTerms && (
<Icon name="check" className="h-3 w-3 text-white" />
)}
</button>
<p className="text-sm text-gray-600">
<a
href="https://winsoo.ir/terms/"
className="text-[#223D5D] underline"
>
قوانین و شرایط
</a>{" "}
را می‌پذیرم.
</p>
</div>
</form>
</div>
);
}

View file

@ -1,236 +1,272 @@
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
import { requestOtp, verifyOtp } from '../services/authApi'
import { fetchMyCafes } from '../services/campaignApi'
import { storeAuthTokens } from '../utils/authToken'
import { clearCafeUuid, setCafeUuid } from '../utils/cafe'
import { setUserPhone } from '../utils/userPhone'
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
import { toE164 } from '../utils/phone'
import { getUserRole, setUserRole, UserRole } from '../utils/role'
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Icon from "../components/Icon";
import { requestOtp, verifyOtp } from "../services/authApi";
import { fetchMyCafes } from "../services/campaignApi";
import { storeAuthTokens } from "../utils/authToken";
import { clearCafeUuid, setCafeUuid } from "../utils/cafe";
import { setUserPhone } from "../utils/userPhone";
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
import { toE164 } from "../utils/phone";
import { getUserRole, setUserRole, UserRole } from "../utils/role";
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
const OTP_LENGTH = 5
const DEFAULT_RESEND_SECONDS = 120
const OTP_LENGTH = 5;
const DEFAULT_RESEND_SECONDS = 120;
// not yet in TS's DOM lib — minimal shape for the WebOTP API result
interface OTPCredential extends Credential {
code: string
code: string;
}
function formatCountdown(seconds: number) {
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0')
return toPersianDigits(`${mm}:${ss}`)
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return toPersianDigits(`${mm}:${ss}`);
}
export default function LoginOtp() {
const navigate = useNavigate()
const location = useLocation()
const state = location.state as { phone?: string; ttl?: number; role?: UserRole } | null
const phone = state?.phone ?? ''
const navigate = useNavigate();
const location = useLocation();
const state = location.state as {
phone?: string;
ttl?: number;
role?: UserRole;
} | null;
const phone = state?.phone ?? "";
// the role this OTP attempt is for — passed explicitly from Login.tsx rather than
// re-read from the global getUserRole(), which could've drifted (e.g. another tab/flow
// overwriting it in between) and silently route a cafe owner through the customer path
const role = state?.role ?? getUserRole()
const loginPath = role === 'cafe_owner' ? '/cafe/login' : '/login'
const role = state?.role ?? getUserRole();
const loginPath = role === "cafe_owner" ? "/cafe/login" : "/login";
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''))
const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS))
const [resendCycle, setResendCycle] = useState(0)
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isResending, setIsResending] = useState(false)
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(""));
const [secondsLeft, setSecondsLeft] = useState(
Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS),
);
const [resendCycle, setResendCycle] = useState(0);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isResending, setIsResending] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// fills consecutive boxes starting at startIndex — used by both the WebOTP
// listener below and a multi-digit autofill landing in a single box (e.g. iOS's
// autocomplete="one-time-code" suggestion pastes the whole code into one input)
const fillOtp = (startIndex: number, rawValue: string) => {
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex)
if (!digits) return
const digits = toEnglishDigits(rawValue)
.replace(/[^0-9]/g, "")
.slice(0, OTP_LENGTH - startIndex);
if (!digits) return;
setOtp((prev) => {
const next = [...prev]
digits.split('').forEach((d, i) => {
next[startIndex + i] = d
})
return next
})
setError('')
inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus()
}
const next = [...prev];
digits.split("").forEach((d, i) => {
next[startIndex + i] = d;
});
return next;
});
setError("");
inputRefs.current[
Math.min(startIndex + digits.length, OTP_LENGTH) - 1
]?.focus();
};
useEffect(() => {
if (!phone) {
navigate(loginPath, { replace: true })
navigate(loginPath, { replace: true });
}
}, [phone, loginPath, navigate])
}, [phone, loginPath, navigate]);
useEffect(() => {
inputRefs.current[0]?.focus()
}, [])
inputRefs.current[0]?.focus();
}, []);
// Android/Chrome: WebOTP lets the browser read the code straight off an incoming
// SMS without the user opening the messaging app — only fires if the SMS body ends
// with a line like "@<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.
useEffect(() => {
if (!('OTPCredential' in window)) return
const abortController = new AbortController()
if (!("OTPCredential" in window)) return;
const abortController = new AbortController();
navigator.credentials
.get({ otp: { transport: ['sms'] }, signal: abortController.signal } as unknown as CredentialRequestOptions)
.get({
otp: { transport: ["sms"] },
signal: abortController.signal,
} as unknown as CredentialRequestOptions)
.then((credential) => {
const code = (credential as OTPCredential | null)?.code
if (code) fillOtp(0, code)
const code = (credential as OTPCredential | null)?.code;
if (code) fillOtp(0, code);
})
.catch(() => {})
return () => abortController.abort()
}, [])
.catch(() => {});
return () => abortController.abort();
}, []);
useEffect(() => {
const timer = setInterval(() => {
setSecondsLeft((s) => {
if (s <= 1) {
clearInterval(timer)
return 0
clearInterval(timer);
return 0;
}
return s - 1
})
}, 1000)
return () => clearInterval(timer)
}, [resendCycle])
return s - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [resendCycle]);
const handleOtpChange = (index: number, rawValue: string) => {
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '')
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, "");
// a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion
if (digits.length > 1) {
fillOtp(index, digits)
return
fillOtp(index, digits);
return;
}
setOtp((prev) => {
const next = [...prev]
next[index] = digits
return next
})
setError('')
const next = [...prev];
next[index] = digits;
return next;
});
setError("");
if (digits && index < OTP_LENGTH - 1) {
inputRefs.current[index + 1]?.focus()
inputRefs.current[index + 1]?.focus();
}
}
};
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !otp[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
const handleOtpKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLInputElement>,
) => {
if (e.key === "Backspace" && !otp[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
}
};
const handleResend = async () => {
setError('')
setIsResending(true)
setError("");
setIsResending(true);
try {
const { ttl } = await requestOtp(toE164(phone))
setOtp(Array(OTP_LENGTH).fill(''))
inputRefs.current[0]?.focus()
setSecondsLeft(Math.round(ttl))
setResendCycle((c) => c + 1)
const { ttl } = await requestOtp(toE164(phone));
setOtp(Array(OTP_LENGTH).fill(""));
inputRefs.current[0]?.focus();
setSecondsLeft(Math.round(ttl));
setResendCycle((c) => c + 1);
} catch {
setError('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
setError("ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
} finally {
setIsResending(false)
setIsResending(false);
}
}
};
const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting
const canSubmit = otp.every((digit) => digit !== "") && !isSubmitting;
const handleSubmit = async () => {
setError('')
setIsSubmitting(true)
setError("");
setIsSubmitting(true);
try {
const tokens = await verifyOtp(toE164(phone), otp.join(''))
storeAuthTokens(tokens.access_token, tokens.refresh_token)
setUserPhone(phone)
const tokens = await verifyOtp(toE164(phone), otp.join(""));
storeAuthTokens(tokens.access_token, tokens.refresh_token);
setUserPhone(phone);
// re-sync in case the global value drifted since Login.tsx set it — `role` (from
// route state) is the source of truth for this submission, not getUserRole()
setUserRole(role)
setUserRole(role);
if (role === 'cafe_owner') {
const myCafes = await fetchMyCafes().catch(() => [])
if (role === "cafe_owner") {
const myCafes = await fetchMyCafes().catch(() => []);
if (myCafes.length > 0) {
setCafeUuid(myCafes[0].uuid)
navigate('/owner/panel')
setCafeUuid(myCafes[0].uuid);
navigate("/owner/panel");
} else {
clearCafeUuid()
navigate('/owner/register')
clearCafeUuid();
navigate("/owner/register");
}
} else {
navigate('/home')
navigate("/home");
}
} catch {
setError('کد وارد شده صحیح نیست')
setError("کد وارد شده صحیح نیست");
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) handleSubmit()
}}
className="flex min-h-screen flex-col justify-center px-6"
>
<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"
<div className="min-h-screen relative overflow-hidden">
<img
src={role === "customer" ? GuestBanner : HostBanner}
alt="بنر"
className="w-full"
/>
<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) handleSubmit();
}}
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6"
>
{isSubmitting ? 'در حال ورود...' : 'ورود'}
</button>
</form>
)
<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>
</form>
</div>
);
}

View file

@ -1,30 +1,44 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
import Icon from "../components/Icon";
import {
fetchMyReservationByUuid,
fetchMyReservations,
MyReservation,
} from "../services/campaignApi";
import { formatPersianNumber } from "../utils/formatNumber";
import GuestBanner from "../assets/images/banners/Guest-banner.jpg";
function formatReservationDateTime(iso: string): string {
return new Intl.DateTimeFormat('fa-IR', {
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso))
return new Intl.DateTimeFormat("fa-IR", {
weekday: "long",
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) {
function MyReservationCard({
reservation,
onClick,
}: {
reservation: MyReservation;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full flex-col items-start gap-3 rounded-2xl p-6 text-right shadow-[0px_4px_12px_0px_#0000001F]"
>
<h3 className="font-medium text-xl text-gray-900">{reservation.event_title}</h3>
<p className="text-lg text-gray-500">{formatReservationDateTime(reservation.event_datetime)}</p>
<h3 className="font-medium text-xl text-gray-900">
{reservation.event_title}
</h3>
<p className="text-lg text-gray-500">
{formatReservationDateTime(reservation.event_datetime)}
</p>
<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">
{formatPersianNumber(reservation.ticket_count)} نفر
@ -34,7 +48,7 @@ function MyReservationCard({ reservation, onClick }: { reservation: MyReservatio
</span>
</div>
</button>
)
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
@ -43,43 +57,60 @@ function DetailRow({ label, value }: { label: string; value: string }) {
<span className="text-gray-500">{label}</span>
<span className="text-gray-900">{value}</span>
</div>
)
);
}
function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () => void }) {
const [reservation, setReservation] = useState<MyReservation | null>(null)
function ReservationDetailModal({
uuid,
onClose,
}: {
uuid: string;
onClose: () => void;
}) {
const [reservation, setReservation] = useState<MyReservation | null>(null);
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow
}
}, [])
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
fetchMyReservationByUuid(uuid)
.then(setReservation)
.catch(() => {})
}, [uuid])
.catch(() => {});
}, [uuid]);
return createPortal(
<div className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
<div
className="fixed inset-0 z-1100 flex items-center justify-center bg-black/70 p-4"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full overflow-y-auto rounded-3xl bg-white p-6"
onClick={(e) => e.stopPropagation()}
>
<Icon name="success" className="mx-auto h-24 w-24" />
<h2 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2>
<h2 className="mt-4 text-right font-semibold text-gray-900">
جزئیات رزرو
</h2>
{reservation && (
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
<DetailRow
label="تعداد"
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
/>
<DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={reservation.phone_number} />
<DetailRow label="کافه" value={reservation.cafe_name} />
<DetailRow label="بازی" value={reservation.event_title} />
<DetailRow label="تاریخ و ساعت" value={formatReservationDateTime(reservation.event_datetime)} />
<DetailRow
label="تاریخ و ساعت"
value={formatReservationDateTime(reservation.event_datetime)}
/>
<DetailRow label="کد رزرو" value={reservation.reservation_code} />
</div>
)}
@ -87,48 +118,67 @@ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () =
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-500">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک می‌شود.
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود در
بخش رزروها قابل مشاهده است.
</p>
</div>
<button type="button" onClick={onClose} className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white">
<button
type="button"
onClick={onClose}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
بازگشت
</button>
</div>
</div>,
document.body,
)
);
}
export default function MyReservations() {
const navigate = useNavigate()
const [reservations, setReservations] = useState<MyReservation[]>([])
const [selectedUuid, setSelectedUuid] = useState<string | null>(null)
const navigate = useNavigate();
const [reservations, setReservations] = useState<MyReservation[]>([]);
const [selectedUuid, setSelectedUuid] = useState<string | null>(null);
useEffect(() => {
fetchMyReservations()
.then(setReservations)
.catch(() => {})
}, [])
.catch(() => {});
}, []);
return (
<div className="min-h-screen bg-white pb-24">
<img src={GuestBanner} alt="" className="w-full" />
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
<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"
/>
<span className="text-lg font-bold text-gray-900">رزروهای شما</span>
</button>
<div className="mt-4 flex flex-col gap-3 px-4">
{reservations.map((reservation) => (
<MyReservationCard key={reservation.uuid} reservation={reservation} onClick={() => setSelectedUuid(reservation.uuid)} />
<MyReservationCard
key={reservation.uuid}
reservation={reservation}
onClick={() => setSelectedUuid(reservation.uuid)}
/>
))}
</div>
{selectedUuid && (
<ReservationDetailModal uuid={selectedUuid} onClose={() => setSelectedUuid(null)} />
<ReservationDetailModal
uuid={selectedUuid}
onClose={() => setSelectedUuid(null)}
/>
)}
</div>
)
);
}

View file

@ -1,50 +1,52 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Icon from '../components/Icon'
import PaymentFailModal from '../components/PaymentFailModal'
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Icon from "../components/Icon";
import PaymentFailModal from "../components/PaymentFailModal";
import {
createReservation,
fetchMyReservationByUuid,
MyReservation,
} from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import { fromE164 } from '../utils/phone'
import { takePendingReservation } from '../utils/pendingReservation'
import { formatMatchDatetime } from '../utils/iranDate'
} from "../services/campaignApi";
import { formatPersianNumber } from "../utils/formatNumber";
import { fromE164 } from "../utils/phone";
import { takePendingReservation } from "../utils/pendingReservation";
import { formatMatchDatetime } from "../utils/iranDate";
// the IPG gateway redirects back here as `/payment/success?state=<code>` regardless of
// whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far
const SUCCESS_STATE = '20'
const SUCCESS_STATE = "20";
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">{value}</span>
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">
{value}
</span>
<span className="text-right text-gray-500 shrink-0">{label}</span>
</div>
)
);
}
export default function PaymentSuccess() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isSuccess = searchParams.get('state') === SUCCESS_STATE
const [reservation, setReservation] = useState<MyReservation | null>(null)
const [hasError, setHasError] = useState(false)
const isFail = !isSuccess || hasError
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isSuccess = searchParams.get("state") === SUCCESS_STATE;
const [reservation, setReservation] = useState<MyReservation | null>(null);
const [hasError, setHasError] = useState(false);
const isFail = !isSuccess || hasError;
// React 18 StrictMode double-invokes effects; the ref persists across the simulated
// remount so the second invocation sees it as true and exits early, preventing
// takePendingReservation() from returning null on the second run and triggering hasError
const reservationAttempted = useRef(false)
const reservationAttempted = useRef(false);
useEffect(() => {
if (!isSuccess || reservationAttempted.current) return
reservationAttempted.current = true
const pending = takePendingReservation()
if (!isSuccess || reservationAttempted.current) return;
reservationAttempted.current = true;
const pending = takePendingReservation();
if (!pending) {
setHasError(true)
return
setHasError(true);
return;
}
createReservation(pending)
.then((result) =>
@ -58,15 +60,15 @@ export default function PaymentSuccess() {
ticket_count: result.people_count,
full_name: result.full_name,
phone_number: result.phone_number,
cafe_name: '',
event_title: '',
event_datetime: '',
cafe_name: "",
event_title: "",
event_datetime: "",
status: result.status,
})
});
}),
)
.catch(() => setHasError(true))
}, [isSuccess])
.catch(() => setHasError(true));
}, [isSuccess]);
return (
<div className="min-h-screen bg-gray-50">
@ -77,11 +79,19 @@ export default function PaymentSuccess() {
{reservation ? (
<>
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<h2 className="mt-6 text-right font-bold text-gray-900">
جزئیات رزرو
</h2>
<div className="mt-2 divide-y divide-gray-100">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
<DetailRow
label="تعداد"
value={`${formatPersianNumber(reservation.ticket_count)} نفر`}
/>
<DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
<DetailRow
label="شماره تماس"
value={fromE164(reservation.phone_number)}
/>
{reservation.cafe_name && (
<DetailRow label="کافه" value={reservation.cafe_name} />
)}
@ -89,25 +99,37 @@ export default function PaymentSuccess() {
<DetailRow label="بازی" value={reservation.event_title} />
)}
{reservation.event_datetime && (
<DetailRow label="تاریخ و ساعت" value={formatMatchDatetime(reservation.event_datetime)} />
<DetailRow
label="تاریخ و ساعت"
value={formatMatchDatetime(reservation.event_datetime)}
/>
)}
<DetailRow label="کد رزرو" value={reservation.reservation_code} />
<DetailRow
label="کد رزرو"
value={reservation.reservation_code}
/>
</div>
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<Icon
name="info"
className="h-5 w-5 shrink-0 text-gray-400"
/>
<p className="text-right text-sm text-gray-600">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود.
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد
ورود در بخش رزروها قابل مشاهده است.
</p>
</div>
</>
) : (
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
<p className="mt-6 text-center text-gray-500">
در حال ثبت رزرو...
</p>
)}
<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"
>
صفحه اصلی
@ -116,7 +138,7 @@ export default function PaymentSuccess() {
</div>
)}
{isFail && <PaymentFailModal onGoHome={() => navigate('/home')} />}
{isFail && <PaymentFailModal onGoHome={() => navigate("/home")} />}
</div>
)
);
}

View file

@ -207,6 +207,7 @@ export interface EventReservation {
full_name: string
phone_number: string
status: string
is_participated: boolean
created_at: string
}
@ -215,6 +216,10 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string
return data
}
export async function participateReservation(uuid: string, isParticipated: boolean): Promise<void> {
await api.patch(`/campaign/reservations/${uuid}/participate/`, { is_participated: isParticipated })
}
export interface MyReservation {
uuid: string
reservation_code: string

12
src/utils/logout.ts Normal file
View file

@ -0,0 +1,12 @@
import { clearAuthTokens } from './authToken'
import { clearCafeUuid } from './cafe'
import { clearUserRole } from './role'
import { clearUserPhone } from './userPhone'
export function logout() {
clearAuthTokens()
clearUserRole()
clearUserPhone()
clearCafeUuid()
sessionStorage.clear()
}

View file

@ -10,3 +10,7 @@ export function setUserRole(role: UserRole) {
export function getUserRole(): UserRole {
return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer'
}
export function clearUserRole() {
localStorage.removeItem(ROLE_STORAGE_KEY)
}

View file

@ -7,3 +7,7 @@ export function setUserPhone(phone: string) {
export function getUserPhone(): string | null {
return localStorage.getItem(USER_PHONE_STORAGE_KEY)
}
export function clearUserPhone() {
localStorage.removeItem(USER_PHONE_STORAGE_KEY)
}