feature/WNPT-176 #1

Merged
afra.khalaj merged 11 commits from feature/WNPT-176 into master 2026-06-24 07:41:29 -04:00
61 changed files with 3643 additions and 179 deletions

5
.env.example Normal file
View 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
View 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
View file

@ -12,6 +12,7 @@ dist
.pnpm-store
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View file

@ -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,
],

View file

@ -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" />

View file

@ -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
View file

@ -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:

View file

@ -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>
)
}

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="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
View 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
View 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
View file

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

After

Width:  |  Height:  |  Size: 1.3 KiB

5
src/assets/edit-2.svg Normal file
View 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
View file

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

After

Width:  |  Height:  |  Size: 435 B

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

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

After

Width:  |  Height:  |  Size: 595 B

5
src/assets/map.svg Normal file
View 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
View file

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

After

Width:  |  Height:  |  Size: 248 B

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

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

After

Width:  |  Height:  |  Size: 801 B

View file

@ -0,0 +1,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

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="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

View 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
View 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

View 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>
)
}

View file

@ -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)
}

View file

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

View file

@ -0,0 +1,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}</>
}

View file

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

View 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>
)
}

View file

@ -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') {
// permission already granted
navigator.permissions
.query({ name: "geolocation" })
.then((status) => {
if (status.state === "granted") {
// permission already granted
performGetPosition();
} else if (status.state === "denied") {
// permission denied
setLoading(false);
setError("permission_denied");
setLocation(null);
} else {
// prompt
performGetPosition();
}
})
.catch(() => {
// fallback to direct request
performGetPosition();
} else if (status.state === 'denied') {
// permission denied
setLoading(false);
setError('permission_denied');
setLocation(null);
} else {
// prompt
performGetPosition();
}
}).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 }}>

View 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);

View 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
View file

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

View 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: 'بی‌بای',
},
]

View 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: '۰۹۱۲۳۴۵۶۷۸۹' },
]

View 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
View 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
}

View file

@ -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)

View file

@ -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
View 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>
)
}

View 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
View 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="&copy; 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>
);
}

View 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>
)
}

View 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="&copy; 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>
);
}

View 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>
)
}

View 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>
);
}

View file

@ -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
View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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,
})

View 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
View 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
View 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'
}

View file

@ -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()],
})