Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
b89b34fbca Merge pull request 'feature/WNPT-301' (#6) from feature/WNPT-301 into develop
Reviewed-on: #6
2026-07-25 04:03:39 -04:00
Afra ‌
ae70edf99b perf: the support modal fixed 2026-07-25 11:28:38 +03:30
Afra ‌
434b5b5235 feat: info icon's modal on reservation modal added 2026-07-22 12:07:35 +03:30
Afra ‌
594cc75165 perf: events label changed across the app 2026-07-22 11:59:23 +03:30
Afra ‌
2e26ef067f feat: logout added to the app 2026-07-22 11:57:05 +03:30
Afra ‌
18996426ac feat: support added to the app 2026-07-22 11:31:14 +03:30
Afra ‌
9382cdb666 feat: login banners and info box added 2026-07-20 13:32:01 +03:30
43a6f05045 Merge pull request 'feature/WNPT-395' (#5) from feature/WNPT-395 into develop
Reviewed-on: #5
2026-07-20 04:14:42 -04:00
Afra ‌
c4b4780a4c perf: reservation modal inputs and success reservation modified 2026-07-18 15:29:21 +03:30
Afra ‌
88386ea77e perf: host panel, event adding fields modified 2026-07-18 15:06:17 +03:30
Afra ‌
e6e4c690ff perf: otp and cafe register fields error handling 2026-07-18 15:01:48 +03:30
b4c8c1a66f Merge pull request 'feature/WNPT-307' (#4) from feature/WNPT-307 into develop
Reviewed-on: #4
2026-07-18 06:49:16 -04:00
Afra ‌
79db6c625a perf: modified the phone number in the cafe register flow 2026-07-18 14:18:39 +03:30
Afra ‌
165c7fd0eb perf: modified routing button in cafe detail page 2026-07-18 13:56:22 +03:30
Afra ‌
5b7f904dbe perf: some modifications applied to app 2026-07-15 16:31:59 +03:30
27 changed files with 1399 additions and 600 deletions

View file

@ -14,6 +14,7 @@
"dependencies": {
"@tanstack/react-query": "^5.9.0",
"axios": "^1.6.6",
"heic2any": "^0.0.4",
"leaflet": "^1.9.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",

8
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
axios:
specifier: ^1.6.6
version: 1.17.0
heic2any:
specifier: ^0.0.4
version: 0.0.4
leaflet:
specifier: ^1.9.4
version: 1.9.4
@ -942,6 +945,9 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
heic2any@0.0.4:
resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@ -2245,6 +2251,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
heic2any@0.0.4: {}
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:

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 { 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";
@ -16,11 +20,15 @@ interface ReservationModalProps {
cafe: Cafe;
ticketPrice: number;
cafeMatchScheduleUuid: string;
remainingTickets: number;
}
// 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
const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
const WALLET_UUIDS = [
"e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d",
"af7d967f-30c0-409b-9066-2549f2da5e5e",
];
function PaymentRow({
label,
@ -35,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" : ""}`}
@ -48,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
@ -56,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>
);
}
@ -66,17 +108,32 @@ export default function ReservationModal({
cafe,
ticketPrice,
cafeMatchScheduleUuid,
remainingTickets,
}: ReservationModalProps) {
const navigate = useNavigate();
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState(() => getUserPhone() ?? "");
const [count, setCount] = useState(1);
const [walletBalance, setWalletBalance] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [payError, setPayError] = useState("");
const clearFieldError = (field: string) =>
setFieldErrors((prev) => {
if (!prev[field]) return prev;
const next = { ...prev };
delete next[field];
return next;
});
useEffect(() => {
if (!isOpen) return;
setFullName("");
setPhone(getUserPhone() ?? "");
setCount(1);
setFieldErrors({});
setPayError("");
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
@ -95,12 +152,16 @@ export default function ReservationModal({
const reservationAmount = ticketPrice * count;
const payableAmount = Math.max(0, reservationAmount - walletBalance);
const atMaxCount = remainingTickets > 0 && count >= remainingTickets;
const handlePay = async () => {
setError("");
setPayError("");
if (!fullName.trim()) {
setError("لطفاً نام و نام خانوادگی را وارد کنید.");
const errors: Record<string, string> = {};
if (!fullName.trim()) errors.name = "نام و نام خانوادگی را وارد کنید";
if (phone.length !== 11) errors.phone = "شماره تلفن باید ۱۱ رقم باشد";
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
@ -110,14 +171,16 @@ export default function ReservationModal({
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
people_count: count,
full_name: fullName,
phone_number: toE164(getUserPhone() ?? ""),
phone_number: toE164(phone),
};
try {
if (payableAmount > 0) {
// reservation creation happens on /payment/success, only once the gateway confirms state=20
setPendingReservation(payload);
const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
const appBaseUrl = (
import.meta.env.VITE_APP_BASE_URL || window.location.origin
).replace(/\/$/, "");
const { uuid } = await createCharge({
amount: payableAmount,
callback_url: `${appBaseUrl}/payment/success`,
@ -129,7 +192,9 @@ export default function ReservationModal({
navigate("/payment/success?state=20");
}
} catch {
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
setPayError(
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
);
setIsSubmitting(false);
}
};
@ -155,10 +220,20 @@ export default function ReservationModal({
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
onChange={(e) => {
setFullName(e.target.value);
clearFieldError("name");
}}
placeholder="نام و نام خانوادگی"
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]"
className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${
fieldErrors.name ? "border-red-400" : "border-gray-200"
}`}
/>
{fieldErrors.name && (
<p className="mt-1 text-right text-sm text-red-500">
{fieldErrors.name}
</p>
)}
</div>
<div className="mt-4">
@ -167,11 +242,26 @@ export default function ReservationModal({
</label>
<input
type="text"
value={getUserPhone() ?? ""}
readOnly
dir="ltr"
inputMode="numeric"
value={phone}
onChange={(e) => {
const digits = toEnglishDigits(e.target.value)
.replace(/\D/g, "")
.slice(0, 11);
setPhone(toPersianDigits(digits));
clearFieldError("phone");
}}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]"
className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${
fieldErrors.phone ? "border-red-400" : "border-gray-200"
}`}
/>
{fieldErrors.phone && (
<p className="mt-1 text-right text-sm text-red-500">
{fieldErrors.phone}
</p>
)}
</div>
<div className="mt-4">
@ -199,12 +289,24 @@ export default function ReservationModal({
</span>
<button
type="button"
onClick={() => setCount((prev) => prev + 1)}
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
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]"
}`}
>
<Icon name="plus" className="h-4 w-4" />
</button>
</div>
{remainingTickets > 0 && (
<p className="mt-1 text-right text-sm text-gray-400">
حداکثر {formatPersianNumber(remainingTickets)} نفر
</p>
)}
</div>
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
@ -229,14 +331,16 @@ export default function ReservationModal({
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-500">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده
کاربر است.
پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت
تمایل به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
</p>
</div>
</div>
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
{error && <p className="mb-2 text-center text-sm text-red-500">{error}</p>}
{payError && (
<p className="mb-2 text-center text-sm text-red-500">{payError}</p>
)}
<button
type="button"
disabled={isSubmitting || !cafeMatchScheduleUuid}

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

@ -21,11 +21,13 @@ function MatchSelect({
options,
now,
onChange,
error,
}: {
value: string
options: MatchEvent[]
now: Date
onChange: (uuid: string) => void
error?: string
}) {
const [open, setOpen] = useState(false)
const selectedMatch = options.find((match) => match.uuid === value)
@ -38,7 +40,7 @@ function MatchSelect({
type="button"
disabled={!hasMatches}
onClick={() => setOpen((o) => !o)}
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right disabled:opacity-50"
className={`flex h-14 w-full items-center justify-between rounded-2xl border px-4 text-right disabled:opacity-50 ${error ? 'border-red-400' : 'border-gray-200'}`}
>
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
{selectedMatch
@ -70,6 +72,7 @@ function MatchSelect({
</div>
</>
)}
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
</div>
)
}
@ -171,8 +174,24 @@ export default function AddEvent() {
const [entryFee, setEntryFee] = useState(100000)
const [description, setDescription] = useState('')
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 errors: Record<string, string> = {}
if (!selectedMatchUuid) errors.match = 'بازی را انتخاب کنید'
if (Object.keys(errors).length > 0) {
setFieldErrors(errors)
return
}
setFieldErrors({})
setIsSubmitting(true)
try {
await createCafeSchedule(getCafeUuid() ?? '', {
@ -195,11 +214,17 @@ export default function AddEvent() {
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
<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">
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
<MatchSelect
value={selectedMatchUuid}
options={matches}
now={now}
onChange={(uuid) => { setSelectedMatchUuid(uuid); clearFieldError('match') }}
error={fieldErrors.match}
/>
<div className="mt-5">
<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">
<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,4 +1,5 @@
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate, useLocation } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
import "leaflet/dist/leaflet.css";
@ -17,56 +18,6 @@ import { fromE164 } from "../utils/phone";
import Icon from "../components/Icon";
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({
children,
@ -86,50 +37,182 @@ function Pill({
function ImageCarousel({ images }: { images: string[] }) {
const [index, setIndex] = useState(0);
const slideCount = Math.max(images.length, 1);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const lightboxScrollRef = useRef<HTMLDivElement>(null);
const slideCount = images.length;
useEffect(() => {
if (!lightboxOpen) return;
setLightboxIndex(index);
requestAnimationFrame(() => {
if (lightboxScrollRef.current) {
lightboxScrollRef.current.scrollLeft =
index * lightboxScrollRef.current.clientWidth;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lightboxOpen]);
const handleLightboxScroll = () => {
if (!lightboxScrollRef.current) return;
const i = Math.round(
lightboxScrollRef.current.scrollLeft /
lightboxScrollRef.current.clientWidth,
);
setLightboxIndex(i);
};
if (slideCount === 0) {
return <div className="h-[35vh] w-full bg-gray-200" />;
}
return (
<div className="relative h-[35vh] w-full bg-gray-200">
<button
type="button"
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"
<>
<div
className="relative h-[35vh] w-full cursor-pointer bg-gray-200"
onClick={() => setLightboxOpen(true)}
>
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
</button>
<button
type="button"
onClick={() => setIndex((i) => (i + 1) % slideCount)}
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"
>
<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">
{Array.from({ length: slideCount }).map((_, i) => (
<span
key={i}
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-[#223D5D]" : "bg-white/70"}`}
/>
))}
<img
src={images[index]}
alt=""
className="h-full w-full object-cover"
/>
{slideCount > 1 && (
<>
<button
type="button"
aria-label="قبلی"
onClick={(e) => {
e.stopPropagation();
setIndex((i) => (i - 1 + slideCount) % slideCount);
}}
className="absolute left-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
>
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
</button>
<button
type="button"
aria-label="بعدی"
onClick={(e) => {
e.stopPropagation();
setIndex((i) => (i + 1) % slideCount);
}}
className="absolute right-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
>
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
</button>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
{images.map((_, i) => (
<span
key={i}
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-white" : "bg-white/50"}`}
/>
))}
</div>
</>
)}
</div>
</div>
{lightboxOpen &&
createPortal(
<div className="fixed inset-0 z-9999 flex flex-col bg-black">
<div className="flex shrink-0 items-center justify-between px-4 py-3">
<span className="text-sm text-white/60">
{lightboxIndex + 1} / {slideCount}
</span>
<button
type="button"
onClick={() => setLightboxOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-lg text-white"
>
✕
</button>
</div>
<div
ref={lightboxScrollRef}
dir="ltr"
className="flex flex-1 snap-x snap-mandatory overflow-x-auto"
style={{ scrollbarWidth: "none" }}
onScroll={handleLightboxScroll}
>
{images.map((url, i) => (
<div
key={i}
className="flex h-full w-full shrink-0 snap-start items-center justify-center p-2"
>
<img
src={url}
alt=""
className="max-h-full max-w-full object-contain"
/>
</div>
))}
</div>
{slideCount > 1 && (
<div className="flex shrink-0 items-center justify-center gap-2 py-4">
{images.map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i === lightboxIndex ? "w-4 bg-white" : "w-1.5 bg-white/40"
}`}
/>
))}
</div>
)}
</div>,
document.body,
)}
</>
);
}
async function openNavigationPicker(name: string, lat: number, lng: number) {
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
function NavigationSheet({
isOpen,
onClose,
lat,
lng,
}: {
isOpen: boolean;
onClose: () => void;
lat: number;
lng: number;
}) {
if (!isOpen) return null;
if (navigator.share) {
try {
await navigator.share({ title: name, url: mapsUrl });
} catch {
// user dismissed the native share sheet
}
} else {
window.open(mapsUrl, "_blank");
}
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const apps: { label: string; url: string }[] = [
{ label: "نشان", url: `https://neshan.org/maps/@${lat},${lng},15z` },
{ label: "بلد", url: `https://balad.ir/p?lat=${lat}&lng=${lng}` },
{ 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}` },
...(isIOS ? [{ label: "Apple Maps", url: `maps://?daddr=${lat},${lng}` }] : []),
];
return createPortal(
<>
<div className="fixed inset-0 z-9998 bg-black/50" onClick={onClose} />
<div className="fixed inset-x-0 bottom-0 z-9999 rounded-t-3xl bg-white px-5 pb-8 pt-4">
<div className="mx-auto mb-4 h-1 w-10 rounded-full bg-gray-200" />
<h3 className="mb-4 text-right font-semibold text-gray-900">مسیریابی با</h3>
<div className="flex flex-col gap-3">
{apps.map((app) => (
<button
key={app.label}
type="button"
onClick={() => { window.open(app.url, "_blank"); onClose(); }}
className="w-full rounded-2xl border border-gray-200 py-3 text-center text-gray-900 active:bg-gray-50"
>
{app.label}
</button>
))}
</div>
</div>
</>,
document.body,
);
}
export default function CafeDetail() {
@ -137,19 +220,29 @@ export default function CafeDetail() {
const navigate = useNavigate();
const now = useNow();
const [isReservationOpen, setIsReservationOpen] = useState(false);
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
const [selectedEventUuid, setSelectedEventUuid] = useState("");
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(undefined);
const [selectedEventUuid] = useState("");
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(
undefined,
);
const state = location.state as { cafe?: Cafe } | null;
const cafe = state?.cafe;
// only ever offer upcoming matches — picking a past one to "reserve" makes no sense
const upcomingSchedules = schedules
.filter((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());
.filter(
(s) => new Date(s.match_event.match_datetime).getTime() > now.getTime(),
)
.sort(
(a, b) =>
new Date(a.match_event.match_datetime).getTime() -
new Date(b.match_event.match_datetime).getTime(),
);
// defaults to the closest upcoming match, but the user can override via MatchPicker
const effectiveEventUuid = selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
const effectiveEventUuid =
selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
useEffect(() => {
if (!cafe) return;
@ -173,9 +266,14 @@ export default function CafeDetail() {
);
}
const images = cafe.media.filter((m) => m.type === "image").map((m) => m.url);
const images = (schedule?.cafe_media ?? cafe.media)
.filter((m) => m.type === "image")
.map((m) => m.url);
const tags = cafe.features.map((f) => f.name);
const mapPosition: [number, number] = [cafe.location.coordinates[1], cafe.location.coordinates[0]];
const mapPosition: [number, number] = [
cafe.location.coordinates[1],
cafe.location.coordinates[0],
];
return (
<div className="min-h-screen bg-white">
@ -185,9 +283,9 @@ export default function CafeDetail() {
type="button"
onClick={() => navigate(-1)}
aria-label="بازگشت"
className="absolute left-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
>
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
</button>
</div>
@ -195,21 +293,22 @@ export default function CafeDetail() {
<h1 className="text-right text-xl font-bold text-gray-900">
{cafe.name}
</h1>
<MatchPicker
value={effectiveEventUuid}
options={upcomingSchedules}
now={now}
onChange={setSelectedEventUuid}
/>
{upcomingSchedules[0] && (
<p className="mt-1 text-sm text-gray-500">
{formatMatchLabel(upcomingSchedules[0].match_event, now)}
</p>
)}
<div className="mt-3 flex flex-wrap gap-2 ">
{schedule && (
<>
<Pill className="bg-[#ECF2F8] text-gray-700">
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))}{" "}
تومان
</Pill>
<Pill className="bg-[#ECF2F8] text-gray-700">
ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر
ظرفیت باقی مانده:{" "}
{formatPersianNumber(schedule.remaining_tickets)}نفر
</Pill>
</>
)}
@ -220,6 +319,13 @@ export default function CafeDetail() {
))}
</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>
@ -260,7 +366,7 @@ export default function CafeDetail() {
<button
type="button"
onClick={() => openNavigationPicker(cafe.name, mapPosition[0], mapPosition[1])}
onClick={() => setIsNavigationOpen(true)}
className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900"
>
مسیریابی
@ -270,8 +376,8 @@ export default function CafeDetail() {
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-600">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده
کاربر است.
پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت تمایل
به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
</p>
</div>
</div>
@ -293,6 +399,13 @@ export default function CafeDetail() {
cafe={cafe}
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
remainingTickets={schedule?.remaining_tickets ?? 0}
/>
<NavigationSheet
isOpen={isNavigationOpen}
onClose={() => setIsNavigationOpen(false)}
lat={mapPosition[0]}
lng={mapPosition[1]}
/>
</div>
);

View file

@ -41,7 +41,7 @@ export default function CafeMapPage() {
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"
>
<Icon name="arrowRight" className="h-5 w-5 text-gray-700" />
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
</button>
<MapContainer

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

@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import heic2any from "heic2any";
import { createPortal } from "react-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import "../utils/leafletIcons";
import Icon from "../components/Icon";
import SupportButton from "../components/SupportButton";
import {
CafeFeature,
CafeMediaItem,
@ -34,12 +36,16 @@ function FormField({
hint,
value,
onChange,
inputDir = "ltr",
error,
}: {
label: string;
placeholder: string;
hint?: string;
value: string;
onChange: (value: string) => void;
inputDir?: "ltr" | "rtl";
error?: string;
}) {
return (
<div className="mt-5">
@ -48,14 +54,18 @@ function FormField({
</label>
<input
type="text"
dir={inputDir}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]"
className={`w-full rounded-2xl border px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999] ${
error ? "border-red-400" : "border-gray-200"
}`}
/>
{hint && (
<p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
)}
{error
? <p className="mt-1 text-right text-sm text-red-500">{error}</p>
: hint && <p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
}
</div>
);
}
@ -188,6 +198,23 @@ function CafeLocationMap({
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({
src,
className,
@ -232,10 +259,12 @@ function PhotoUpload({
photos,
onAdd,
onRemove,
error,
}: {
photos: UploadedPhoto[];
onAdd: (files: File[]) => void;
onRemove: (id: string) => void;
error?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
@ -330,6 +359,7 @@ function PhotoUpload({
e.target.value = "";
}}
/>
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
</div>
);
}
@ -338,10 +368,12 @@ function DocumentUpload({
doc,
onAdd,
onRemove,
error,
}: {
doc: UploadedDocument | null;
onAdd: (file: File) => void;
onRemove: () => void;
error?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
@ -421,6 +453,7 @@ function DocumentUpload({
e.target.value = "";
}}
/>
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
</div>
);
}
@ -473,7 +506,16 @@ export default function CafeOwnerRegister() {
const [photos, setPhotos] = useState<UploadedPhoto[]>([]);
const [licenseDoc, setLicenseDoc] = useState<UploadedDocument | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitError, setSubmitError] = useState("");
const clearFieldError = (field: string) =>
setFieldErrors((prev) => {
if (!prev[field]) return prev;
const next = { ...prev };
delete next[field];
return next;
});
// keep refs so the unmount cleanup can revoke any remaining object URLs
const photosRef = useRef(photos);
@ -564,12 +606,14 @@ export default function CafeOwnerRegister() {
};
const handleAddPhotos = async (files: File[]) => {
const newPhotos: UploadedPhoto[] = files.map((file) => ({
id: crypto.randomUUID(),
file,
localUrl: URL.createObjectURL(file),
status: "uploading",
}));
const newPhotos: UploadedPhoto[] = await Promise.all(
files.map(async (file) => ({
id: crypto.randomUUID(),
file,
localUrl: await createPreviewUrl(file),
status: "uploading" as const,
})),
);
setPhotos((prev) => [...prev, ...newPhotos]);
await Promise.all(
@ -609,7 +653,7 @@ export default function CafeOwnerRegister() {
const handleAddLicense = async (file: File) => {
const newDoc: UploadedDocument = {
file,
localUrl: URL.createObjectURL(file),
localUrl: await createPreviewUrl(file),
status: "uploading",
};
setLicenseDoc(newDoc);
@ -639,24 +683,33 @@ export default function CafeOwnerRegister() {
};
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 =
photos.some((p) => p.status === "uploading") ||
licenseDoc?.status === "uploading";
if (stillUploading) {
setError("لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود.");
errors.photos = "لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود";
} else {
if (!photos.some((p) => p.status === "done"))
errors.photos = "حداقل یک تصویر از کافه الزامی است";
if (!licenseDoc || licenseDoc.status !== "done")
errors.licenseDoc = "تصویر مجوز کسب و کار الزامی است";
}
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
if (!isEditMode) {
if (!photos.some((p) => p.status === "done")) {
setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید.");
return;
}
if (!licenseDoc || licenseDoc.status !== "done") {
setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید.");
return;
}
}
setError("");
setFieldErrors({});
setSubmitError("");
setIsSubmitting(true);
try {
const mediaItems: CafeMediaItem[] = [
@ -696,7 +749,7 @@ export default function CafeOwnerRegister() {
if (!isEditMode) {
navigate("/owner/register/fail");
} else {
setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
setSubmitError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
}
} finally {
setIsSubmitting(false);
@ -712,7 +765,8 @@ export default function CafeOwnerRegister() {
label="نام کافه"
placeholder="نام کافه را وارد کنید"
value={cafeName}
onChange={setCafeName}
onChange={(v) => { setCafeName(v); clearFieldError("cafeName"); }}
error={fieldErrors.cafeName}
/>
<CitySelectField
value={effectiveCityUuid}
@ -723,7 +777,8 @@ export default function CafeOwnerRegister() {
label="آدرس"
placeholder="موقعیت کافه را با جزئیات وارد کنید"
value={address}
onChange={setAddress}
onChange={(v) => { setAddress(v); clearFieldError("address"); }}
error={fieldErrors.address}
/>
<CafeLocationMap
label="موقعیت روی نقشه"
@ -732,20 +787,24 @@ export default function CafeOwnerRegister() {
/>
<FormField
label="شماره تماس کافه"
placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
placeholder={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`}
hint="شماره را همراه با پیش‌شماره شهر وارد کنید."
value={cafePhone}
onChange={setCafePhone}
onChange={(v) => { setCafePhone(v); clearFieldError("cafePhone"); }}
inputDir="ltr"
error={fieldErrors.cafePhone}
/>
<PhotoUpload
photos={photos}
onAdd={handleAddPhotos}
onAdd={(files) => { clearFieldError("photos"); handleAddPhotos(files); }}
onRemove={handleRemovePhoto}
error={fieldErrors.photos}
/>
<DocumentUpload
doc={licenseDoc}
onAdd={handleAddLicense}
onAdd={(file) => { clearFieldError("licenseDoc"); handleAddLicense(file); }}
onRemove={handleRemoveLicense}
error={fieldErrors.licenseDoc}
/>
<div className="mt-5">
@ -758,24 +817,22 @@ export default function CafeOwnerRegister() {
key={feature.uuid}
label={feature.name}
checked={selectedFeatures.includes(feature.uuid)}
onToggle={() => toggleFeature(feature.uuid)}
onToggle={() => { toggleFeature(feature.uuid); clearFieldError("features"); }}
/>
))}
</div>
{fieldErrors.features && (
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.features}</p>
)}
</div>
{error && (
<p className="mt-4 text-right text-sm text-red-500">{error}</p>
{submitError && (
<p className="mt-4 text-right text-sm text-red-500">{submitError}</p>
)}
</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,20 +117,28 @@ 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 = () => {
const selectedReservations =
selectedIds.length > 0
? reservations.filter((r) => selectedIds.includes(r.uuid))
: reservations;
downloadReservationsCsv(selectedReservations);
downloadReservationsCsv(reservations);
};
return (
@ -132,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>
@ -157,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,10 +1,14 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import SupportButton from '../components/SupportButton'
import LogoutModal from '../components/LogoutModal'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
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'
@ -77,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>
@ -96,15 +100,26 @@ function CitySelectorRow({
onChange: (uuid: string) => void
}) {
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 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 (
<div className="relative px-4 mt-4">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<div className="mt-4 px-4">
<p className="mb-1 text-sm text-gray-500">{label}</p>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
onClick={handleOpen}
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
>
<span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
<Icon
@ -113,26 +128,28 @@ function CitySelectorRow({
/>
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
{options.map((city) => (
<button
key={city.uuid}
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"
>
{city.city_name}
</button>
))}
</div>
</>
)}
{open &&
createPortal(
<>
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
<div
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
>
{options.map((city) => (
<button
key={city.uuid}
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"
>
{city.city_name}
</button>
))}
</div>
</>,
document.body,
)}
</div>
)
}
@ -151,17 +168,28 @@ function MatchSelectorRow({
onChange: (uuid: string) => void
}) {
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 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 (
<div className="relative px-4 mt-4">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<div className="mt-4 px-4">
<p className="mb-1 text-sm text-gray-500">{label}</p>
<button
ref={buttonRef}
type="button"
disabled={!hasMatches}
onClick={() => setOpen((o) => !o)}
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"
onClick={handleOpen}
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
>
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
@ -172,26 +200,28 @@ function MatchSelectorRow({
/>
</button>
{open && hasMatches && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
{options.map((match) => (
<button
key={match.uuid}
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"
>
{formatMatchLabel(match, now)}
</button>
))}
</div>
</>
)}
{open && hasMatches &&
createPortal(
<>
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
<div
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
>
{options.map((match) => (
<button
key={match.uuid}
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"
>
{formatMatchLabel(match, now)}
</button>
))}
</div>
</>,
document.body,
)}
</div>
)
}
@ -217,6 +247,7 @@ function MyReservationsRow() {
function CafeCard({ cafe }: { cafe: Cafe }) {
const navigate = useNavigate()
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
const firstImage = cafe.media.find((m) => m.type === 'image')
return (
<button
@ -224,7 +255,11 @@ function CafeCard({ cafe }: { cafe: 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"
>
<div className="h-32 w-36 shrink-0 rounded-xl bg-gray-200" />
<div className="h-32 w-36 shrink-0 overflow-hidden rounded-xl bg-gray-200">
{firstImage && (
<img src={firstImage.url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="font-semibold text-gray-900">{cafe.name}</h3>
{remainingTickets !== undefined && (
@ -238,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[]>([])
@ -268,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 />
@ -319,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

@ -2,14 +2,29 @@ import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Icon from "../components/Icon";
import { requestOtp } from "../services/authApi";
import { toPersianDigits } from "../utils/formatNumber";
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;
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() {
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(() => {
@ -21,77 +36,113 @@ export default function Login() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const canContinue = phone.trim().length > 0 && acceptedTerms && !isSubmitting;
const canContinue = phone.length === 11 && acceptedTerms && !isSubmitting;
const handleContinue = async () => {
setError("");
if (phone.length !== 11) {
setError("شماره تلفن باید ۱۱ رقم باشد.");
return;
}
setIsSubmitting(true);
try {
const { ttl } = await requestOtp(toE164(phone));
navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
} catch {
setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
navigate("/login/otp", {
state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" },
});
} catch (err) {
setError(extractApiError(err));
} finally {
setIsSubmitting(false);
}
};
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) => {
setPhone(toPersianDigits(e.target.value));
setError("");
}}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]"
<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,46 +1,74 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Icon from '../components/Icon'
import PaymentFailModal from '../components/PaymentFailModal'
import { createReservation, ReservationResult } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import { fromE164 } from '../utils/phone'
import { takePendingReservation } from '../utils/pendingReservation'
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";
// 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-center justify-between py-2">
<span className="text-gray-500">{label}</span>
<span className="font-medium text-gray-900">{value}</span>
<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 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<ReservationResult | null>(null)
const [hasError, setHasError] = useState(false)
// gateway-reported failure, or a successful payment whose reservation couldn't be created —
// both land on the same fail modal
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);
useEffect(() => {
if (!isSuccess) return
const pending = takePendingReservation()
if (!isSuccess || reservationAttempted.current) return;
reservationAttempted.current = true;
const pending = takePendingReservation();
if (!pending) {
Promise.resolve().then(() => setHasError(true))
return
setHasError(true);
return;
}
createReservation(pending)
.then(setReservation)
.catch(() => setHasError(true))
}, [isSuccess])
.then((result) =>
fetchMyReservationByUuid(result.uuid)
.then(setReservation)
.catch(() => {
// full detail fetch failed — synthesise what we have from the create response
setReservation({
uuid: result.uuid,
reservation_code: result.reserve_code,
ticket_count: result.people_count,
full_name: result.full_name,
phone_number: result.phone_number,
cafe_name: "",
event_title: "",
event_datetime: "",
status: result.status,
});
}),
)
.catch(() => setHasError(true));
}, [isSuccess]);
return (
<div className="min-h-screen bg-gray-50">
@ -51,28 +79,57 @@ export default function PaymentSuccess() {
{reservation ? (
<>
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
<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={reservation.full_name} />
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
<DetailRow label="کد رزرو" value={reservation.reserve_code} />
<DetailRow
label="شماره تماس"
value={fromE164(reservation.phone_number)}
/>
{reservation.cafe_name && (
<DetailRow label="کافه" value={reservation.cafe_name} />
)}
{reservation.event_title && (
<DetailRow label="بازی" value={reservation.event_title} />
)}
{reservation.event_datetime && (
<DetailRow
label="تاریخ و ساعت"
value={formatMatchDatetime(reservation.event_datetime)}
/>
)}
<DetailRow
label="کد رزرو"
value={reservation.reservation_code}
/>
</div>
<div 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"
>
صفحه اصلی
@ -81,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

View file

@ -66,3 +66,16 @@ export function formatIranKickoffTime(matchDatetimeIso: string): string {
})
return toPersianDigits(time)
}
// e.g. "پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰"
export function formatMatchDatetime(datetimeIso: string): string {
const date = new Date(datetimeIso)
const weekday = new Intl.DateTimeFormat('fa-IR', {
timeZone: IRAN_TIME_ZONE,
weekday: 'long',
}).format(date)
const { month, day } = getIranJalaliParts(date)
const monthName = PERSIAN_MONTHS[month - 1] ?? ''
const time = formatIranKickoffTime(datetimeIso)
return `${weekday} ${toPersianDigits(String(day))} ${monthName} - ساعت ${time}`
}

12
src/utils/logout.ts Normal file
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)
}