feat: customer reservation flow

This commit is contained in:
Afra ‌ 2026-06-20 12:17:44 +03:30
parent 104053f802
commit 75f40df7e0
14 changed files with 796 additions and 29 deletions

View file

@ -13,12 +13,16 @@
"dependencies": {
"@tanstack/react-query": "^5.9.0",
"axios": "^1.6.6",
"leaflet": "^1.9.4",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.5.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

98
pnpm-lock.yaml generated
View file

@ -14,12 +14,21 @@ importers:
axios:
specifier: ^1.6.6
version: 1.17.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
react:
specifier: ^19.2.6
version: 19.2.7
react-dom:
specifier: ^19.2.6
version: 19.2.7(react@19.2.7)
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-router-dom:
specifier: ^7.17.0
version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
devDependencies:
'@eslint/js':
specifier: ^10.0.1
@ -27,6 +36,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.1
version: 4.3.1(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
'@types/node':
specifier: ^20.5.1
version: 20.19.43
@ -232,6 +244,13 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@react-leaflet/core@3.0.0':
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -441,9 +460,15 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@ -589,6 +614,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -862,6 +891,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@ -1032,6 +1064,30 @@ packages:
peerDependencies:
react: ^19.2.7
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
react-router-dom@7.17.0:
resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.17.0:
resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@ -1053,6 +1109,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -1377,6 +1436,12 @@ snapshots:
'@oxc-project/types@0.133.0': {}
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
leaflet: 1.9.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@ -1512,8 +1577,14 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@ -1691,6 +1762,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -1950,6 +2023,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
leaflet@1.9.4: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@ -2080,6 +2155,27 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
leaflet: 1.9.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-router-dom@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-router: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
cookie: 1.1.1
react: 19.2.7
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
react@19.2.7: {}
rolldown@1.0.3:
@ -2109,6 +2205,8 @@ snapshots:
semver@7.8.4: {}
set-cookie-parser@2.7.2: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

View file

@ -1,6 +1,26 @@
import React from 'react'
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import Home from './pages/Home'
import CafeDetail from './pages/CafeDetail'
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo(0, 0)
}, [pathname])
return null
}
export default function App() {
return <Home />
return (
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/cafe/:id" element={<CafeDetail />} />
</Routes>
</BrowserRouter>
)
}

3
src/assets/call.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.97 18.33C21.97 18.69 21.89 19.06 21.72 19.42C21.55 19.78 21.33 20.12 21.04 20.44C20.55 20.98 20.01 21.37 19.4 21.62C18.8 21.87 18.15 22 17.45 22C16.43 22 15.34 21.76 14.19 21.27C13.04 20.78 11.89 20.12 10.75 19.29C9.6 18.45 8.51 17.52 7.47 16.49C6.44 15.45 5.51 14.36 4.68 13.22C3.86 12.08 3.2 10.94 2.72 9.81C2.24 8.67 2 7.58 2 6.54C2 5.86 2.12 5.21 2.36 4.61C2.6 4 2.98 3.44 3.51 2.94C4.15 2.31 4.85 2 5.59 2C5.87 2 6.15 2.06 6.4 2.18C6.66 2.3 6.89 2.48 7.07 2.74L9.39 6.01C9.57 6.26 9.7 6.49 9.79 6.71C9.88 6.92 9.93 7.13 9.93 7.32C9.93 7.56 9.86 7.8 9.72 8.03C9.59 8.26 9.4 8.5 9.16 8.74L8.4 9.53C8.29 9.64 8.24 9.77 8.24 9.93C8.24 10.01 8.25 10.08 8.27 10.16C8.3 10.24 8.33 10.3 8.35 10.36C8.53 10.69 8.84 11.12 9.28 11.64C9.73 12.16 10.21 12.69 10.73 13.22C11.27 13.75 11.79 14.24 12.32 14.69C12.84 15.13 13.27 15.43 13.61 15.61C13.66 15.63 13.72 15.66 13.79 15.69C13.87 15.72 13.95 15.73 14.04 15.73C14.21 15.73 14.34 15.67 14.45 15.56L15.21 14.81C15.46 14.56 15.7 14.37 15.93 14.25C16.16 14.11 16.39 14.04 16.64 14.04C16.83 14.04 17.03 14.08 17.25 14.17C17.47 14.26 17.7 14.39 17.95 14.56L21.26 16.91C21.52 17.09 21.7 17.3 21.81 17.55C21.91 17.8 21.97 18.05 21.97 18.33Z" stroke="#4D4D4D" stroke-width="1.5" stroke-miterlimit="10"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

3
src/assets/info.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.08333 12.4167V9.08333M9.08333 5.75H9.09167M17.4167 9.08333C17.4167 13.6857 13.6857 17.4167 9.08333 17.4167C4.48096 17.4167 0.75 13.6857 0.75 9.08333C0.75 4.48096 4.48096 0.75 9.08333 0.75C13.6857 0.75 17.4167 4.48096 17.4167 9.08333Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

4
src/assets/location.svg Normal file
View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.61971 8.49C5.58971 -0.169998 18.4197 -0.159997 20.3797 8.5C21.5297 13.58 18.3697 17.88 15.5997 20.54C13.5897 22.48 10.4097 22.48 8.38971 20.54C5.62971 17.88 2.46971 13.57 3.61971 8.49Z" stroke="#4D4D4D" stroke-width="1.5"/>
<path d="M11.9989 13.4295C13.722 13.4295 15.1189 12.0326 15.1189 10.3095C15.1189 8.58633 13.722 7.18945 11.9989 7.18945C10.2758 7.18945 8.87891 8.58633 8.87891 10.3095C8.87891 12.0326 10.2758 13.4295 11.9989 13.4295Z" stroke="#4D4D4D" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 595 B

1
src/assets/minus.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#223D5D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>

After

Width:  |  Height:  |  Size: 248 B

3
src/assets/plus.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.75 2C7.94891 2 8.13968 2.07902 8.28033 2.21967C8.42098 2.36032 8.5 2.55109 8.5 2.75V7H12.75C12.9489 7 13.1397 7.07902 13.2803 7.21967C13.421 7.36032 13.5 7.55109 13.5 7.75C13.5 7.94891 13.421 8.13968 13.2803 8.28033C13.1397 8.42098 12.9489 8.5 12.75 8.5H8.5V12.75C8.5 12.9489 8.42098 13.1397 8.28033 13.2803C8.13968 13.421 7.94891 13.5 7.75 13.5C7.55109 13.5 7.36032 13.421 7.21967 13.2803C7.07902 13.1397 7 12.9489 7 12.75V8.5H2.75C2.55109 8.5 2.36032 8.42098 2.21967 8.28033C2.07902 8.13968 2 7.94891 2 7.75C2 7.55109 2.07902 7.36032 2.21967 7.21967C2.36032 7.07902 2.55109 7 2.75 7H7V2.75C7 2.55109 7.07902 2.36032 7.21967 2.21967C7.36032 2.07902 7.55109 2 7.75 2Z" fill="#223D5D"/>
</svg>

After

Width:  |  Height:  |  Size: 801 B

View file

@ -0,0 +1,15 @@
export default function InfoIcon({ className = 'h-5 w-5 shrink-0 text-gray-400' }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 19 19"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9.08333 12.4167V9.08333M9.08333 5.75H9.09167M17.4167 9.08333C17.4167 13.6857 13.6857 17.4167 9.08333 17.4167C4.48096 17.4167 0.75 13.6857 0.75 9.08333C0.75 4.48096 4.48096 0.75 9.08333 0.75C13.6857 0.75 17.4167 4.48096 17.4167 9.08333Z" />
</svg>
)
}

View file

@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Cafe } from "../data/mockCafes";
import { formatPersianNumber } from "../utils/formatNumber";
import InfoIcon from "./InfoIcon";
interface ReservationModalProps {
isOpen: boolean;
onClose: () => void;
cafe: Cafe;
}
const mockPhone = "۰۹۱۲۳۴۵۶۷۸۹";
// mock wallet balance — will come from the user's wallet once auth is implemented
const walletBalance = 10000;
function MinusIcon({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33398 8H12.6673" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function PlusIcon({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.75 2C7.94891 2 8.13968 2.07902 8.28033 2.21967C8.42098 2.36032 8.5 2.55109 8.5 2.75V7H12.75C12.9489 7 13.1397 7.07902 13.2803 7.21967C13.421 7.36032 13.5 7.55109 13.5 7.75C13.5 7.94891 13.421 8.13968 13.2803 8.28033C13.1397 8.42098 12.9489 8.5 12.75 8.5H8.5V12.75C8.5 12.9489 8.42098 13.1397 8.28033 13.2803C8.13968 13.421 7.94891 13.5 7.75 13.5C7.55109 13.5 7.36032 13.421 7.21967 13.2803C7.07902 13.1397 7 12.9489 7 12.75V8.5H2.75C2.55109 8.5 2.36032 8.42098 2.21967 8.28033C2.07902 8.13968 2 7.94891 2 7.75C2 7.55109 2.07902 7.36032 2.21967 7.21967C2.36032 7.07902 2.55109 7 2.75 7H7V2.75C7 2.55109 7.07902 2.36032 7.21967 2.21967C7.36032 2.07902 7.55109 2 7.75 2Z"
fill="currentColor"
/>
</svg>
);
}
function PaymentRow({
label,
amount,
emphasize = false,
bottomBorder = false,
infoIconParam = false,
}: {
label: string;
amount: number;
emphasize?: boolean;
bottomBorder?: boolean;
infoIconParam?: boolean;
}) {
return (
<div
className={`flex items-center justify-between py-3 ${bottomBorder ? "border-b border-gray-100" : ""}`}
>
<div className="flex items-center gap-1">
<span
className={
emphasize ? "font-semibold text-gray-900" : "text-gray-700"
}
>
{label}
</span>
{infoIconParam && (
<InfoIcon className="h-5 w-5 shrink-0 text-[#376195]" />
)}
</div>
<span
className={emphasize ? "font-semibold text-gray-900" : "text-gray-900"}
>
{formatPersianNumber(amount)} تومان
</span>
</div>
);
}
export default function ReservationModal({
isOpen,
onClose,
cafe,
}: ReservationModalProps) {
const [fullName, setFullName] = useState("");
const [count, setCount] = useState(1);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) return null;
const reservationAmount = cafe.entryFeeAmount * count;
const payableAmount = Math.max(0, reservationAmount - walletBalance);
return createPortal(
<>
<div className="fixed inset-0 z-1100 bg-black/70" onClick={onClose} />
<div className="fixed inset-x-0 bottom-0 z-1100 flex max-h-[92vh] flex-col rounded-t-3xl bg-white">
<div className="mx-auto mt-3 mb-4 h-1 w-10 shrink-0 rounded-full bg-gray-200" />
<div className="overflow-y-auto px-5 pb-6">
<h2 className="text-right text-lg font-bold text-gray-900">
{cafe.name}
</h2>
<p className="mt-3 mb-5 text-right text-sm text-gray-500">
{cafe.match}
</p>
<div>
<label className="mb-2 block text-right font-semibold text-gray-900">
نام و نام خانوادگی
</label>
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="نام و نام خانوادگی"
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right"
/>
</div>
<div className="mt-4">
<label className="mb-2 block text-right font-semibold text-gray-900">
شماره تماس
</label>
{/* readOnly mock for now — once OTP auth ships, this comes from the auth context */}
<input
type="text"
value={mockPhone}
readOnly
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
className="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-right"
/>
</div>
<div className="mt-4">
<label className="mb-2 block text-right font-semibold text-gray-900">
تعداد
</label>
<div
dir="ltr"
className="flex w-full items-center justify-between rounded-2xl border border-gray-200 px-4 py-3"
>
<button
type="button"
onClick={() => setCount((prev) => Math.max(1, prev - 1))}
disabled={count === 1}
className={`flex h-6 w-6 items-center justify-center ${
count === 1
? "cursor-not-allowed text-[#999999]"
: "text-[#223D5D]"
}`}
>
<MinusIcon className="h-4 w-4" />
</button>
<span dir="rtl" className="text-center font-medium text-gray-900">
{formatPersianNumber(count)} نفر
</span>
<button
type="button"
onClick={() => setCount((prev) => prev + 1)}
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
پرداخت
</h3>
<PaymentRow
infoIconParam
label="مبلغ رزرو"
amount={reservationAmount}
/>
<PaymentRow
bottomBorder
label="موجودی کیف پول"
amount={walletBalance}
/>
<PaymentRow
label="مبلغ قابل پرداخت"
amount={payableAmount}
emphasize
/>
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
<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">
<button
type="button"
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
پرداخت
</button>
</div>
</div>
</>,
document.body,
);
}

55
src/data/mockCafes.ts Normal file
View file

@ -0,0 +1,55 @@
export interface Cafe {
id: number
name: string
match: string
entryFee: string
entryFeeAmount: number
remainingCapacity: number
tags: string[]
location: { lat: number; lng: number }
phone: string
address: string
images: string[] // empty array for now, will show gray placeholder
}
export const mockCafes: Cafe[] = [
{
id: 1,
name: 'کافه بی‌بای',
match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
entryFee: '۲,۰۰۰,۰۰۰ تومان',
entryFeeAmount: 2000000,
remainingCapacity: 3,
tags: ['پارکینگ دارد', 'فضای سیگار', 'امکان سفارش'],
location: { lat: 35.7219, lng: 51.3347 },
phone: '۰۲۱۹۱۰۲۰۲۱۲۰۲',
address: 'ونک، خدامی، نبش نیروی انتظامی، پلاک ۲',
images: [],
},
{
id: 2,
name: 'کافه بی‌بای شعبه ۲',
match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
entryFee: '۱,۵۰۰,۰۰۰ تومان',
entryFeeAmount: 1500000,
remainingCapacity: 10,
tags: ['فضای سیگار', 'امکان سفارش'],
location: { lat: 35.755, lng: 51.4165 },
phone: '۰۲۱۸۸۴۴۵۵۶۶',
address: 'جردن، خیابان آفریقا، پلاک ۱۲',
images: [],
},
{
id: 3,
name: 'کافه ستاره',
match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
entryFee: 'رایگان',
entryFeeAmount: 0,
remainingCapacity: 15,
tags: ['پارکینگ دارد'],
location: { lat: 35.6892, lng: 51.389 },
phone: '۰۲۱۷۷۳۳۲۲۱۱',
address: 'انقلاب، خیابان ۱۶ آذر، پلاک ۵',
images: [],
},
]

272
src/pages/CafeDetail.tsx Normal file
View file

@ -0,0 +1,272 @@
import { ReactNode, useState } from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";
import { mockCafes, Cafe } from "../data/mockCafes";
import InfoIcon from "../components/InfoIcon";
import ReservationModal from "../components/ReservationModal";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
function ChevronLeftIcon() {
return (
<svg
className="h-5 w-5 text-gray-700"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
</svg>
);
}
function ChevronRightIcon() {
return (
<svg
className="h-5 w-5 text-gray-700"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
);
}
function PhoneIcon() {
return (
<svg
className="h-5 w-5 shrink-0 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeMiterlimit="10"
d="M21.97 18.33C21.97 18.69 21.89 19.06 21.72 19.42C21.55 19.78 21.33 20.12 21.04 20.44C20.55 20.98 20.01 21.37 19.4 21.62C18.8 21.87 18.15 22 17.45 22C16.43 22 15.34 21.76 14.19 21.27C13.04 20.78 11.89 20.12 10.75 19.29C9.6 18.45 8.51 17.52 7.47 16.49C6.44 15.45 5.51 14.36 4.68 13.22C3.86 12.08 3.2 10.94 2.72 9.81C2.24 8.67 2 7.58 2 6.54C2 5.86 2.12 5.21 2.36 4.61C2.6 4 2.98 3.44 3.51 2.94C4.15 2.31 4.85 2 5.59 2C5.87 2 6.15 2.06 6.4 2.18C6.66 2.3 6.89 2.48 7.07 2.74L9.39 6.01C9.57 6.26 9.7 6.49 9.79 6.71C9.88 6.92 9.93 7.13 9.93 7.32C9.93 7.56 9.86 7.8 9.72 8.03C9.59 8.26 9.4 8.5 9.16 8.74L8.4 9.53C8.29 9.64 8.24 9.77 8.24 9.93C8.24 10.01 8.25 10.08 8.27 10.16C8.3 10.24 8.33 10.3 8.35 10.36C8.53 10.69 8.84 11.12 9.28 11.64C9.73 12.16 10.21 12.69 10.73 13.22C11.27 13.75 11.79 14.24 12.32 14.69C12.84 15.13 13.27 15.43 13.61 15.61C13.66 15.63 13.72 15.66 13.79 15.69C13.87 15.72 13.95 15.73 14.04 15.73C14.21 15.73 14.34 15.67 14.45 15.56L15.21 14.81C15.46 14.56 15.7 14.37 15.93 14.25C16.16 14.11 16.39 14.04 16.64 14.04C16.83 14.04 17.03 14.08 17.25 14.17C17.47 14.26 17.7 14.39 17.95 14.56L21.26 16.91C21.52 17.09 21.7 17.3 21.81 17.55C21.91 17.8 21.97 18.05 21.97 18.33Z"
/>
</svg>
);
}
function MapPinIcon() {
return (
<svg
className="h-5 w-5 shrink-0 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path d="M3.61971 8.49C5.58971 -0.169998 18.4197 -0.159997 20.3797 8.5C21.5297 13.58 18.3697 17.88 15.5997 20.54C13.5897 22.48 10.4097 22.48 8.38971 20.54C5.62971 17.88 2.46971 13.57 3.61971 8.49Z" />
<path d="M11.9989 13.4295C13.722 13.4295 15.1189 12.0326 15.1189 10.3095C15.1189 8.58633 13.722 7.18945 11.9989 7.18945C10.2758 7.18945 8.87891 8.58633 8.87891 10.3095C8.87891 12.0326 10.2758 13.4295 11.9989 13.4295Z" />
</svg>
);
}
function Pill({
children,
className = "bg-gray-100 text-gray-700",
}: {
children: ReactNode;
className?: string;
}) {
return (
<span
className={`whitespace-nowrap rounded-full px-3 py-1 text-sm ${className}`}
>
{children}
</span>
);
}
function ImageCarousel({ images }: { images: string[] }) {
const [index, setIndex] = useState(0);
const slideCount = Math.max(images.length, 1);
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"
>
<ChevronLeftIcon />
</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"
>
<ChevronRightIcon />
</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"}`}
/>
))}
</div>
</div>
);
}
function toEnglishDigits(value: string) {
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)));
}
async function openNavigationPicker(cafe: Cafe) {
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${cafe.location.lat},${cafe.location.lng}`;
if (navigator.share) {
try {
await navigator.share({ title: cafe.name, url: mapsUrl });
} catch {
// user dismissed the native share sheet
}
} else {
window.open(mapsUrl, "_blank");
}
}
export default function CafeDetail() {
const { id } = useParams();
const location = useLocation();
const navigate = useNavigate();
const [isReservationOpen, setIsReservationOpen] = useState(false);
const cafe: Cafe | undefined =
(location.state as { cafe?: Cafe } | null)?.cafe ??
mockCafes.find((c) => c.id === Number(id));
if (!cafe) {
return (
<div className="flex min-h-screen items-center justify-center text-gray-500">
کافه پیدا نشد
</div>
);
}
return (
<div className="min-h-screen bg-white">
<div className="relative">
<ImageCarousel images={cafe.images} />
<button
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"
>
<ChevronLeftIcon />
</button>
</div>
<div className="px-4 pt-4 pb-28">
<h1 className="text-right text-xl font-bold text-gray-900">
{cafe.name}
</h1>
<p className="mt-1 text-right text-sm text-gray-500">{cafe.match}</p>
<div className="mt-3 flex flex-wrap gap-2 ">
<Pill className="bg-[#ECF2F8] text-gray-700">ورودی: نفری {cafe.entryFee}</Pill>
<Pill className="bg-[#ECF2F8] text-gray-700">ظرفیت باقی مانده: {cafe.remainingCapacity}نفر</Pill>
{/* </div> */}
{/* <div className="mt-2 flex flex-wrap gap-2"> */}
{cafe.tags.map((tag) => (
<Pill key={tag} className="bg-[#ECF2F8] text-gray-700">
{tag}
</Pill>
))}
</div>
<h2 className="mt-4 mb-2 text-right font-semibold text-gray-900">
آدرس
</h2>
<MapContainer
center={[cafe.location.lat, cafe.location.lng]}
zoom={15}
scrollWheelZoom={false}
zoomControl={false}
className="h-48 w-full overflow-hidden rounded-2xl"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[cafe.location.lat, cafe.location.lng]} />
<ZoomControl position="topright" />
</MapContainer>
<div className="mt-4 border border-gray-200 rounded-2xl p-4">
<div className="flex items-center justify-start gap-4 py-3">
<PhoneIcon />
<div className="flex flex-col items-start gap-1">
<p>شماره تماس</p>
<a href={`tel:${toEnglishDigits(cafe.phone)}`} className="text-gray-900">
{cafe.phone}
</a>
</div>
</div>
<div className="flex items-center justify-start gap-4 py-3">
<MapPinIcon />
<div className="flex flex-col items-start gap-1">
<p>آدرس</p>
<span className="text-gray-900">{cafe.address}</span>
</div>
</div>
<button
type="button"
onClick={() => openNavigationPicker(cafe)}
className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900"
>
مسیریابی
</button>
</div>
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
<p className="text-right text-sm text-gray-600">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده
کاربر است.
</p>
</div>
</div>
<div className="fixed inset-x-0 bottom-0 border-t border-gray-100 bg-white px-4 py-4">
<button
type="button"
onClick={() => setIsReservationOpen(true)}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
رزرو
</button>
</div>
<ReservationModal
isOpen={isReservationOpen}
onClose={() => setIsReservationOpen(false)}
cafe={cafe}
/>
</div>
);
}

View file

@ -1,70 +1,143 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { mockCafes, Cafe } from '../data/mockCafes'
type Cafe = {
id: number
name: string
seats: number
image: string | null
}
const mockCafes: Cafe[] = [
{ id: 1, name: 'کافه بی‌بای', seats: 8, image: null },
{ id: 2, name: 'کافه بی‌بای', seats: 10, image: null },
{ id: 3, name: 'کافه ستاره', seats: 15, image: null },
const cityOptions = ['تهران', 'اصفهان', 'مشهد']
const matchOptions = [
'برزیل-مراکش (۳ روز دیگر-۲:۰۰)',
'آرژانتین-فرانسه (۲ روز دیگر-۱۸:۰۰)',
'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
]
function ChevronDownIcon() {
function ChevronDownIcon({ className = '' }: { className?: string }) {
return (
<svg className="w-5 h-5 text-gray-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${className}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
)
}
function MapIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.52734 5.186V11.6727C1.52734 12.9393 2.42734 13.4593 3.52068 12.8327L5.08734 11.9393C5.42734 11.746 5.99401 11.726 6.34734 11.906L9.84734 13.6593C10.2007 13.8327 10.7673 13.8193 11.1073 13.626L13.994 11.9727C14.3607 11.7593 14.6673 11.2393 14.6673 10.8127V4.326C14.6673 3.05933 13.7673 2.53933 12.674 3.166L11.1073 4.05933C10.7673 4.25267 10.2007 4.27267 9.84734 4.09267L6.34734 2.346C5.99401 2.17267 5.42734 2.186 5.08734 2.37933L2.20068 4.03267C1.82734 4.246 1.52734 4.766 1.52734 5.186Z"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function ListIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 3.66602H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function MapPlaceholder() {
return (
<div className="w-full h-[35vh] bg-gray-200 flex items-center justify-center text-gray-500">
نقشه
بنر
</div>
)
}
function SelectorRow({ label, value }: { label: string; value: string }) {
function SelectorRow({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: string[]
onChange: (value: string) => void
}) {
const [open, setOpen] = useState(false)
return (
<div className="px-4 mt-4">
<div className="relative px-4 mt-4">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<button
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"
>
<span className="text-gray-900">{value}</span>
<ChevronDownIcon />
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-4 top-full z-20 mt-2 overflow-hidden rounded-xl bg-white shadow-lg">
{options.map((option) => (
<button
key={option}
type="button"
onClick={() => {
onChange(option)
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"
>
{option}
</button>
))}
</div>
</>
)}
</div>
)
}
function CafeCard({ cafe }: { cafe: Cafe }) {
const navigate = useNavigate()
return (
<div className="flex items-center justify-between gap-3 rounded-2xl bg-white p-3 shadow-sm">
<button
type="button"
onClick={() => navigate(`/cafe/${cafe.id}`, { 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="flex flex-col items-start gap-2">
<h3 className="font-semibold text-gray-900">{cafe.name}</h3>
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">{cafe.seats} صندلی</span>
</div>
<div className="h-24 w-28 shrink-0 rounded-xl bg-gray-200" />
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">{cafe.remainingCapacity} صندلی</span>
</div>
</button>
)
}
export default function Home() {
const [view, setView] = useState<'list' | 'map'>('list')
const [city, setCity] = useState(cityOptions[0])
const [match, setMatch] = useState(matchOptions[0])
return (
<div className="min-h-screen bg-white pb-24">
<MapPlaceholder />
<SelectorRow label="شهر" value="تهران" />
<SelectorRow label="بازی انتخابی" value="برزیل-مراکش (۳ روز دیگر-۲:۰۰)" />
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
<SelectorRow label="بازی انتخابی" value={match} options={matchOptions} onChange={setMatch} />
<div className="mt-6 flex items-center justify-between px-4">
<h2 className="font-semibold text-gray-900">کافه‌ها</h2>
@ -73,18 +146,20 @@ export default function Home() {
type="button"
onClick={() => setView('map')}
className={`flex items-center gap-1 rounded-full px-3 py-1.5 text-sm ${
view === 'map' ? 'bg-[#1e2d4a] text-white' : 'text-gray-600'
view === 'map' ? 'bg-[#223D5D] text-white' : 'text-gray-600'
}`}
>
<MapIcon />
نقشه
</button>
<button
type="button"
onClick={() => setView('list')}
className={`flex items-center gap-1 rounded-full px-3 py-1.5 text-sm ${
view === 'list' ? 'bg-[#1e2d4a] text-white' : 'text-gray-600'
view === 'list' ? 'bg-[#223D5D] text-white' : 'text-gray-600'
}`}
>
<ListIcon />
لیست
</button>
</div>
@ -104,7 +179,7 @@ export default function Home() {
<button
type="button"
className="fixed bottom-4 right-4 rounded-full bg-[#1e2d4a] px-5 py-3 text-white shadow-lg"
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
>
پشتیبانی
</button>

View file

@ -0,0 +1,3 @@
export function formatPersianNumber(n: number): string {
return n.toLocaleString('fa-IR')
}