feature/WNPT-176 #1
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
|
||||
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
|
||||
VITE_AUTH_CLIENT_ID=
|
||||
VITE_AUTH_CLIENT_SECRET=
|
||||
VITE_APP_BASE_URL=
|
||||
2
.env.staging
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/
|
||||
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
|
||||
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ dist
|
|||
.pnpm-store
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import js from '@eslint/js'
|
|||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
|
|
|||
15
package.json
|
|
@ -6,30 +6,37 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:staging": "vite build --mode staging",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.9.0",
|
||||
"axios": "^1.6.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"@tanstack/react-query": "^5.9.0"
|
||||
"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",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.26",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"postcss": "^8.4.26",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
531
pnpm-lock.yaml
generated
|
|
@ -14,16 +14,31 @@ 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
|
||||
version: 10.0.1(eslint@10.5.0)
|
||||
version: 10.0.1(eslint@10.5.0(jiti@2.7.0))
|
||||
'@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
|
||||
|
|
@ -35,19 +50,19 @@ importers:
|
|||
version: 19.2.3(@types/react@19.2.17)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.2(vite@8.0.16(@types/node@20.19.43))
|
||||
version: 6.0.2(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.14
|
||||
version: 10.5.0(postcss@8.5.15)
|
||||
eslint:
|
||||
specifier: ^10.3.0
|
||||
version: 10.5.0
|
||||
version: 10.5.0(jiti@2.7.0)
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.1(eslint@10.5.0)
|
||||
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2(eslint@10.5.0)
|
||||
version: 0.5.2(eslint@10.5.0(jiti@2.7.0))
|
||||
globals:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
|
|
@ -60,9 +75,12 @@ importers:
|
|||
typescript:
|
||||
specifier: ^5.6.2
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.61.1
|
||||
version: 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^8.0.12
|
||||
version: 8.0.16(@types/node@20.19.43)
|
||||
version: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -226,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}
|
||||
|
|
@ -324,6 +349,100 @@ packages:
|
|||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.3.1':
|
||||
resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.3.1':
|
||||
resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.3.1':
|
||||
resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.3.1':
|
||||
resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1':
|
||||
resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.3.1':
|
||||
resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.1':
|
||||
resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.1':
|
||||
resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.1':
|
||||
resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.1':
|
||||
resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.3.1':
|
||||
resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.3.1':
|
||||
resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.3.1':
|
||||
resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/vite@4.3.1':
|
||||
resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@tanstack/query-core@5.101.0':
|
||||
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
|
||||
|
||||
|
|
@ -341,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==}
|
||||
|
||||
|
|
@ -355,6 +480,65 @@ packages:
|
|||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.61.1':
|
||||
resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.61.1
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.61.1':
|
||||
resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.61.1':
|
||||
resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.61.1':
|
||||
resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.61.1':
|
||||
resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.61.1':
|
||||
resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/types@8.61.1':
|
||||
resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.61.1':
|
||||
resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.61.1':
|
||||
resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.61.1':
|
||||
resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-react@6.0.2':
|
||||
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -430,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'}
|
||||
|
|
@ -464,6 +652,10 @@ packages:
|
|||
electron-to-chromium@1.5.372:
|
||||
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
|
||||
|
||||
enhanced-resolve@5.21.6:
|
||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -622,6 +814,9 @@ packages:
|
|||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -648,6 +843,10 @@ packages:
|
|||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
ignore@7.0.5:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
|
@ -663,6 +862,10 @@ packages:
|
|||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
|
@ -688,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'}
|
||||
|
|
@ -773,6 +979,9 @@ packages:
|
|||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -855,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'}
|
||||
|
|
@ -871,6 +1104,14 @@ packages:
|
|||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.4:
|
||||
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
|
||||
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'}
|
||||
|
|
@ -886,10 +1127,20 @@ packages:
|
|||
tailwindcss@4.3.1:
|
||||
resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
|
||||
|
||||
tapable@2.3.3:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||
engines: {node: '>=18.12'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
|
|
@ -897,6 +1148,13 @@ packages:
|
|||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
typescript-eslint@8.61.1:
|
||||
resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
|
|
@ -1100,9 +1358,9 @@ snapshots:
|
|||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)':
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))':
|
||||
dependencies:
|
||||
eslint: 10.5.0
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.2': {}
|
||||
|
|
@ -1123,9 +1381,9 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/js@10.0.1(eslint@10.5.0)':
|
||||
'@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0))':
|
||||
optionalDependencies:
|
||||
eslint: 10.5.0
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
|
||||
'@eslint/object-schema@3.0.5': {}
|
||||
|
||||
|
|
@ -1178,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
|
||||
|
||||
|
|
@ -1229,6 +1493,74 @@ snapshots:
|
|||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.21.6
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.3.1
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.3.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.3.1':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.3.1
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.3.1
|
||||
'@tailwindcss/oxide-darwin-x64': 4.3.1
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.3.1
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.1
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.3.1
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.3.1
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.3.1
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.3.1
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.1
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.1
|
||||
|
||||
'@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.1
|
||||
'@tailwindcss/oxide': 4.3.1
|
||||
tailwindcss: 4.3.1
|
||||
vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
|
||||
|
||||
'@tanstack/query-core@5.101.0': {}
|
||||
|
||||
'@tanstack/react-query@5.101.0(react@19.2.7)':
|
||||
|
|
@ -1245,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
|
||||
|
|
@ -1259,10 +1597,101 @@ snapshots:
|
|||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@20.19.43))':
|
||||
'@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.61.1
|
||||
'@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.1
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.61.1
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.1
|
||||
debug: 4.4.3
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.61.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.61.1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
'@typescript-eslint/visitor-keys': 8.61.1
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.61.1': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.61.1(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
'@typescript-eslint/visitor-keys': 8.61.1
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.4
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0))
|
||||
'@typescript-eslint/scope-manager': 8.61.1
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.61.1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/node@20.19.43)
|
||||
vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.17.0):
|
||||
dependencies:
|
||||
|
|
@ -1333,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
|
||||
|
|
@ -1359,6 +1790,11 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.372: {}
|
||||
|
||||
enhanced-resolve@5.21.6:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
|
@ -1378,20 +1814,20 @@ snapshots:
|
|||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-plugin-react-hooks@7.1.1(eslint@10.5.0):
|
||||
eslint-plugin-react-hooks@7.1.1(eslint@10.5.0(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
eslint: 10.5.0
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.4.3
|
||||
zod-validation-error: 4.0.2(zod@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-react-refresh@0.5.2(eslint@10.5.0):
|
||||
eslint-plugin-react-refresh@0.5.2(eslint@10.5.0(jiti@2.7.0)):
|
||||
dependencies:
|
||||
eslint: 10.5.0
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
|
||||
eslint-scope@9.1.2:
|
||||
dependencies:
|
||||
|
|
@ -1404,9 +1840,9 @@ snapshots:
|
|||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@10.5.0:
|
||||
eslint@10.5.0(jiti@2.7.0):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0)
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.23.5
|
||||
'@eslint/config-helpers': 0.6.0
|
||||
|
|
@ -1436,6 +1872,8 @@ snapshots:
|
|||
minimatch: 10.2.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -1528,6 +1966,8 @@ snapshots:
|
|||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
|
|
@ -1553,6 +1993,8 @@ snapshots:
|
|||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
|
@ -1563,6 +2005,8 @@ snapshots:
|
|||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
|
@ -1579,6 +2023,8 @@ snapshots:
|
|||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
|
@ -1641,6 +2087,10 @@ snapshots:
|
|||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
|
@ -1705,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:
|
||||
|
|
@ -1732,6 +2203,10 @@ snapshots:
|
|||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.4: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
|
@ -1742,11 +2217,17 @@ snapshots:
|
|||
|
||||
tailwindcss@4.3.1: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
ts-api-utils@2.5.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
|
|
@ -1754,6 +2235,17 @@ snapshots:
|
|||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
typescript-eslint@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
|
@ -1768,7 +2260,7 @@ snapshots:
|
|||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
vite@8.0.16(@types/node@20.19.43):
|
||||
vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
|
|
@ -1778,6 +2270,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
|
|
|
|||
46
src/App.tsx
|
|
@ -1,6 +1,48 @@
|
|||
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'
|
||||
import PaymentSuccess from './pages/PaymentSuccess'
|
||||
import CafeOwnerRegister from './pages/CafeOwnerRegister'
|
||||
import CafeRegisterSuccess from './pages/CafeRegisterSuccess'
|
||||
import CafeOwnerPanel from './pages/CafeOwnerPanel'
|
||||
import AddEvent from './pages/AddEvent'
|
||||
import AddEventSuccess from './pages/AddEventSuccess'
|
||||
import EventReservations from './pages/EventReservations'
|
||||
import MyReservations from './pages/MyReservations'
|
||||
import Login from './pages/Login'
|
||||
import LoginOtp from './pages/LoginOtp'
|
||||
import RequireRole from './components/RequireRole'
|
||||
|
||||
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={<RequireRole role="customer"><Home /></RequireRole>} />
|
||||
<Route path="/cafe/:id" element={<RequireRole role="customer"><CafeDetail /></RequireRole>} />
|
||||
<Route path="/payment/success" element={<RequireRole role="customer"><PaymentSuccess /></RequireRole>} />
|
||||
<Route path="/reservations" element={<RequireRole role="customer"><MyReservations /></RequireRole>} />
|
||||
<Route path="/owner/register" element={<RequireRole role="cafe_owner"><CafeOwnerRegister /></RequireRole>} />
|
||||
<Route path="/owner/register/success" element={<RequireRole role="cafe_owner"><CafeRegisterSuccess /></RequireRole>} />
|
||||
<Route path="/owner/panel" element={<RequireRole role="cafe_owner"><CafeOwnerPanel /></RequireRole>} />
|
||||
<Route path="/owner/events/new" element={<RequireRole role="cafe_owner"><AddEvent /></RequireRole>} />
|
||||
<Route path="/owner/events/success" element={<RequireRole role="cafe_owner"><AddEventSuccess /></RequireRole>} />
|
||||
<Route path="/owner/events/:id/reservations" element={<RequireRole role="cafe_owner"><EventReservations /></RequireRole>} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/cafe/login" element={<Login />} />
|
||||
<Route path="/login/otp" element={<LoginOtp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
3
src/assets/arrow-right.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="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" stroke="white" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 314 B |
6
src/assets/book.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 16.7397V4.6697C22 3.4697 21.02 2.5797 19.83 2.6797H19.77C17.67 2.8597 14.48 3.9297 12.7 5.0497L12.53 5.1597C12.24 5.3397 11.76 5.3397 11.47 5.1597L11.22 5.0097C9.44 3.8997 6.26 2.8397 4.16 2.6697C2.97 2.5697 2 3.4697 2 4.6597V16.7397C2 17.6997 2.78 18.5997 3.74 18.7197L4.03 18.7597C6.2 19.0497 9.55 20.1497 11.47 21.1997L11.51 21.2197C11.78 21.3697 12.21 21.3697 12.47 21.2197C14.39 20.1597 17.75 19.0497 19.93 18.7597L20.26 18.7197C21.22 18.5997 22 17.6997 22 16.7397Z" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 5.49023V20.4902" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.75 8.49023H5.5" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 11.4902H5.5" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,013 B |
12
src/assets/calendar.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 2V5" stroke="#4D4D4D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 2V5" stroke="#4D4D4D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.5 9.08984H20.5" stroke="#4D4D4D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21 8.5V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" stroke="#4D4D4D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.6937 13.6992H15.7027" stroke="#4D4D4D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.6937 16.6992H15.7027" stroke="#4D4D4D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.9945 13.6992H12.0035" stroke="#4D4D4D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.9945 16.6992H12.0035" stroke="#4D4D4D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.29529 13.6992H8.30427" stroke="#4D4D4D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.29529 16.6992H8.30427" stroke="#4D4D4D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
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 |
5
src/assets/edit-2.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.2594 3.59924L5.04936 12.2892C4.73936 12.6192 4.43936 13.2692 4.37936 13.7192L4.00936 16.9592C3.87936 18.1292 4.71936 18.9292 5.87936 18.7292L9.09936 18.1792C9.54936 18.0992 10.1794 17.7692 10.4894 17.4292L18.6994 8.73924C20.1194 7.23924 20.7594 5.52924 18.5494 3.43924C16.3494 1.36924 14.6794 2.09924 13.2594 3.59924Z" stroke="#223D5D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.8906 5.05078C12.3206 7.81078 14.5606 9.92078 17.3406 10.2008" stroke="#223D5D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 22H21" stroke="#223D5D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 853 B |
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
|
|
@ -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 |
5
src/assets/map.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="16" height="16" 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="#223D5D" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.70703 2.66602V11.3327" stroke="#223D5D" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.4863 4.41406V13.3341" stroke="#223D5D" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 933 B |
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
|
|
@ -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 |
5
src/assets/profile-circle.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.1207 12.7805C12.0507 12.7705 11.9607 12.7705 11.8807 12.7805C10.1207 12.7205 8.7207 11.2805 8.7207 9.51047C8.7207 7.70047 10.1807 6.23047 12.0007 6.23047C13.8107 6.23047 15.2807 7.70047 15.2807 9.51047C15.2707 11.2805 13.8807 12.7205 12.1207 12.7805Z" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.7398 19.3805C16.9598 21.0105 14.5998 22.0005 11.9998 22.0005C9.39977 22.0005 7.03977 21.0105 5.25977 19.3805C5.35977 18.4405 5.95977 17.5205 7.02977 16.8005C9.76977 14.9805 14.2498 14.9805 16.9698 16.8005C18.0398 17.5205 18.6398 18.4405 18.7398 19.3805Z" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#4D4D4D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,015 B |
4
src/assets/search-normal.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="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z" stroke="#666666" stroke-width="1.3835" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22 22L20 20" stroke="#666666" stroke-width="1.3835" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
4
src/assets/success_fallback.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M87.6843 35.2269C86.0089 35.1574 84.1303 35.9432 82.6821 37.4859L49.8018 72.5197L36.1683 59.574C33.8655 57.3862 30.7441 56.96 29.1707 58.6174L28.278 59.556C26.7046 61.2137 27.2868 64.3147 29.5952 66.4964L46.7361 82.7002C48.7203 84.5114 52.1374 83.9803 54.4546 81.5114L89.6437 44.0199C91.961 41.5509 92.2732 38.1059 90.3456 36.2964C89.6222 35.6179 88.6899 35.2685 87.6843 35.2269Z" fill="#57A327"/>
|
||||
<path d="M59.5005 0C43.7202 0 28.586 6.26823 17.4275 17.4268C6.26893 28.5857 0 43.7192 0 59.5002C0 75.2805 6.26893 90.4147 17.4275 101.573C28.586 112.731 43.7202 119 59.5005 119C75.2808 119 90.4144 112.73 101.573 101.573C112.732 90.4137 119 75.2805 119 59.5002C119 43.7199 112.732 28.5857 101.573 17.4268C90.4144 6.26893 75.2808 0 59.5005 0ZM59.5005 9.34306C72.803 9.34306 85.5608 14.6275 94.9663 24.0337C104.372 33.4403 109.657 46.197 109.657 59.5002C109.657 72.8017 104.372 85.5593 94.9663 94.9656C85.5608 104.372 72.803 109.656 59.5005 109.656C46.1984 109.656 33.4407 104.372 24.0344 94.9656C14.6282 85.5593 9.34377 72.8017 9.34377 59.5002C9.34377 46.197 14.6282 33.4403 24.0344 24.0337C33.4407 14.6275 46.1984 9.34306 59.5005 9.34306Z" fill="#57A327"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
8
src/assets/task.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.33398 13H14.0007" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.33398 8.33398H14.0007" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.33398 3.66602H14.0007" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8.33333L2.66667 9L4.66667 7" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 852 B |
10
src/components/BookIcon.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default function BookIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5 text-[#4D4D4D]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 16.7397V4.6697C22 3.4697 21.02 2.5797 19.83 2.6797H19.77C17.67 2.8597 14.48 3.9297 12.7 5.0497L12.53 5.1597C12.24 5.3397 11.76 5.3397 11.47 5.1597L11.22 5.0097C9.44 3.8997 6.26 2.8397 4.16 2.6697C2.97 2.5697 2 3.4697 2 4.6597V16.7397C2 17.6997 2.78 18.5997 3.74 18.7197L4.03 18.7597C6.2 19.0497 9.55 20.1497 11.47 21.1997L11.51 21.2197C11.78 21.3697 12.21 21.3697 12.47 21.2197C14.39 20.1597 17.75 19.0497 19.93 18.7597L20.26 18.7197C21.22 18.5997 22 17.6997 22 16.7397Z" />
|
||||
<path d="M12 5.49023V20.4902" />
|
||||
<path d="M7.75 8.49023H5.5" />
|
||||
<path d="M8.5 11.4902H5.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ import React from 'react'
|
|||
|
||||
type State = { hasError: boolean; error?: Error }
|
||||
|
||||
export default class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, State> {
|
||||
constructor(props: {}) {
|
||||
export default class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||
constructor(props: React.PropsWithChildren) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ export default class ErrorBoundary extends React.Component<React.PropsWithChildr
|
|||
|
||||
componentDidCatch(error: Error, info: unknown) {
|
||||
// send to remote logging if desired
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Uncaught error in component tree:', error, info)
|
||||
}
|
||||
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
11
src/components/RequireRole.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { getUserRole, UserRole } from '../utils/role'
|
||||
|
||||
export default function RequireRole({ role, children }: { role: UserRole; children: ReactNode }) {
|
||||
const userRole = getUserRole()
|
||||
if (userRole !== role) {
|
||||
return <Navigate to={userRole === 'cafe_owner' ? '/cafe/login' : '/login'} replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
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 placeholder:font-light placeholder:text-[#999999]"
|
||||
/>
|
||||
</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 placeholder:font-light placeholder:text-[#999999]"
|
||||
/>
|
||||
</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,
|
||||
);
|
||||
}
|
||||
14
src/components/SuccessIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export default function SuccessIcon({ className = 'mx-auto h-28 w-28' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M87.6843 35.2269C86.0089 35.1574 84.1303 35.9432 82.6821 37.4859L49.8018 72.5197L36.1683 59.574C33.8655 57.3862 30.7441 56.96 29.1707 58.6174L28.278 59.556C26.7046 61.2137 27.2868 64.3147 29.5952 66.4964L46.7361 82.7002C48.7203 84.5114 52.1374 83.9803 54.4546 81.5114L89.6437 44.0199C91.961 41.5509 92.2732 38.1059 90.3456 36.2964C89.6222 35.6179 88.6899 35.2685 87.6843 35.2269Z"
|
||||
fill="#57A327"
|
||||
/>
|
||||
<path
|
||||
d="M59.5005 0C43.7202 0 28.586 6.26823 17.4275 17.4268C6.26893 28.5857 0 43.7192 0 59.5002C0 75.2805 6.26893 90.4147 17.4275 101.573C28.586 112.731 43.7202 119 59.5005 119C75.2808 119 90.4144 112.73 101.573 101.573C112.732 90.4137 119 75.2805 119 59.5002C119 43.7199 112.732 28.5857 101.573 17.4268C90.4144 6.26893 75.2808 0 59.5005 0ZM59.5005 9.34306C72.803 9.34306 85.5608 14.6275 94.9663 24.0337C104.372 33.4403 109.657 46.197 109.657 59.5002C109.657 72.8017 104.372 85.5593 94.9663 94.9656C85.5608 104.372 72.803 109.656 59.5005 109.656C46.1984 109.656 33.4407 104.372 24.0344 94.9656C14.6282 85.5593 9.34377 72.8017 9.34377 59.5002C9.34377 46.197 14.6282 33.4403 24.0344 24.0337C33.4407 14.6275 46.1984 9.34306 59.5005 9.34306Z"
|
||||
fill="#57A327"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,6 @@
|
|||
import React, { createContext, useCallback, useEffect, useRef, useState, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, ReactNode } from "react";
|
||||
import { Location } from "../types/location";
|
||||
|
||||
type LocationError = "permission_denied" | "location_error" | null;
|
||||
|
||||
type LocationContextValue = {
|
||||
location: Location | null;
|
||||
loading: boolean;
|
||||
error: LocationError;
|
||||
requestLocation: () => void;
|
||||
};
|
||||
|
||||
export const LocationContext = createContext<LocationContextValue | undefined>(undefined);
|
||||
import { LocationContext, LocationError } from "./LocationContextValue";
|
||||
|
||||
export const LocationProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [location, setLocation] = useState<Location | null>(null);
|
||||
|
|
@ -27,8 +17,6 @@ export const LocationProvider = ({ children }: { children: ReactNode }) => {
|
|||
setLocation(loc);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
// Log once received
|
||||
// eslint-disable-next-line no-console
|
||||
console.info("User location obtained", loc);
|
||||
};
|
||||
|
||||
|
|
@ -37,11 +25,9 @@ export const LocationProvider = ({ children }: { children: ReactNode }) => {
|
|||
setLoading(false);
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
setError("permission_denied");
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Geolocation permission denied", err.message);
|
||||
} else {
|
||||
setError("location_error");
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Geolocation error", err.message);
|
||||
}
|
||||
};
|
||||
|
|
@ -74,26 +60,29 @@ export const LocationProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
const requestLocation = useCallback(() => {
|
||||
// If Permissions API exists, read the state first to provide immediate feedback
|
||||
if ((navigator as any).permissions && typeof (navigator as any).permissions.query === 'function') {
|
||||
if (navigator.permissions && typeof navigator.permissions.query === "function") {
|
||||
try {
|
||||
;(navigator as any).permissions.query({ name: 'geolocation' }).then((status: PermissionStatus) => {
|
||||
if (status.state === 'granted') {
|
||||
navigator.permissions
|
||||
.query({ name: "geolocation" })
|
||||
.then((status) => {
|
||||
if (status.state === "granted") {
|
||||
// permission already granted
|
||||
performGetPosition();
|
||||
} else if (status.state === 'denied') {
|
||||
} else if (status.state === "denied") {
|
||||
// permission denied
|
||||
setLoading(false);
|
||||
setError('permission_denied');
|
||||
setError("permission_denied");
|
||||
setLocation(null);
|
||||
} else {
|
||||
// prompt
|
||||
performGetPosition();
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback to direct request
|
||||
performGetPosition();
|
||||
});
|
||||
} catch (e) {
|
||||
} catch {
|
||||
performGetPosition();
|
||||
}
|
||||
} else {
|
||||
|
|
@ -103,9 +92,8 @@ export const LocationProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
// Automatically request location on provider mount
|
||||
useEffect(() => {
|
||||
requestLocation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
queueMicrotask(requestLocation);
|
||||
}, [requestLocation]);
|
||||
|
||||
return (
|
||||
<LocationContext.Provider value={{ location, loading, error, requestLocation }}>
|
||||
|
|
|
|||
13
src/context/LocationContextValue.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext } from "react";
|
||||
import { Location } from "../types/location";
|
||||
|
||||
export type LocationError = "permission_denied" | "location_error" | null;
|
||||
|
||||
export type LocationContextValue = {
|
||||
location: Location | null;
|
||||
loading: boolean;
|
||||
error: LocationError;
|
||||
requestLocation: () => void;
|
||||
};
|
||||
|
||||
export const LocationContext = createContext<LocationContextValue | undefined>(undefined);
|
||||
11
src/data/mockCafeEvents.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface CafeEvent {
|
||||
id: number
|
||||
matchLabel: string
|
||||
remainingSeats: number
|
||||
totalSeats: number
|
||||
}
|
||||
|
||||
export const mockCafeEvents: CafeEvent[] = [
|
||||
{ id: 1, matchLabel: 'بازی برزیل-مراکش (۳ روز دیگر-۲:۰۰)', remainingSeats: 8, totalSeats: 8 },
|
||||
{ id: 2, matchLabel: 'بازی برزیل-مراکش (۳ روز دیگر-۲:۰۰)', remainingSeats: 0, totalSeats: 8 },
|
||||
]
|
||||
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: [],
|
||||
},
|
||||
]
|
||||
33
src/data/mockMyReservations.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export interface MyReservation {
|
||||
id: number
|
||||
matchLabel: string
|
||||
dateTimeLabel: string
|
||||
count: number
|
||||
code: string
|
||||
name: string
|
||||
phone: string
|
||||
cafeName: string
|
||||
}
|
||||
|
||||
export const mockMyReservations: MyReservation[] = [
|
||||
{
|
||||
id: 1,
|
||||
matchLabel: 'برزیل-مراکش',
|
||||
dateTimeLabel: 'سهشنبه ۲۰ خرداد - ساعت ۰۰:۳۰',
|
||||
count: 3,
|
||||
code: 'QC-۸۸۸۷',
|
||||
name: 'وینسو وینسوزاده',
|
||||
phone: '۰۹۱۲۳۴۵۶۷۸۹',
|
||||
cafeName: 'بیبای',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
matchLabel: 'برزیل-مراکش',
|
||||
dateTimeLabel: 'سهشنبه ۲۰ خرداد - ساعت ۰۰:۳۰',
|
||||
count: 3,
|
||||
code: 'QC-۸۸۸۷',
|
||||
name: 'وینسو وینسوزاده',
|
||||
phone: '۰۹۱۲۳۴۵۶۷۸۹',
|
||||
cafeName: 'بیبای',
|
||||
},
|
||||
]
|
||||
13
src/data/mockReservations.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export interface Reservation {
|
||||
id: number
|
||||
code: string
|
||||
count: number
|
||||
name: string
|
||||
phone: string
|
||||
}
|
||||
|
||||
export const mockReservations: Reservation[] = [
|
||||
{ id: 1, code: 'QC-۸۸۸۷', count: 3, name: 'وینسو وینسوزاده', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||
{ id: 2, code: 'QC-۸۸۸۷', count: 3, name: 'وینسو وینسوزاده', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||
{ id: 3, code: 'QC-۸۸۸۷', count: 3, name: 'وینسو وینسوزاده', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||
]
|
||||
19
src/hooks/useMatchEvents.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { fetchMatchEvents, MatchEvent } from '../services/campaignApi'
|
||||
import { useNow } from './useNow'
|
||||
|
||||
export function useMatchEvents() {
|
||||
const [matches, setMatches] = useState<MatchEvent[]>([])
|
||||
const now = useNow()
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatchEvents()
|
||||
.then(setMatches)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// drop matches whose kickoff has already passed — only upcoming ones should ever be selectable
|
||||
const upcomingMatches = matches.filter((match) => new Date(match.match_datetime).getTime() > now.getTime())
|
||||
|
||||
return { matches: upcomingMatches, now }
|
||||
}
|
||||
12
src/hooks/useNow.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useNow() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return now
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useContext } from 'react'
|
||||
import { LocationContext } from '../context/LocationContext'
|
||||
import { LocationContext } from '../context/LocationContextValue'
|
||||
|
||||
export function useUserLocation() {
|
||||
const ctx = useContext(LocationContext)
|
||||
|
|
|
|||
113
src/index.css
|
|
@ -1,111 +1,18 @@
|
|||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap');
|
||||
@import 'tailwindcss';
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
@theme {
|
||||
--font-sans: 'Vazirmatn', system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
font-family: var(--font-sans);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
|
|
|||
261
src/pages/AddEvent.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
import { createCafeSchedule, MatchEvent } from '../services/campaignApi'
|
||||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
import { getCafeUuid } from '../utils/cafe'
|
||||
|
||||
const ENTRY_FEE_STEP = 10000
|
||||
const DESCRIPTION_MAX_LENGTH = 200
|
||||
|
||||
function toEnglishDigits(value: string) {
|
||||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||
return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
|
||||
}
|
||||
|
||||
function ChevronIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<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 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 MatchSelect({
|
||||
value,
|
||||
options,
|
||||
now,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
options: MatchEvent[]
|
||||
now: Date
|
||||
onChange: (uuid: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedMatch = options.find((match) => match.uuid === value)
|
||||
|
||||
return (
|
||||
<div className="relative mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">بازی</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
|
||||
>
|
||||
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
||||
{selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'}
|
||||
</span>
|
||||
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-x-0 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
value,
|
||||
step,
|
||||
min = 0,
|
||||
suffix,
|
||||
editable = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
step: number
|
||||
min?: number
|
||||
suffix: string
|
||||
editable?: boolean
|
||||
onChange: (next: number) => void
|
||||
}) {
|
||||
const atMin = value <= min
|
||||
const measureRef = useRef<HTMLSpanElement>(null)
|
||||
const [inputWidth, setInputWidth] = useState<number>(50)
|
||||
|
||||
useEffect(() => {
|
||||
if (editable && measureRef.current) {
|
||||
setInputWidth(measureRef.current.offsetWidth)
|
||||
}
|
||||
}, [editable, value])
|
||||
|
||||
const handleInputChange = (raw: string) => {
|
||||
const digitsOnly = toEnglishDigits(raw).replace(/[^\d]/g, '')
|
||||
onChange(digitsOnly === '' ? min : Math.max(min, Number(digitsOnly)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir="ltr" className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(min, value - step))}
|
||||
disabled={atMin}
|
||||
className={`flex h-6 w-6 items-center justify-center ${atMin ? 'cursor-not-allowed text-[#999999]' : 'text-[#223D5D]'}`}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{editable ? (
|
||||
<div dir="rtl" className="flex flex-1 gap-2 items-center justify-center">
|
||||
<span ref={measureRef} className="invisible absolute font-medium whitespace-pre">
|
||||
{formatPersianNumber(value)}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatPersianNumber(value)}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
style={inputWidth ? { width: `${inputWidth}px` } : undefined}
|
||||
className="bg-transparent text-left font-medium text-gray-900 focus:outline-none"
|
||||
/>
|
||||
<span className="shrink-0 font-medium text-gray-900">{suffix}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span dir="rtl" className="text-center font-medium text-gray-900">
|
||||
{formatPersianNumber(value)} {suffix}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => onChange(value + step)} className="flex h-6 w-6 items-center justify-center text-[#223D5D]">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DescriptionField({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">توضیحات (اختیاری)</label>
|
||||
<div className="relative rounded-2xl border border-gray-200">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.slice(0, DESCRIPTION_MAX_LENGTH))}
|
||||
placeholder="مثال: ورودی شامل نوشیدنی و پاپکورن"
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-2xl bg-transparent px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] focus:outline-none"
|
||||
/>
|
||||
<span dir="ltr" className="absolute left-4 top-3 text-sm text-[#999999]">
|
||||
{formatPersianNumber(value.length)}/{formatPersianNumber(DESCRIPTION_MAX_LENGTH)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AddEvent() {
|
||||
const navigate = useNavigate()
|
||||
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||
const { matches, now } = useMatchEvents()
|
||||
const [capacity, setCapacity] = useState(1)
|
||||
const [entryFee, setEntryFee] = useState(100000)
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createCafeSchedule(getCafeUuid() ?? '', {
|
||||
match_event_uuid: selectedMatchUuid,
|
||||
total_tickets: capacity,
|
||||
ticket_price: String(entryFee),
|
||||
description,
|
||||
})
|
||||
navigate('/owner/events/success')
|
||||
} catch {
|
||||
setError('ثبت رویداد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
||||
<ChevronIcon className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||
<span className="text-lg font-bold text-gray-900">افزودن ایونت</span>
|
||||
</button>
|
||||
|
||||
<div className="px-4">
|
||||
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
||||
<Stepper value={capacity} step={1} min={1} suffix="نفر" editable onChange={setCapacity} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ورودی</label>
|
||||
<Stepper value={entryFee} step={ENTRY_FEE_STEP} min={0} suffix="تومان" editable onChange={setEntryFee} />
|
||||
</div>
|
||||
|
||||
<DescriptionField value={description} onChange={setDescription} />
|
||||
|
||||
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex justify-end bg-white px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'در حال ارسال...' : 'ادامه'}
|
||||
{!isSubmitting && <ChevronIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/pages/AddEventSuccess.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import SuccessIcon from '../components/SuccessIcon'
|
||||
|
||||
export default function AddEventSuccess() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white pb-32">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<SuccessIcon />
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/owner/events/new')}
|
||||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||
>
|
||||
افزودن ایونت
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/owner/panel')}
|
||||
className="w-full rounded-2xl border border-[#223D5D] py-4 text-lg font-semibold text-[#223D5D]"
|
||||
>
|
||||
مشاهده پنل
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
src/pages/CafeDetail.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
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 "../utils/leafletIcons";
|
||||
import { mockCafes, Cafe } from "../data/mockCafes";
|
||||
import InfoIcon from "../components/InfoIcon";
|
||||
import ReservationModal from "../components/ReservationModal";
|
||||
|
||||
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="© Addwin Maps"
|
||||
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
|
||||
maxZoom={19}
|
||||
minZoom={8}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
138
src/pages/CafeOwnerPanel.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import BookIcon from '../components/BookIcon'
|
||||
import { useNow } from '../hooks/useNow'
|
||||
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
|
||||
import { getCafeUuid } from '../utils/cafe'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
|
||||
function ChevronIcon({ className = 'h-5 w-5 text-gray-400' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCircleIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5 text-[#4D4D4D]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.1207 12.7805C12.0507 12.7705 11.9607 12.7705 11.8807 12.7805C10.1207 12.7205 8.7207 11.2805 8.7207 9.51047C8.7207 7.70047 10.1807 6.23047 12.0007 6.23047C13.8107 6.23047 15.2807 7.70047 15.2807 9.51047C15.2707 11.2805 13.8807 12.7205 12.1207 12.7805Z" />
|
||||
<path d="M18.7398 19.3805C16.9598 21.0105 14.5998 22.0005 11.9998 22.0005C9.39977 22.0005 7.03977 21.0105 5.25977 19.3805C5.35977 18.4405 5.95977 17.5205 7.02977 16.8005C9.76977 14.9805 14.2498 14.9805 16.9698 16.8005C18.0398 17.5205 18.6398 18.4405 18.7398 19.3805Z" />
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5 text-[#4D4D4D]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2V5" />
|
||||
<path d="M16 2V5" />
|
||||
<path d="M3.5 9.08984H20.5" />
|
||||
<path d="M21 8.5V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" />
|
||||
<path strokeWidth={2} d="M15.6937 13.6992H15.7027" />
|
||||
<path strokeWidth={2} d="M15.6937 16.6992H15.7027" />
|
||||
<path strokeWidth={2} d="M11.9945 13.6992H12.0035" />
|
||||
<path strokeWidth={2} d="M11.9945 16.6992H12.0035" />
|
||||
<path strokeWidth={2} d="M8.29529 13.6992H8.30427" />
|
||||
<path strokeWidth={2} d="M8.29529 16.6992H8.30427" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-gray-900">{label}</span>
|
||||
</div>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CafeOwnerPanel() {
|
||||
const navigate = useNavigate()
|
||||
const now = useNow()
|
||||
const [schedules, setSchedules] = useState<CafeSchedule[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const cafeUuid = getCafeUuid()
|
||||
if (!cafeUuid) return
|
||||
fetchCafeSchedules(cafeUuid)
|
||||
.then(setSchedules)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-20">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="px-4">
|
||||
<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={<ProfileCircleIcon />}
|
||||
label="اطلاعات کافه"
|
||||
onClick={() => navigate('/owner/register', { state: { step: 2 } })}
|
||||
/>
|
||||
<PanelRow icon={<BookIcon />} label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} />
|
||||
</div>
|
||||
|
||||
<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)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={schedule.uuid}
|
||||
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>
|
||||
)}
|
||||
</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)} باقی مانده
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<PanelRow
|
||||
icon={<CalendarIcon />}
|
||||
label="مشاهده رزروها"
|
||||
onClick={() =>
|
||||
navigate(`/owner/events/${schedule.uuid}/reservations`, {
|
||||
state: {
|
||||
event: {
|
||||
id: schedule.uuid,
|
||||
matchLabel,
|
||||
remainingSeats: schedule.remaining_tickets,
|
||||
totalSeats: schedule.total_tickets,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
529
src/pages/CafeOwnerRegister.tsx
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 InfoIcon from "../components/InfoIcon";
|
||||
import {
|
||||
CafeFeature,
|
||||
City,
|
||||
fetchCafeFeatures,
|
||||
fetchCities,
|
||||
fetchMyCafes,
|
||||
registerCafe,
|
||||
registerCafeOwner,
|
||||
updateCafe,
|
||||
} from "../services/campaignApi";
|
||||
import { getCafeUuid, setCafeUuid } from "../utils/cafe";
|
||||
import { toEnglishDigits } from "../utils/formatNumber";
|
||||
import { fromE164, toE164 } from "../utils/phone";
|
||||
|
||||
// Tehran — default pin until the owner picks their cafe's actual location
|
||||
const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 };
|
||||
|
||||
function ArrowIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<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 CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2.5 6.5L4.5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCircle({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`mx-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${
|
||||
active
|
||||
? "bg-[#223D5D] text-white"
|
||||
: "border border-gray-300 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({ currentStep }: { currentStep: 1 | 2 }) {
|
||||
return (
|
||||
<div className="flex items-center px-4 py-7">
|
||||
<span
|
||||
className={`whitespace-nowrap ${currentStep === 1 ? "font-medium text-gray-900" : "text-gray-400"}`}
|
||||
>
|
||||
اطلاعات نماینده
|
||||
</span>
|
||||
<StepCircle active={currentStep === 1}>۱</StepCircle>
|
||||
<span className="mx-1 h-px flex-1 bg-gray-200" />
|
||||
<StepCircle active={currentStep === 2}>۲</StepCircle>
|
||||
<span
|
||||
className={`whitespace-nowrap ${currentStep === 2 ? "font-medium text-gray-900" : "text-gray-400"}`}
|
||||
>
|
||||
اطلاعات کافه
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CitySelectField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: City[];
|
||||
onChange: (uuid: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedCity = options.find((city) => city.uuid === value);
|
||||
|
||||
return (
|
||||
<div className="relative mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
|
||||
>
|
||||
<span className={selectedCity ? "text-gray-900" : "font-light text-[#999999]"}>
|
||||
{selectedCity ? selectedCity.city_name : "شهر کافه را انتخاب کنید"}
|
||||
</span>
|
||||
<ChevronDownIcon className={open ? "rotate-180" : ""} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-60 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) => void }) {
|
||||
// keep the handlers object reference stable — react-leaflet re-binds map
|
||||
// listeners (map.off/map.on) whenever this reference changes, and doing
|
||||
// that mid-drag (e.g. triggered by an unrelated re-render) was silently
|
||||
// aborting the user's pan gesture. a ref lets onSelect stay fresh without
|
||||
// recreating the object every render.
|
||||
const onSelectRef = useRef(onSelect);
|
||||
useEffect(() => {
|
||||
onSelectRef.current = onSelect;
|
||||
}, [onSelect]);
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
click(e: { latlng: { lat: number; lng: number } }) {
|
||||
onSelectRef.current(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useMapEvents(handlers);
|
||||
return null;
|
||||
}
|
||||
|
||||
function CafeLocationMap({
|
||||
location,
|
||||
onChange,
|
||||
}: {
|
||||
location: { lat: number; lng: number };
|
||||
onChange: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||
موقعیت کافه روی نقشه
|
||||
</label>
|
||||
<MapContainer
|
||||
center={[location.lat, location.lng]}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
className="h-48 w-full overflow-hidden rounded-2xl"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© Addwin Maps"
|
||||
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
|
||||
maxZoom={19}
|
||||
minZoom={8}
|
||||
/>
|
||||
<Marker position={[location.lat, location.lng]} />
|
||||
<ZoomControl position="topright" />
|
||||
<LocationPicker onSelect={onChange} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadBox({
|
||||
label,
|
||||
buttonLabel,
|
||||
}: {
|
||||
label: string;
|
||||
buttonLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-36 w-full items-center justify-center rounded-2xl border border-dashed border-gray-300 text-[#666666] bg-[#F8F8F8]"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureChip({
|
||||
label,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-2xl border border-[#F2F2F2] px-3 py-2 text-sm text-gray-900 ${
|
||||
checked ? "bg-[#ECF2F8]" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[#223D5D] ${
|
||||
checked ? "bg-[#223D5D]" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
{checked && <CheckIcon />}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CafeOwnerRegister() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const initialStep = (location.state as { step?: 1 | 2 } | null)?.step ?? 1;
|
||||
// the panel's "اطلاعات کافه" link is the only place that lands here with step 2 directly —
|
||||
// treat that as "editing an existing cafe" rather than "continuing a fresh registration"
|
||||
const isEditMode = initialStep === 2;
|
||||
const [step, setStep] = useState<1 | 2>(initialStep);
|
||||
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [nationalId, setNationalId] = useState("");
|
||||
const [cardNumber, setCardNumber] = useState("");
|
||||
|
||||
const [cafeName, setCafeName] = useState("");
|
||||
const [selectedCityUuid, setSelectedCityUuid] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [cafePhone, setCafePhone] = useState("");
|
||||
const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION);
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
|
||||
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cafeFeatures, setCafeFeatures] = useState<CafeFeature[]>([]);
|
||||
// returned by /campaign/cafe-owners/ — sent as owner_uuid in the final /campaign/cafes/ request
|
||||
const [ownerUuid, setOwnerUuid] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStep === 1 && getCafeUuid()) {
|
||||
navigate("/owner/panel", { replace: true });
|
||||
}
|
||||
}, [initialStep, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCities()
|
||||
.then(setCities)
|
||||
.catch(() => {});
|
||||
fetchCafeFeatures()
|
||||
.then(setCafeFeatures)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return;
|
||||
fetchMyCafes()
|
||||
.then((cafes) => {
|
||||
const cafe = cafes[0];
|
||||
if (!cafe) return;
|
||||
setCafeName(cafe.name);
|
||||
setSelectedCityUuid(cafe.city.uuid);
|
||||
setAddress(cafe.address);
|
||||
setCafePhone(fromE164(cafe.phone_number));
|
||||
setCafeLocation({ lat: cafe.location.coordinates[1], lng: cafe.location.coordinates[0] });
|
||||
setSelectedFeatures(cafe.features.map((feature) => feature.uuid));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [isEditMode]);
|
||||
|
||||
const toggleFeature = (feature: string) => {
|
||||
setSelectedFeatures((prev) =>
|
||||
prev.includes(feature)
|
||||
? prev.filter((f) => f !== feature)
|
||||
: [...prev, feature],
|
||||
);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (step === 1) {
|
||||
const owner = await registerCafeOwner({
|
||||
full_name: fullName,
|
||||
phone_number: toE164(phone),
|
||||
national_id: toEnglishDigits(nationalId),
|
||||
pan_number: toEnglishDigits(cardNumber),
|
||||
});
|
||||
setOwnerUuid(owner.uuid);
|
||||
setStep(2);
|
||||
} else if (isEditMode) {
|
||||
const cafeUuid = getCafeUuid() ?? "";
|
||||
await updateCafe(cafeUuid, {
|
||||
city_uuid: selectedCityUuid,
|
||||
name: cafeName,
|
||||
phone_number: cafePhone ? toE164(cafePhone) : "",
|
||||
location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] },
|
||||
address,
|
||||
feature_uuids: selectedFeatures,
|
||||
});
|
||||
navigate("/owner/panel");
|
||||
} else {
|
||||
const cafe = await registerCafe({
|
||||
owner_uuid: ownerUuid,
|
||||
city_uuid: selectedCityUuid,
|
||||
name: cafeName,
|
||||
phone_number: cafePhone ? toE164(cafePhone) : "",
|
||||
location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] },
|
||||
address,
|
||||
feature_uuids: selectedFeatures,
|
||||
});
|
||||
setCafeUuid(cafe.uuid);
|
||||
navigate("/owner/register/success");
|
||||
}
|
||||
} catch {
|
||||
setError("ثبت اطلاعات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<Stepper currentStep={step} />
|
||||
|
||||
<div className="px-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-4">
|
||||
<InfoIcon />
|
||||
<p className="text-right text-md leading-relaxed text-gray-600">
|
||||
لطفاً یک{" "}
|
||||
<span className="font-semibold text-gray-900">نماینده</span>{" "}
|
||||
برای کافه معرفی کنید. نماینده باید در اپلیکیشن
|
||||
ثبتنام کند و پرداختها به کیف پول او واریز میشود. شماره
|
||||
موبایل، کد ملی و کارت بانکی باید متعلق به یک نفر باشند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="نام و نام خانوادگی"
|
||||
placeholder="نام و نام خانوادگی نماینده را وارد کنید"
|
||||
value={fullName}
|
||||
onChange={setFullName}
|
||||
/>
|
||||
<FormField
|
||||
label="شماره تماس نماینده"
|
||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
/>
|
||||
<FormField
|
||||
label="کد ملی"
|
||||
placeholder="کد ملی نماینده را وارد کنید"
|
||||
value={nationalId}
|
||||
onChange={setNationalId}
|
||||
/>
|
||||
<FormField
|
||||
label="شماره کارت"
|
||||
placeholder="شماره کارت نماینده را وارد کنید"
|
||||
value={cardNumber}
|
||||
onChange={setCardNumber}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
label="نام کافه"
|
||||
placeholder="نام کافه را وارد کنید"
|
||||
value={cafeName}
|
||||
onChange={setCafeName}
|
||||
/>
|
||||
<CitySelectField
|
||||
label="شهر"
|
||||
value={selectedCityUuid}
|
||||
options={cities}
|
||||
onChange={setSelectedCityUuid}
|
||||
/>
|
||||
<FormField
|
||||
label="آدرس"
|
||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
/>
|
||||
<FormField
|
||||
label="شماره تماس کافه (اختیاری)"
|
||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
value={cafePhone}
|
||||
onChange={setCafePhone}
|
||||
/>
|
||||
<CafeLocationMap
|
||||
location={cafeLocation}
|
||||
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
|
||||
/>
|
||||
|
||||
<UploadBox label="تصاویر (اختیاری)" buttonLabel="افزودن تصویر" />
|
||||
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||
ویژگیهای کافه
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{cafeFeatures.map((feature) => (
|
||||
<FeatureChip
|
||||
key={feature.uuid}
|
||||
label={feature.name}
|
||||
checked={selectedFeatures.includes(feature.uuid)}
|
||||
onToggle={() => toggleFeature(feature.uuid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</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>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleContinue}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "در حال ارسال..." : isEditMode ? "ذخیره تغییرات" : "ادامه"}
|
||||
{!isSubmitting && !isEditMode && <ArrowIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/pages/CafeRegisterSuccess.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import SuccessIcon from '../components/SuccessIcon'
|
||||
|
||||
export default function CafeRegisterSuccess() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white pb-32">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<SuccessIcon />
|
||||
<h1 className="mt-6 text-xl font-bold text-gray-900">کافه شما با موفقیت ثبت شد!</h1>
|
||||
<p className="mt-3 leading-relaxed text-gray-500">
|
||||
با اضافه کردن ایونت بازیها، کافه شما در کمپین نمایش داده میشود و کاربران میتوانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 flex flex-col gap-3 bg-white px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/owner/events/new')}
|
||||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||
>
|
||||
افزودن ایونت
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/owner/panel')}
|
||||
className="w-full rounded-2xl border border-[#223D5D] py-4 text-lg font-semibold text-[#223D5D]"
|
||||
>
|
||||
مشاهده پنل
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
src/pages/EventReservations.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
|
||||
import { mockReservations, Reservation } from "../data/mockReservations";
|
||||
import { formatPersianNumber } from "../utils/formatNumber";
|
||||
|
||||
function ChevronIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.3835}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z" />
|
||||
<path d="M22 22L20 20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2.5 6.5L4.5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function toEnglishDigits(value: string) {
|
||||
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
||||
return value.replace(/[۰-۹]/g, (digit) =>
|
||||
String(persianDigits.indexOf(digit)),
|
||||
);
|
||||
}
|
||||
|
||||
function reservationMatches(reservation: Reservation, query: string) {
|
||||
if (!query) return true;
|
||||
const haystack = toEnglishDigits(
|
||||
`${reservation.name} ${reservation.phone} ${reservation.count} ${formatPersianNumber(reservation.count)} ${reservation.code}`,
|
||||
).toLowerCase();
|
||||
return haystack.includes(toEnglishDigits(query).toLowerCase());
|
||||
}
|
||||
|
||||
function downloadReservationsCsv(reservations: Reservation[]) {
|
||||
const header = ["کد رزرو", "تعداد", "نام", "شماره تماس"];
|
||||
const rows = reservations.map((r) => [
|
||||
r.code,
|
||||
String(r.count),
|
||||
r.name,
|
||||
toEnglishDigits(r.phone),
|
||||
]);
|
||||
const csvContent = [header, ...rows]
|
||||
.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob(["" + csvContent], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "reservations.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function ReservationCard({
|
||||
reservation,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
reservation: Reservation;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
|
||||
{reservation.code}
|
||||
</span>
|
||||
<span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
|
||||
{formatPersianNumber(reservation.count)} نفر
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900">{reservation.name}</p>
|
||||
<p className="text-gray-500">{reservation.phone}</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 && <CheckIcon />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventReservations() {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
const event: CafeEvent | undefined =
|
||||
(location.state as { event?: CafeEvent } | null)?.event ??
|
||||
mockCafeEvents.find((e) => e.id === Number(id));
|
||||
|
||||
const filteredReservations = useMemo(
|
||||
() =>
|
||||
mockReservations.filter((reservation) =>
|
||||
reservationMatches(reservation, query),
|
||||
),
|
||||
[query],
|
||||
);
|
||||
|
||||
const toggleSelected = (reservationId: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(reservationId)
|
||||
? prev.filter((id) => id !== reservationId)
|
||||
: [...prev, reservationId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const selectedReservations =
|
||||
selectedIds.length > 0
|
||||
? mockReservations.filter((r) => selectedIds.includes(r.id))
|
||||
: mockReservations;
|
||||
downloadReservationsCsv(selectedReservations);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
|
||||
>
|
||||
<ChevronIcon className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||
<span className="text-md font-semibold text-gray-900">
|
||||
رزروهای {event?.matchLabel ?? ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="relative mt-4 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="جستجو"
|
||||
className="w-full rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] bg-white py-3 pr-6 pl-4 text-right placeholder:font-light placeholder:text-[#999999]"
|
||||
/>
|
||||
<SearchIcon className="pointer-events-none absolute left-8 top-1/2 h-5 w-5 -translate-y-1/2 text-[#666666]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 px-4">
|
||||
{filteredReservations.map((reservation) => (
|
||||
<ReservationCard
|
||||
key={reservation.id}
|
||||
reservation={reservation}
|
||||
selected={selectedIds.includes(reservation.id)}
|
||||
onToggle={() => toggleSelected(reservation.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 bg-white px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||
>
|
||||
دانلود لیست
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,275 @@
|
|||
import React from 'react'
|
||||
import LocationStatus from '../components/LocationStatus'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import BookIcon from '../components/BookIcon'
|
||||
import { mockCafes, Cafe } from '../data/mockCafes'
|
||||
import { MatchEvent } from '../services/campaignApi'
|
||||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
|
||||
export default function Home() {
|
||||
const cityOptions = ['تهران', 'اصفهان', 'مشهد']
|
||||
|
||||
function ChevronIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-full max-w-sm p-4">
|
||||
<LocationStatus />
|
||||
</div>
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<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,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<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 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 MatchSelectorRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
now,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: MatchEvent[]
|
||||
now: Date
|
||||
onChange: (uuid: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedMatch = options.find((match) => match.uuid === value)
|
||||
|
||||
return (
|
||||
<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">{selectedMatch ? formatMatchLabel(selectedMatch, now) : ''}</span>
|
||||
<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 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MyReservationsRow() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/reservations')}
|
||||
className="flex w-full items-center justify-between px-4 py-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookIcon />
|
||||
<span className="text-gray-900">رزروهای شما</span>
|
||||
</div>
|
||||
<ChevronIcon className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<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.remainingCapacity} صندلی</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<'list' | 'map'>('list')
|
||||
const [city, setCity] = useState(cityOptions[0])
|
||||
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||
const { matches, now } = useMatchEvents()
|
||||
const effectiveMatchUuid = selectedMatchUuid || matches[0]?.uuid || ''
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<MapPlaceholder />
|
||||
|
||||
<MyReservationsRow />
|
||||
|
||||
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
|
||||
<MatchSelectorRow
|
||||
label="بازی انتخابی"
|
||||
value={effectiveMatchUuid}
|
||||
options={matches}
|
||||
now={now}
|
||||
onChange={setSelectedMatchUuid}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between px-4">
|
||||
<h2 className="font-semibold text-gray-900">کافهها</h2>
|
||||
<div className="flex rounded-full bg-gray-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('map')}
|
||||
className={`flex items-center gap-1 rounded-full px-3 py-1.5 text-sm ${
|
||||
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-[#223D5D] text-white' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<ListIcon />
|
||||
لیست
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-4">
|
||||
{view === 'list' ? (
|
||||
<div className="space-y-3">
|
||||
{mockCafes.map((cafe) => (
|
||||
<CafeCard key={cafe.id} cafe={cafe} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<MapPlaceholder />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
|
||||
>
|
||||
پشتیبانی
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
106
src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { requestOtp } from "../services/authApi";
|
||||
import { toPersianDigits } from "../utils/formatNumber";
|
||||
import { toE164 } from "../utils/phone";
|
||||
import { setUserRole } from "../utils/role";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2.5 6.5L4.5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const initialPhone = (location.state as { phone?: string } | null)?.phone ?? "";
|
||||
const isOwnerFlow = location.pathname.startsWith("/cafe/login");
|
||||
|
||||
useEffect(() => {
|
||||
setUserRole(isOwnerFlow ? "cafe_owner" : "customer");
|
||||
}, [isOwnerFlow]);
|
||||
|
||||
const [phone, setPhone] = useState(initialPhone);
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const canContinue = phone.trim().length > 0 && acceptedTerms && !isSubmitting;
|
||||
|
||||
const handleContinue = async () => {
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { ttl } = await requestOtp(toE164(phone));
|
||||
navigate("/login/otp", { state: { phone, ttl } });
|
||||
} catch {
|
||||
setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div 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"
|
||||
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]"
|
||||
/>
|
||||
|
||||
{error && <p className="mt-2 text-right text-sm text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canContinue}
|
||||
onClick={handleContinue}
|
||||
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 && <CheckIcon />}
|
||||
</button>
|
||||
<p className="text-sm text-gray-600">
|
||||
<a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
|
||||
قوانین و شرایط
|
||||
</a>{" "}
|
||||
را میپذیرم.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/pages/LoginOtp.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { requestOtp, verifyOtp } from '../services/authApi'
|
||||
import { fetchMyCafes } from '../services/campaignApi'
|
||||
import { storeAuthTokens } from '../utils/authToken'
|
||||
import { setCafeUuid } from '../utils/cafe'
|
||||
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
|
||||
import { toE164 } from '../utils/phone'
|
||||
import { getUserRole } from '../utils/role'
|
||||
|
||||
const OTP_LENGTH = 5
|
||||
const DEFAULT_RESEND_SECONDS = 120
|
||||
|
||||
function EditIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path
|
||||
strokeMiterlimit={10}
|
||||
d="M13.2594 3.59924L5.04936 12.2892C4.73936 12.6192 4.43936 13.2692 4.37936 13.7192L4.00936 16.9592C3.87936 18.1292 4.71936 18.9292 5.87936 18.7292L9.09936 18.1792C9.54936 18.0992 10.1794 17.7692 10.4894 17.4292L18.6994 8.73924C20.1194 7.23924 20.7594 5.52924 18.5494 3.43924C16.3494 1.36924 14.6794 2.09924 13.2594 3.59924Z"
|
||||
/>
|
||||
<path strokeMiterlimit={10} d="M11.8906 5.05078C12.3206 7.81078 14.5606 9.92078 17.3406 10.2008" />
|
||||
<path strokeMiterlimit={10} d="M3 22H21" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
export default function LoginOtp() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const state = location.state as { phone?: string; ttl?: number } | null
|
||||
const phone = state?.phone ?? ''
|
||||
const loginPath = getUserRole() === '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)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!phone) {
|
||||
navigate(loginPath, { replace: true })
|
||||
}
|
||||
}, [phone, loginPath, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setSecondsLeft((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [resendCycle])
|
||||
|
||||
const handleOtpChange = (index: number, rawValue: string) => {
|
||||
const digit = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(-1)
|
||||
setOtp((prev) => {
|
||||
const next = [...prev]
|
||||
next[index] = digit
|
||||
return next
|
||||
})
|
||||
setError('')
|
||||
if (digit && index < OTP_LENGTH - 1) {
|
||||
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)
|
||||
try {
|
||||
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('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const tokens = await verifyOtp(toE164(phone), otp.join(''))
|
||||
storeAuthTokens(tokens.access_token, tokens.refresh_token)
|
||||
const role = getUserRole()
|
||||
|
||||
if (role === 'cafe_owner') {
|
||||
const myCafes = await fetchMyCafes().catch(() => [])
|
||||
if (myCafes.length > 0) {
|
||||
setCafeUuid(myCafes[0].uuid)
|
||||
navigate('/owner/panel')
|
||||
} else {
|
||||
navigate('/owner/register')
|
||||
}
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
} catch {
|
||||
setError('کد وارد شده صحیح نیست')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div 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="ویرایش شماره تماس">
|
||||
<EditIcon 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"
|
||||
maxLength={1}
|
||||
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="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'در حال ورود...' : 'ورود'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/pages/MyReservations.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import InfoIcon from '../components/InfoIcon'
|
||||
import SuccessIcon from '../components/SuccessIcon'
|
||||
import { mockMyReservations, MyReservation } from '../data/mockMyReservations'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
|
||||
function ChevronIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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.matchLabel}</h3>
|
||||
<p className="text-lg text-gray-500">{reservation.dateTimeLabel}</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.count)} نفر
|
||||
</span>
|
||||
<span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
|
||||
{reservation.code}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="text-gray-900">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReservationDetailModal({ reservation, onClose }: { reservation: MyReservation; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<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()}
|
||||
>
|
||||
<SuccessIcon className="mx-auto h-24 w-24" />
|
||||
<h2 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2>
|
||||
|
||||
<div className="mt-2">
|
||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.count)} نفر`} />
|
||||
<DetailRow label="به نام" value={reservation.name} />
|
||||
<DetailRow label="شماره تماس" value={reservation.phone} />
|
||||
<DetailRow label="کافه" value={reservation.cafeName} />
|
||||
<DetailRow label="بازی" value={reservation.matchLabel} />
|
||||
<DetailRow label="تاریخ و ساعت" value={reservation.dateTimeLabel} />
|
||||
<DetailRow label="کد رزرو" value={reservation.code} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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 [selectedReservation, setSelectedReservation] = useState<MyReservation | null>(null)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
||||
<ChevronIcon 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">
|
||||
{mockMyReservations.map((reservation) => (
|
||||
<MyReservationCard key={reservation.id} reservation={reservation} onClick={() => setSelectedReservation(reservation)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedReservation && (
|
||||
<ReservationDetailModal reservation={selectedReservation} onClose={() => setSelectedReservation(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/pages/PaymentSuccess.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import InfoIcon from '../components/InfoIcon'
|
||||
import SuccessIcon from '../components/SuccessIcon'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// mock reservation summary — will come from the payment gateway's return params once integrated
|
||||
const mockReservation = {
|
||||
count: '۳ نفر',
|
||||
fullName: 'هستی حصادی',
|
||||
phone: '۰۹۱۲۳۴۵۶۷۸۹',
|
||||
cafeName: 'بیبای',
|
||||
matchName: 'برزیل-مراکش',
|
||||
dateTime: 'پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰',
|
||||
reservationCode: 'HH-۲۸۴۷',
|
||||
}
|
||||
|
||||
export default function PaymentSuccess() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<div className="w-full max-w-sm rounded-3xl bg-white p-6 shadow-lg">
|
||||
<SuccessIcon />
|
||||
|
||||
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
|
||||
<div className="mt-2">
|
||||
<DetailRow label="تعداد" value={mockReservation.count} />
|
||||
<DetailRow label="به نام" value={mockReservation.fullName} />
|
||||
<DetailRow label="شماره تماس" value={mockReservation.phone} />
|
||||
<DetailRow label="کافه" value={mockReservation.cafeName} />
|
||||
<DetailRow label="بازی" value={mockReservation.matchName} />
|
||||
<DetailRow label="تاریخ و ساعت" value={mockReservation.dateTime} />
|
||||
<DetailRow label="کد رزرو" value={mockReservation.reservationCode} />
|
||||
</div>
|
||||
|
||||
<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-600">
|
||||
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک میشود.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/')}
|
||||
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
|
||||
>
|
||||
صفحه اصلی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,19 @@
|
|||
import axios from 'axios'
|
||||
import { getAccessToken } from '../utils/authToken'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default api
|
||||
|
||||
export const exampleFetch = async (path: string) => {
|
||||
|
|
|
|||
68
src/services/authApi.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const authApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_AUTH_BASE_URL,
|
||||
headers: { Accept: 'application/json', 'Accept-Language': 'fa' },
|
||||
})
|
||||
|
||||
const SCOPE = [
|
||||
'accounts.account:change_password',
|
||||
'accounts.account:update',
|
||||
'accounts.account:retrieve',
|
||||
'wallet.wallet:get_balance',
|
||||
'billboard_merchant.billboard:retrieve',
|
||||
'billboard_merchant.billboard:update',
|
||||
'billboard_merchant.billboard:create',
|
||||
'wallet.user:transaction_list',
|
||||
'data_crud.data:retrieve',
|
||||
'data_crud.data:update',
|
||||
'data_crud.data:delete',
|
||||
'notifications.push:get_client_token',
|
||||
'crm.message:create',
|
||||
'crm.message:retrieve',
|
||||
'accounts.profile:list',
|
||||
'billboard_merchant.billboard:delete',
|
||||
].join(' ')
|
||||
|
||||
export interface RequestOtpResponse {
|
||||
phone_number: string
|
||||
otp_expire: string
|
||||
ttl: number
|
||||
}
|
||||
|
||||
export async function requestOtp(phoneNumber: string): Promise<RequestOtpResponse> {
|
||||
const { data } = await authApi.post<RequestOtpResponse>('/users/api/request_otp/', {
|
||||
phone_number: phoneNumber,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export async function verifyOtp(phoneNumber: string, otpCode: string): Promise<TokenResponse> {
|
||||
const clientId = import.meta.env.VITE_AUTH_CLIENT_ID
|
||||
const clientSecret = import.meta.env.VITE_AUTH_CLIENT_SECRET
|
||||
const credentials = btoa(`${clientId}:${clientSecret}`)
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
username: phoneNumber,
|
||||
password: otpCode,
|
||||
scope: SCOPE,
|
||||
auth_fields: 'phone_number:otp',
|
||||
})
|
||||
|
||||
const { data } = await authApi.post<TokenResponse>('/oauth2/token/', body, {
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
150
src/services/campaignApi.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import api from './api'
|
||||
|
||||
export interface RegisterCafeOwnerPayload {
|
||||
full_name: string
|
||||
phone_number: string
|
||||
national_id: string
|
||||
pan_number: string
|
||||
}
|
||||
|
||||
export interface CafeOwnerResponse {
|
||||
uuid: string
|
||||
user_uuid: string
|
||||
full_name: string
|
||||
phone_number: string
|
||||
national_id: string
|
||||
pan_number: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function registerCafeOwner(payload: RegisterCafeOwnerPayload): Promise<CafeOwnerResponse> {
|
||||
const { data } = await api.post<CafeOwnerResponse>('/campaign/cafe-owners/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface City {
|
||||
uuid: string
|
||||
city_name: string
|
||||
}
|
||||
|
||||
export async function fetchCities(): Promise<City[]> {
|
||||
const { data } = await api.get<City[]>('/campaign/cities/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface MatchEvent {
|
||||
uuid: string
|
||||
title: string
|
||||
team_home: string
|
||||
team_away: string
|
||||
match_datetime: string
|
||||
}
|
||||
|
||||
export async function fetchMatchEvents(): Promise<MatchEvent[]> {
|
||||
const { data } = await api.get<MatchEvent[]>('/campaign/match-events/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CafeFeature {
|
||||
uuid: string
|
||||
name: string
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
export async function fetchCafeFeatures(): Promise<CafeFeature[]> {
|
||||
const { data } = await api.get<CafeFeature[]>('/campaign/cafe-features/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GeoPoint {
|
||||
type: 'Point'
|
||||
coordinates: [number, number]
|
||||
}
|
||||
|
||||
export interface RegisterCafePayload {
|
||||
owner_uuid: string
|
||||
city_uuid: string
|
||||
name: string
|
||||
phone_number: string
|
||||
location: GeoPoint
|
||||
address: string
|
||||
feature_uuids: string[]
|
||||
}
|
||||
|
||||
export interface CafeResponse extends RegisterCafePayload {
|
||||
uuid: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function registerCafe(payload: RegisterCafePayload): Promise<CafeResponse> {
|
||||
const { data } = await api.post<CafeResponse>('/campaign/cafes/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UpdateCafePayload {
|
||||
city_uuid: string
|
||||
name: string
|
||||
phone_number: string
|
||||
location: GeoPoint
|
||||
address: string
|
||||
feature_uuids: string[]
|
||||
}
|
||||
|
||||
export async function updateCafe(cafeUuid: string, payload: UpdateCafePayload): Promise<CafeResponse> {
|
||||
const { data } = await api.patch<CafeResponse>(`/campaign/cafes/${cafeUuid}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CafeMedia {
|
||||
uuid: string
|
||||
cafe_uuid: string
|
||||
url: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface MyCafe {
|
||||
uuid: string
|
||||
owner_uuid: string
|
||||
name: string
|
||||
phone_number: string
|
||||
location: GeoPoint
|
||||
address: string
|
||||
is_approved: boolean
|
||||
created_at: string
|
||||
city: City
|
||||
features: CafeFeature[]
|
||||
media: CafeMedia[]
|
||||
}
|
||||
|
||||
export async function fetchMyCafes(): Promise<MyCafe[]> {
|
||||
// bare array, same as /campaign/cities/, /campaign/cafe-features/, etc. — NOT wrapped in { success, data }
|
||||
const { data } = await api.get<MyCafe[]>('/campaign/cafes/my/')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CafeSchedule {
|
||||
uuid: string
|
||||
cafe_uuid: string
|
||||
match_event: MatchEvent
|
||||
total_tickets: number
|
||||
ticket_price: string
|
||||
remaining_tickets: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CreateCafeSchedulePayload {
|
||||
match_event_uuid: string
|
||||
total_tickets: number
|
||||
ticket_price: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export async function createCafeSchedule(cafeUuid: string, payload: CreateCafeSchedulePayload): Promise<CafeSchedule> {
|
||||
const { data } = await api.post<CafeSchedule>(`/campaign/cafes/${cafeUuid}/schedules/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCafeSchedules(cafeUuid: string): Promise<CafeSchedule[]> {
|
||||
const { data } = await api.get<CafeSchedule[]>(`/campaign/cafes/${cafeUuid}/schedules/`)
|
||||
return data
|
||||
}
|
||||
11
src/utils/authToken.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const ACCESS_TOKEN_KEY = 'access_token'
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||
|
||||
export function storeAuthTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
9
src/utils/cafe.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const CAFE_UUID_STORAGE_KEY = 'cafe_uuid'
|
||||
|
||||
export function setCafeUuid(uuid: string) {
|
||||
localStorage.setItem(CAFE_UUID_STORAGE_KEY, uuid)
|
||||
}
|
||||
|
||||
export function getCafeUuid(): string | null {
|
||||
return localStorage.getItem(CAFE_UUID_STORAGE_KEY)
|
||||
}
|
||||
13
src/utils/formatNumber.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function formatPersianNumber(n: number): string {
|
||||
return n.toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
export function toEnglishDigits(value: string): string {
|
||||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||
return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
|
||||
}
|
||||
|
||||
export function toPersianDigits(value: string): string {
|
||||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||
return value.replace(/[0-9]/g, (digit) => persianDigits[Number(digit)])
|
||||
}
|
||||
12
src/utils/leafletIcons.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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'
|
||||
|
||||
// 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,
|
||||
})
|
||||
37
src/utils/matchCountdown.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { toPersianDigits } from './formatNumber'
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function formatKickoffTime(matchDate: Date) {
|
||||
const time = matchDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return toPersianDigits(time)
|
||||
}
|
||||
|
||||
// under 24h: live HH:MM:SS countdown. otherwise: "X روز دیگر". both paired with the kickoff clock time.
|
||||
export function formatMatchCountdown(matchDatetimeIso: string, now: Date): string {
|
||||
const matchDate = new Date(matchDatetimeIso)
|
||||
const diffMs = matchDate.getTime() - now.getTime()
|
||||
const kickoffTime = formatKickoffTime(matchDate)
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return `اکنون-${kickoffTime}`
|
||||
}
|
||||
|
||||
if (diffMs >= ONE_DAY_MS) {
|
||||
const days = Math.floor(diffMs / ONE_DAY_MS)
|
||||
return `${toPersianDigits(String(days))} روز دیگر-${kickoffTime}`
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(diffMs / 1000)
|
||||
const hh = String(Math.floor(totalSeconds / 3600)).padStart(2, '0')
|
||||
const mm = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(totalSeconds % 60).padStart(2, '0')
|
||||
return `${toPersianDigits(`${hh}:${mm}:${ss}`)}-${kickoffTime}`
|
||||
}
|
||||
|
||||
export function formatMatchLabel(
|
||||
match: { team_home: string; team_away: string; match_datetime: string },
|
||||
now: Date,
|
||||
): string {
|
||||
return `${match.team_home}-${match.team_away} (${formatMatchCountdown(match.match_datetime, now)})`
|
||||
}
|
||||
13
src/utils/phone.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { toEnglishDigits, toPersianDigits } from './formatNumber'
|
||||
|
||||
export function toE164(localPhone: string): string {
|
||||
const digitsOnly = toEnglishDigits(localPhone).replace(/\D/g, '')
|
||||
const withoutLeadingZero = digitsOnly.replace(/^0+/, '')
|
||||
return `+98${withoutLeadingZero}`
|
||||
}
|
||||
|
||||
export function fromE164(e164Phone: string): string {
|
||||
if (!e164Phone) return ''
|
||||
const local = e164Phone.startsWith('+98') ? `0${e164Phone.slice(3)}` : e164Phone
|
||||
return toPersianDigits(local)
|
||||
}
|
||||
12
src/utils/role.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export type UserRole = 'customer' | 'cafe_owner'
|
||||
|
||||
const ROLE_STORAGE_KEY = 'role'
|
||||
|
||||
// set by Login.tsx based on whether the user entered via /login or /cafe/login
|
||||
export function setUserRole(role: UserRole) {
|
||||
localStorage.setItem(ROLE_STORAGE_KEY, role)
|
||||
}
|
||||
|
||||
export function getUserRole(): UserRole {
|
||||
return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer'
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
|
|
|||