feat: customer reservation flow
This commit is contained in:
parent
104053f802
commit
75f40df7e0
14 changed files with 796 additions and 29 deletions
|
|
@ -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
98
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
24
src/App.tsx
24
src/App.tsx
|
|
@ -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
3
src/assets/call.svg
Normal 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
3
src/assets/info.svg
Normal 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
4
src/assets/location.svg
Normal 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
1
src/assets/minus.svg
Normal 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
3
src/assets/plus.svg
Normal 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 |
15
src/components/InfoIcon.tsx
Normal file
15
src/components/InfoIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
src/components/ReservationModal.tsx
Normal file
211
src/components/ReservationModal.tsx
Normal 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
55
src/data/mockCafes.ts
Normal 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
272
src/pages/CafeDetail.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">{cafe.remainingCapacity} صندلی</span>
|
||||
</div>
|
||||
<div className="h-24 w-28 shrink-0 rounded-xl bg-gray-200" />
|
||||
</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>
|
||||
|
|
|
|||
3
src/utils/formatNumber.ts
Normal file
3
src/utils/formatNumber.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function formatPersianNumber(n: number): string {
|
||||
return n.toLocaleString('fa-IR')
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue