feature/WNPT-247 #2

Merged
afra.khalaj merged 7 commits from feature/WNPT-247 into master 2026-07-11 10:41:42 -04:00
71 changed files with 1632 additions and 770 deletions

View file

@ -37,6 +37,7 @@
"tailwindcss": "^4.3.1",
"typescript": "^5.6.2",
"typescript-eslint": "^8.61.1",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vite-plugin-svgr": "^5.2.0"
}
}

327
pnpm-lock.yaml generated
View file

@ -81,6 +81,9 @@ importers:
vite:
specifier: ^8.0.12
version: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
vite-plugin-svgr:
specifier: ^5.2.0
version: 5.2.0(typescript@5.9.3)(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))
packages:
@ -349,6 +352,83 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-remove-jsx-attribute@8.0.0':
resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0':
resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0':
resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-svg-dynamic-title@8.0.0':
resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-svg-em-dimensions@8.0.0':
resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-transform-react-native-svg@8.1.0':
resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-transform-svg-component@8.0.0':
resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==}
engines: {node: '>=12'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-preset@8.1.0':
resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/core@8.1.0':
resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==}
engines: {node: '>=14'}
'@svgr/hast-util-to-babel-ast@8.0.0':
resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==}
engines: {node: '>=14'}
'@svgr/plugin-jsx@8.1.0':
resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==}
engines: {node: '>=14'}
peerDependencies:
'@svgr/core': '*'
'@tailwindcss/node@4.3.1':
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
@ -569,6 +649,9 @@ packages:
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@ -604,6 +687,14 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
@ -618,6 +709,15 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cosmiconfig@8.3.6:
resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
peerDependenciesMeta:
typescript:
optional: true
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -645,6 +745,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -656,6 +759,13 @@ packages:
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@ -729,6 +839,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@ -847,10 +960,17 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -869,6 +989,10 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@ -877,6 +1001,9 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@ -972,10 +1099,16 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -1009,6 +1142,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
@ -1025,6 +1161,14 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -1033,6 +1177,10 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1092,6 +1240,10 @@ packages:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1120,10 +1272,16 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
tailwindcss@4.3.1:
resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
@ -1172,6 +1330,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
vite-plugin-svgr@5.2.0:
resolution: {integrity: sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==}
peerDependencies:
vite: '>=3.0.0'
vite@8.0.16:
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1493,6 +1656,82 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@rollup/pluginutils@5.4.0':
dependencies:
'@types/estree': 1.0.9
estree-walker: 2.0.2
picomatch: 4.0.4
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-preset@8.1.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.7)
'@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.7)
'@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.7)
'@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.7)
'@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.7)
'@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.7)
'@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.7)
'@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.7)
'@svgr/core@8.1.0(typescript@5.9.3)':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-preset': 8.1.0(@babel/core@7.29.7)
camelcase: 6.3.0
cosmiconfig: 8.3.6(typescript@5.9.3)
snake-case: 3.0.4
transitivePeerDependencies:
- supports-color
- typescript
'@svgr/hast-util-to-babel-ast@8.0.0':
dependencies:
'@babel/types': 7.29.7
entities: 4.5.0
'@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.7
'@svgr/babel-preset': 8.1.0(@babel/core@7.29.7)
'@svgr/core': 8.1.0(typescript@5.9.3)
'@svgr/hast-util-to-babel-ast': 8.0.0
svg-parser: 2.0.4
transitivePeerDependencies:
- supports-color
'@tailwindcss/node@4.3.1':
dependencies:
'@jridgewell/remapping': 2.3.5
@ -1712,6 +1951,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
argparse@2.0.1: {}
asynckit@0.4.0: {}
autoprefixer@10.5.0(postcss@8.5.15):
@ -1754,6 +1995,10 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
camelcase@6.3.0: {}
caniuse-lite@1.0.30001799: {}
combined-stream@1.0.8:
@ -1764,6 +2009,15 @@ snapshots:
cookie@1.1.1: {}
cosmiconfig@8.3.6(typescript@5.9.3):
dependencies:
import-fresh: 3.3.1
js-yaml: 4.3.0
parse-json: 5.2.0
path-type: 4.0.0
optionalDependencies:
typescript: 5.9.3
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -1782,6 +2036,11 @@ snapshots:
detect-libc@2.1.2: {}
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
tslib: 2.8.1
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -1795,6 +2054,12 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@4.5.0: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@ -1893,6 +2158,8 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@2.0.2: {}
esutils@2.0.3: {}
fast-deep-equal@3.1.3: {}
@ -1995,8 +2262,15 @@ snapshots:
ignore@7.0.5: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
resolve-from: 4.0.0
imurmurhash@0.1.4: {}
is-arrayish@0.2.1: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
@ -2009,10 +2283,16 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
@ -2079,10 +2359,16 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lines-and-columns@1.2.4: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
lower-case@2.0.2:
dependencies:
tslib: 2.8.1
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -2109,6 +2395,11 @@ snapshots:
natural-compare@1.4.0: {}
no-case@3.0.4:
dependencies:
lower-case: 2.0.2
tslib: 2.8.1
node-releases@2.0.47: {}
optionator@0.9.4:
@ -2128,10 +2419,23 @@ snapshots:
dependencies:
p-limit: 3.1.0
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.29.7
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-type@4.0.0: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@ -2178,6 +2482,8 @@ snapshots:
react@19.2.7: {}
resolve-from@4.0.0: {}
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@ -2213,8 +2519,15 @@ snapshots:
shebang-regex@3.0.0: {}
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
tslib: 2.8.1
source-map-js@1.2.1: {}
svg-parser@2.0.4: {}
tailwindcss@4.3.1: {}
tapable@2.3.3: {}
@ -2228,8 +2541,7 @@ snapshots:
dependencies:
typescript: 5.9.3
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
@ -2260,6 +2572,17 @@ snapshots:
dependencies:
punycode: 2.3.1
vite-plugin-svgr@5.2.0(typescript@5.9.3)(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0)):
dependencies:
'@rollup/pluginutils': 5.4.0
'@svgr/core': 8.1.0(typescript@5.9.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))
vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0):
dependencies:
lightningcss: 1.32.0

BIN
public/main-banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

View file

@ -5,13 +5,16 @@ import CafeDetail from './pages/CafeDetail'
import PaymentSuccess from './pages/PaymentSuccess'
import CafeOwnerRegister from './pages/CafeOwnerRegister'
import CafeRegisterSuccess from './pages/CafeRegisterSuccess'
import CafeRegisterFail from './pages/CafeRegisterFail'
import CafeOwnerPanel from './pages/CafeOwnerPanel'
import AddEvent from './pages/AddEvent'
import AddEventSuccess from './pages/AddEventSuccess'
import AddEventFail from './pages/AddEventFail'
import EventReservations from './pages/EventReservations'
import MyReservations from './pages/MyReservations'
import Login from './pages/Login'
import LoginOtp from './pages/LoginOtp'
import NotFound from './pages/NotFound'
import RequireRole from './components/RequireRole'
function ScrollToTop() {
@ -36,13 +39,16 @@ export default function App() {
<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/register/fail" element={<RequireRole role="cafe_owner"><CafeRegisterFail /></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/fail" element={<RequireRole role="cafe_owner"><AddEventFail /></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 />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,75 @@
/* Kalameh FaNum — the "FaNum" variant renders Persian digit glyphs (۰-۹) correctly within
the font's own numeral slots, unlike the plain Kalameh set. Scoped to its own
--font-banner token (see index.css) rather than replacing the site-wide Vazirmatn font. */
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-THIN.TTF') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-EXTRALIGHT.TTF') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-LIGHT.TTF') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-REGULAR.TTF') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-MEDIUM.TTF') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-SEMIBOLD.TTF') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-BOLD.TTF') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-EXTRABOLD.TTF') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kalameh FaNum';
src: url('./KALAMEH(FANUM)-BLACK.TTF') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}

View file

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,3 +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"/>
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 321 B

View file

@ -1,6 +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"/>
<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="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5.49023V20.4902" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.75 8.49023H5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 11.4902H5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1,013 B

After

Width:  |  Height:  |  Size: 1 KiB

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="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 2V5" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 9.08984H20.5" stroke="currentColor" 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="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.6937 13.6992H15.7027" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.6937 16.6992H15.7027" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.9945 13.6992H12.0035" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.9945 16.6992H12.0035" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.29529 13.6992H8.30427" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.29529 16.6992H8.30427" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,3 +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"/>
<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="currentColor" stroke-width="1.5" stroke-miterlimit="10"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 6.5L4.5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 226 B

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="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

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 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 216 B

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="M9 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View file

@ -1,5 +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"/>
<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="currentColor" 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="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 22H21" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

After

Width:  |  Height:  |  Size: 868 B

View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.9171 74.4333L74.1559 38.1342C76.724 35.5615 80.2279 34.9239 82.012 36.7033C83.7943 38.4839 83.1619 41.9888 80.5938 44.5622L44.3551 80.8595C41.7863 83.433 38.2831 84.0709 36.499 82.2904C34.716 80.5109 35.3483 77.006 37.9171 74.4333Z" fill="#C23B3B"/>
<path d="M80.8379 74.4333L44.5996 38.1342C42.0304 35.5615 38.5265 34.9239 36.7431 36.7033C34.9601 38.4839 35.5924 41.9888 38.1616 44.5622L74.4 80.8595C76.9681 83.433 80.472 84.0709 82.2554 82.2904C84.0384 80.5109 83.4061 77.006 80.8379 74.4333Z" fill="#C23B3B"/>
<path d="M59.5 0C43.7197 0 28.5856 6.26821 17.4278 17.4267C6.26891 28.5856 0 43.719 0 59.5C0 75.2803 6.26891 90.4144 17.4278 101.572C28.5856 112.731 43.7197 118.999 59.5 118.999C75.281 118.999 90.4144 112.73 101.572 101.572C112.731 90.4134 119 75.2803 119 59.5C119 43.7197 112.731 28.5856 101.572 17.4267C90.4144 6.26891 75.281 0 59.5 0ZM59.5 9.34303C72.8025 9.34303 85.5598 14.6275 94.9663 24.0337C104.373 33.4402 109.657 46.1968 109.657 59.5C109.657 72.8014 104.373 85.5591 94.9663 94.9653C85.5598 104.371 72.8025 109.656 59.5 109.656C46.1975 109.656 33.4409 104.371 24.0347 94.9653C14.6285 85.5591 9.34409 72.8014 9.34409 59.5C9.34409 46.1968 14.6285 33.4402 24.0347 24.0337C33.4409 14.6275 46.1975 9.34303 59.5 9.34303Z" fill="#C23B3B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,3 +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"/>
<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="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 435 B

After

Width:  |  Height:  |  Size: 440 B

View file

@ -1,4 +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"/>
<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="currentColor" 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="currentColor" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 595 B

After

Width:  |  Height:  |  Size: 605 B

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="M3.33398 8H12.6673" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

View file

@ -1,3 +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"/>
<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>

Before

Width:  |  Height:  |  Size: 801 B

After

Width:  |  Height:  |  Size: 806 B

View file

@ -1,5 +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"/>
<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="currentColor" 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="currentColor" 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="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1,015 B

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,4 +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"/>
<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="currentColor" stroke-width="1.3835" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 22L20 20" stroke="currentColor" stroke-width="1.3835" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 436 B

After

Width:  |  Height:  |  Size: 446 B

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

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

Before

Width:  |  Height:  |  Size: 248 B

View file

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

@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { fetchMatchEvents, MatchEvent } from "../services/campaignApi";
import { useNow } from "../hooks/useNow";
import {
formatIranJalaliHeading,
formatIranKickoffTime,
isSameIranDay,
} from "../utils/iranDate";
export default function HomeBanner() {
const [matches, setMatches] = useState<MatchEvent[]>([]);
const now = useNow();
useEffect(() => {
fetchMatchEvents()
.then(setMatches)
.catch(() => {});
}, []);
// "today" means the Iran calendar day, regardless of the visiting device's own timezone —
// and unlike the match selectors elsewhere, this intentionally includes matches that
// already kicked off earlier today, since it's a poster of the day's full schedule
const todayMatches = matches
.filter((match) => isSameIranDay(new Date(match.match_datetime), now))
.sort(
(a, b) =>
new Date(a.match_datetime).getTime() -
new Date(b.match_datetime).getTime(),
);
return (
<div
className="relative h-[35vh] w-full bg-gray-200 bg-cover bg-center"
style={{ backgroundImage: "url('/main-banner.jpg')" }}
>
{/* darkens the right-hand side only, where the text/match list sits — keeps it
legible no matter what the underlying photo looks like there */}
<div className="absolute inset-0 bg-gradient-to-l from-black/45 to-transparent" />
{/* pinned to the image's right edge with a fixed width — not just text-aligned within
a full-width box — so every piece (title, date, match pills) stays on the right side
of the banner regardless of viewport width */}
<div className="absolute inset-y-0 right-0 flex w-[60%] flex-col justify-center gap-9 px-4 text-right font-banner">
<div className="w-full">
<h2 className="mb-3 text-4xl font-extrabold text-white">
بازی‌های امروز
</h2>
<p className="mb-3 w-full text-right text-4lg font-light text-[#FF7A1A]">
{formatIranJalaliHeading(now)}
</p>
<div className="w-full border-b border-white" />
</div>
{todayMatches.length > 0 && (
<div className="flex max-h-[60%] flex-col items-end gap-2 overflow-y-auto">
{todayMatches.map((match) => (
<div
key={match.uuid}
className="flex w-full items-center justify-end gap-2"
>
<span className="truncate rounded-full bg-[#FF7A1A]/20 px-3 py-1 text-sm text-white">
{match.team_home} {" "} {match.team_away}
</span>
<span className="w-20 shrink-0 rounded-full bg-[#FF7A1A] px-3 py-1 text-center text-sm font-semibold text-white">
{formatIranKickoffTime(match.match_datetime)}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { memo, type CSSProperties, type SVGProps } from 'react'
import { icons } from './icons'
// `keyof typeof icons` derives the union straight from icons.ts's keys — add/remove/rename
// a key there and `name`'s autocomplete (and every existing <Icon name="..."> call site,
// via a type error) updates with it. No second list of names to keep in sync.
export type IconName = keyof typeof icons
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'color'> {
/** Which icon to render. Autocompletes from icons.ts. */
name: IconName
/** Sets width and height together. Ignored for either one passed explicitly. Defaults to 24. */
size?: number | string
/**
* Recolors the icon by setting the CSS `color` property, which every icon in icons.ts
* paints with via `stroke="currentColor"`/`fill="currentColor"` — EXCEPT `success` and
* `fail`, whose fills are hardcoded brand colors (green/red) by design and won't move.
*
* Left undefined (the default) on purpose, rather than defaulting it to the literal
* string `'currentColor'`: an inline `style.color` always beats a `className` text-color
* utility (e.g. Tailwind's `text-gray-400`) regardless of specificity, since inline
* styles win outright. Only setting `style.color` when the caller actually passes
* `color` lets `className`-based coloring keep working untouched, while still landing on
* `currentColor` at rest — which is what "inherit the ambient text color" already means.
*/
color?: string
}
function IconImpl({ name, size = 24, width, height, color, style, ...rest }: IconProps) {
const Svg = icons[name]
const mergedStyle: CSSProperties | undefined = color ? { color, ...style } : style
// Decorative by default (aria-hidden) — the WAI-ARIA "img" pattern only applies once
// the icon carries its own meaning, signaled here by the caller passing `aria-label`.
// Spreading `...rest` last lets an explicit `role`/`aria-hidden`/etc. from the caller
// override these computed defaults.
const a11yProps = rest['aria-label'] ? { role: 'img' as const } : { 'aria-hidden': true as const }
return (
<Svg
width={width ?? size}
height={height ?? size}
// A bare <svg> isn't part of the tab order in any current browser, but old
// IE/Edge would focus one — `focusable="false"` is the standard belt-and-suspenders
// fix. Every clickable icon in this app is already wrapped in a real <button> by its
// caller (that's how keyboard/Enter-activation and the native click semantics work),
// so Icon itself stays a plain graphic and never adds its own tabIndex/role="button".
focusable="false"
style={mergedStyle}
{...a11yProps}
{...rest}
/>
)
}
// Most call sites pass the same `name`/`className` on every render, so memo skips
// re-rendering (and re-diffing the SVG's child <path> nodes) when a parent re-renders
// for unrelated state — icons are leaf nodes with no children of their own to bail out on.
const Icon = memo(IconImpl)
Icon.displayName = 'Icon'
export default Icon

View file

@ -0,0 +1,47 @@
// Each icon is a plain .svg file under src/assets/icons/, imported with the `?react`
// suffix so vite-plugin-svgr compiles it into a React component at build time instead
// of shipping the raw markup as a string or a separate network request. Every import
// below is static (no `import()`/glob magic), so Vite/Rollup can see exactly which
// icons are referenced and tree-shake the rest — this file is the *only* place all 17
// icons are ever imported, so an icon that no call site uses never makes it into a chunk.
import ArrowRight from '../../assets/icons/arrow-right.svg?react'
import Book from '../../assets/icons/book.svg?react'
import Calendar from '../../assets/icons/calendar.svg?react'
import Call from '../../assets/icons/call.svg?react'
import Check from '../../assets/icons/check.svg?react'
import ChevronDown from '../../assets/icons/chevron-down.svg?react'
import ChevronLeft from '../../assets/icons/chevron-left.svg?react'
import ChevronRight from '../../assets/icons/chevron-right.svg?react'
import Edit from '../../assets/icons/edit.svg?react'
import Fail from '../../assets/icons/fail.svg?react'
import Info from '../../assets/icons/info.svg?react'
import Location from '../../assets/icons/location.svg?react'
import Minus from '../../assets/icons/minus.svg?react'
import Plus from '../../assets/icons/plus.svg?react'
import ProfileCircle from '../../assets/icons/profile-circle.svg?react'
import Search from '../../assets/icons/search.svg?react'
import Success from '../../assets/icons/success.svg?react'
// The single source of truth for available icons — keys are what callers pass to
// `<Icon name="..." />`. `IconName` (in Icon.tsx) is `keyof typeof icons`, so adding or
// renaming a key here immediately updates autocomplete/type-checking everywhere `Icon`
// is used, with no second list to keep in sync.
export const icons = {
arrowRight: ArrowRight,
book: Book,
calendar: Calendar,
call: Call,
check: Check,
chevronDown: ChevronDown,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
edit: Edit,
fail: Fail,
info: Info,
location: Location,
minus: Minus,
plus: Plus,
profileCircle: ProfileCircle,
search: Search,
success: Success,
} as const

View file

@ -0,0 +1,2 @@
export { default } from './Icon'
export type { IconName, IconProps } from './Icon'

View file

@ -1,15 +0,0 @@
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,30 @@
import { createPortal } from 'react-dom'
import Icon from './Icon'
interface PaymentFailModalProps {
onGoHome: () => void
}
// covers both a gateway-reported payment failure and a payment that succeeded but
// whose reservation couldn't be created — same message, same recovery action either way
export default function PaymentFailModal({ onGoHome }: PaymentFailModalProps) {
return createPortal(
<>
<div className="fixed inset-0 z-1100 bg-black/70" />
<div className="fixed inset-0 z-1100 flex items-center justify-center p-4">
<div className="w-full max-w-sm rounded-3xl bg-white p-6 text-center shadow-lg">
<Icon name="fail" className="mx-auto h-28 w-28" />
<p className="mt-6 text-gray-900">رزرو با خطا مواجه شد. لطفا دوباره تلاش کنید.</p>
<button
type="button"
onClick={onGoHome}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
بازگشت به صفحه اصلی
</button>
</div>
</div>
</>,
document.body,
)
}

View file

@ -8,7 +8,7 @@ import { formatPersianNumber } from "../utils/formatNumber";
import { toE164 } from "../utils/phone";
import { setPendingReservation } from "../utils/pendingReservation";
import { getUserPhone } from "../utils/userPhone";
import InfoIcon from "./InfoIcon";
import Icon from "./Icon";
interface ReservationModalProps {
isOpen: boolean;
@ -22,25 +22,6 @@ interface ReservationModalProps {
// these are fixed test wallet ids given directly for now, replace once that exists
const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
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,
@ -67,7 +48,7 @@ function PaymentRow({
{label}
</span>
{infoIconParam && (
<InfoIcon className="h-5 w-5 shrink-0 text-[#376195]" />
<Icon name="info" className="h-5 w-5 shrink-0 text-[#376195]" />
)}
</div>
<span
@ -211,7 +192,7 @@ export default function ReservationModal({
: "text-[#223D5D]"
}`}
>
<MinusIcon className="h-4 w-4" />
<Icon name="minus" className="h-4 w-4" />
</button>
<span dir="rtl" className="text-center font-medium text-gray-900">
{formatPersianNumber(count)} نفر
@ -221,7 +202,7 @@ export default function ReservationModal({
onClick={() => setCount((prev) => prev + 1)}
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
>
<PlusIcon className="h-4 w-4" />
<Icon name="plus" className="h-4 w-4" />
</button>
</div>
</div>
@ -246,7 +227,7 @@ export default function ReservationModal({
/>
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-500">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده
کاربر است.

View file

@ -1,14 +0,0 @@
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,8 +1,10 @@
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap');
@import 'tailwindcss';
@import './assets/banner-fonts/banner-fonts.css';
@theme {
--font-sans: 'Vazirmatn', system-ui, 'Segoe UI', Roboto, sans-serif;
--font-banner: 'Kalameh FaNum', var(--font-sans);
}
html,

View file

@ -5,6 +5,8 @@ import { createCafeSchedule, MatchEvent } from '../services/campaignApi'
import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown'
import { getCafeUuid } from '../utils/cafe'
import Icon from '../components/Icon'
import HostBanner from '../assets/images/banners/Host-banner.jpg'
const ENTRY_FEE_STEP = 10000
const DESCRIPTION_MAX_LENGTH = 200
@ -14,41 +16,6 @@ function toEnglishDigits(value: string) {
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,
@ -62,22 +29,28 @@ function MatchSelect({
}) {
const [open, setOpen] = useState(false)
const selectedMatch = options.find((match) => match.uuid === value)
const hasMatches = options.length > 0
return (
<div className="relative mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">بازی</label>
<button
type="button"
disabled={!hasMatches}
onClick={() => setOpen((o) => !o)}
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right disabled:opacity-50"
>
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'}
{selectedMatch
? formatMatchLabel(selectedMatch, now)
: hasMatches
? 'بازی مورد نظر را انتخاب کنید'
: 'بازی فعالی موجود نیست'}
</span>
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
<Icon name="chevronDown" className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
{open && hasMatches && (
<>
<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">
@ -139,7 +112,7 @@ function Stepper({
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" />
<Icon name="minus" className="h-4 w-4" />
</button>
{editable ? (
@ -164,7 +137,7 @@ function Stepper({
)}
<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" />
<Icon name="plus" className="h-4 w-4" />
</button>
</div>
)
@ -198,10 +171,8 @@ export default function AddEvent() {
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() ?? '', {
@ -212,7 +183,7 @@ export default function AddEvent() {
})
navigate('/owner/events/success')
} catch {
setError('ثبت رویداد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
navigate('/owner/events/fail')
} finally {
setIsSubmitting(false)
}
@ -220,10 +191,10 @@ export default function AddEvent() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<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" />
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
<span className="text-lg font-bold text-gray-900">افزودن ایونت</span>
</button>
@ -241,8 +212,6 @@ export default function AddEvent() {
</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">
@ -253,7 +222,7 @@ export default function AddEvent() {
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" />}
{!isSubmitting && <Icon name="arrowRight" className="h-4 w-4" />}
</button>
</div>
</div>

View file

@ -0,0 +1,33 @@
import { useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
export default function AddEventFail() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col bg-white pb-32">
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<Icon name="fail" className="mx-auto h-28 w-28" />
<h1 className="mt-6 text-xl font-light text-gray-900">ثبت ایونت با خطا مواجه شد. لطفا دوباره تلاش کنید.</h1>
</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

@ -1,15 +1,14 @@
import { useNavigate } from 'react-router-dom'
import SuccessIcon from '../components/SuccessIcon'
import Icon from '../components/Icon'
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 />
<Icon name="success" className="mx-auto h-28 w-28" />
<h1 className="mt-6 text-xl font-bold text-gray-900">ایونت با موفقیت ثبت شد!</h1>
</div>

View file

@ -14,83 +14,9 @@ import { useNow } from "../hooks/useNow";
import { formatMatchLabel } from "../utils/matchCountdown";
import { formatPersianNumber } from "../utils/formatNumber";
import { fromE164 } from "../utils/phone";
import InfoIcon from "../components/InfoIcon";
import Icon from "../components/Icon";
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 ChevronDownIcon({ className = "" }: { className?: string }) {
return (
<svg
className={`h-4 w-4 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 MatchPicker({
value,
options,
@ -115,7 +41,7 @@ function MatchPicker({
className="flex items-center gap-1 text-right text-sm text-gray-500"
>
<span>{selected ? formatMatchLabel(selected.match_event, now) : "بازی را انتخاب کنید"}</span>
<ChevronDownIcon className={open ? "rotate-180" : ""} />
<Icon name="chevronDown" className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
@ -170,7 +96,7 @@ function ImageCarousel({ images }: { images: string[] }) {
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 />
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
</button>
<button
type="button"
@ -178,7 +104,7 @@ function ImageCarousel({ images }: { images: string[] }) {
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 />
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
</button>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
{Array.from({ length: slideCount }).map((_, i) => (
@ -261,7 +187,7 @@ export default function CafeDetail() {
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 />
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
</button>
</div>
@ -316,7 +242,7 @@ export default function CafeDetail() {
<div className="mt-4 border border-gray-200 rounded-2xl p-4">
<div className="flex items-center justify-start gap-4 py-3">
<PhoneIcon />
<Icon name="call" className="h-5 w-5 shrink-0 text-gray-500" />
<div className="flex flex-col items-start gap-1">
<p>شماره تماس</p>
<a href={`tel:${cafe.phone_number}`} className="text-gray-900">
@ -325,7 +251,7 @@ export default function CafeDetail() {
</div>
</div>
<div className="flex items-center justify-start gap-4 py-3">
<MapPinIcon />
<Icon name="location" className="h-5 w-5 shrink-0 text-gray-500" />
<div className="flex flex-col items-start gap-1">
<p>آدرس</p>
<span className="text-gray-900">{cafe.address}</span>
@ -342,7 +268,7 @@ export default function CafeDetail() {
</div>
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-600">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده
کاربر است.

View file

@ -1,47 +1,13 @@
import { ReactNode, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import BookIcon from '../components/BookIcon'
import Icon from '../components/Icon'
import { useNow } from '../hooks/useNow'
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
import { getCafeUuid } from '../utils/cafe'
import HostBanner from '../assets/images/banners/Host-banner.jpg'
import { formatPersianNumber } from '../utils/formatNumber'
import { formatMatchLabel } from '../utils/matchCountdown'
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">
@ -49,7 +15,7 @@ function PanelRow({ icon, label, onClick }: { icon: ReactNode; label: string; on
{icon}
<span className="text-gray-900">{label}</span>
</div>
<ChevronIcon />
<Icon name="arrowRight" className="h-5 w-5 text-gray-400" />
</button>
)
}
@ -69,18 +35,18 @@ export default function CafeOwnerPanel() {
return (
<div className="min-h-screen bg-white pb-20">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<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 />}
icon={<Icon name="profileCircle" className="h-5 w-5 text-[#4D4D4D]" />}
label="اطلاعات کافه"
onClick={() => navigate('/owner/register', { state: { step: 2 } })}
/>
<PanelRow icon={<BookIcon />} label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} />
<PanelRow icon={<Icon name="book" className="h-5 w-5 text-[#4D4D4D]" />} label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} />
</div>
<h2 className="mt-6 text-right font-semibold text-gray-900">رویدادهای فعال شما</h2>
@ -108,7 +74,7 @@ export default function CafeOwnerPanel() {
</div>
<div>
<PanelRow
icon={<CalendarIcon />}
icon={<Icon name="calendar" className="h-5 w-5 text-[#4D4D4D]" />}
label="مشاهده رزروها"
onClick={() =>
navigate(`/owner/events/${schedule.uuid}/reservations`, {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
import { useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
export default function CafeRegisterFail() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col bg-white pb-32">
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<Icon name="fail" className="mx-auto h-28 w-28" />
<h1 className="mt-6 text-xl font-light text-gray-900">ثبت کافه با خطا مواجه شد. لطفا دوباره تلاش کنید.</h1>
</div>
<div className="fixed inset-x-0 bottom-0 flex flex-col gap-3 bg-white px-4 py-4">
{/* no "مشاهده پنل" option here, unlike the other fail pages — registration never
completed, so there's no cafe yet for the panel or "add event" flow to operate on */}
<button
type="button"
onClick={() => navigate('/owner/register')}
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
تلاش دوباره
</button>
</div>
</div>
)
}

View file

@ -1,15 +1,14 @@
import { useNavigate } from 'react-router-dom'
import SuccessIcon from '../components/SuccessIcon'
import Icon from '../components/Icon'
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 />
<Icon name="success" className="mx-auto h-28 w-28" />
<h1 className="mt-6 text-xl font-bold text-gray-900">کافه شما با موفقیت ثبت شد!</h1>
<p className="mt-3 leading-relaxed text-gray-500">
با اضافه کردن ایونت بازی‌ها، کافه شما در کمپین نمایش داده می‌شود و کاربران می‌توانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند.

View file

@ -1,59 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import Icon from "../components/Icon";
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
import { EventReservation, fetchEventReservations } from "../services/campaignApi";
import { getCafeUuid } from "../utils/cafe";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
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) =>
@ -120,7 +73,7 @@ function ReservationCard({
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 />}
{selected && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
</div>
);
@ -172,14 +125,14 @@ export default function EventReservations() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<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" />
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
<span className="text-md font-semibold text-gray-900">
رزروهای {event?.matchLabel ?? ""}
</span>
@ -193,7 +146,10 @@ export default function EventReservations() {
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]" />
<Icon
name="search"
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">

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import BookIcon from '../components/BookIcon'
import Icon from '../components/Icon'
import HomeBanner from '../components/HomeBanner'
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown'
@ -10,28 +11,6 @@ function findTehran(cities: City[]): City | undefined {
return cities.find((c) => c.city_name === 'تهران' || c.city_name.toLowerCase() === 'tehran')
}
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>
)
}
// MapIcon/ListIcon — only used by the disabled نقشه/لیست toggle above, commented out together with it
// function MapIcon() {
// return (
@ -62,13 +41,15 @@ function ChevronDownIcon({ className = '' }: { className?: string }) {
// )
// }
function MapPlaceholder() {
return (
<div className="w-full h-[35vh] bg-gray-200 flex items-center justify-center text-gray-500">
بنر
</div>
)
}
// MapPlaceholder — only used by the disabled نقشه/لیست toggle below (the cafes-on-a-map
// view isn't built yet); the top-of-page بنر slot it used to fill is now HomeBanner.
// function MapPlaceholder() {
// return (
// <div className="w-full h-[35vh] bg-gray-200 flex items-center justify-center text-gray-500">
// بنر
// </div>
// )
// }
function CitySelectorRow({
label,
@ -93,7 +74,10 @@ function CitySelectorRow({
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">{selectedCity?.city_name ?? ''}</span>
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
<Icon
name="chevronDown"
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
@ -135,20 +119,27 @@ function MatchSelectorRow({
}) {
const [open, setOpen] = useState(false)
const selectedMatch = options.find((match) => match.uuid === value)
const hasMatches = options.length > 0
return (
<div className="relative px-4 mt-4">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<button
type="button"
disabled={!hasMatches}
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
>
<span className="text-gray-900">{selectedMatch ? formatMatchLabel(selectedMatch, now) : ''}</span>
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
</span>
<Icon
name="chevronDown"
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
{open && hasMatches && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
@ -182,10 +173,10 @@ function MyReservationsRow() {
className="flex w-full items-center justify-between px-4 py-6"
>
<div className="flex items-center gap-2">
<BookIcon />
<Icon name="book" className="h-5 w-5 text-[#4D4D4D]" />
<span className="text-gray-900">رزروهای شما</span>
</div>
<ChevronIcon className="h-5 w-5 text-gray-400" />
<Icon name="arrowRight" className="h-5 w-5 text-gray-400" />
</button>
)
}
@ -246,7 +237,7 @@ export default function Home() {
return (
<div className="min-h-screen bg-white pb-24">
<MapPlaceholder />
<HomeBanner />
<MyReservationsRow />

View file

@ -1,26 +1,11 @@
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Icon from "../components/Icon";
import { requestOtp } from "../services/authApi";
import { toPersianDigits } from "../utils/formatNumber";
import { 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();
@ -52,7 +37,13 @@ export default function Login() {
};
return (
<div className="flex min-h-screen flex-col justify-center px-6">
<form
onSubmit={(e) => {
e.preventDefault();
if (canContinue) handleContinue();
}}
className="flex min-h-screen flex-col justify-center px-6"
>
<h1 className="text-right text-2xl font-bold text-gray-900">
ورود به وینسو
</h1>
@ -64,6 +55,7 @@ export default function Login() {
type="text"
dir="ltr"
inputMode="numeric"
enterKeyHint="go"
value={phone}
onChange={(e) => {
setPhone(toPersianDigits(e.target.value));
@ -76,9 +68,8 @@ export default function Login() {
{error && <p className="mt-2 text-right text-sm text-red-500">{error}</p>}
<button
type="button"
type="submit"
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 ? "در حال ارسال..." : "ادامه"}
@ -92,7 +83,7 @@ export default function Login() {
acceptedTerms ? "bg-[#223D5D]" : "bg-transparent"
}`}
>
{acceptedTerms && <CheckIcon />}
{acceptedTerms && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
<p className="text-sm text-gray-600">
<a href="https://winsoo.ir/terms/" className="text-[#223D5D] underline">
@ -101,6 +92,6 @@ export default function Login() {
را می‌پذیرم.
</p>
</div>
</div>
</form>
);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
import { requestOtp, verifyOtp } from '../services/authApi'
import { fetchMyCafes } from '../services/campaignApi'
import { storeAuthTokens } from '../utils/authToken'
@ -12,17 +13,9 @@ import { getUserRole, setUserRole, UserRole } 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>
)
// not yet in TS's DOM lib — minimal shape for the WebOTP API result
interface OTPCredential extends Credential {
code: string
}
function formatCountdown(seconds: number) {
@ -50,12 +43,50 @@ export default function LoginOtp() {
const [isResending, setIsResending] = useState(false)
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
// fills consecutive boxes starting at startIndex — used by both the WebOTP
// listener below and a multi-digit autofill landing in a single box (e.g. iOS's
// autocomplete="one-time-code" suggestion pastes the whole code into one input)
const fillOtp = (startIndex: number, rawValue: string) => {
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex)
if (!digits) return
setOtp((prev) => {
const next = [...prev]
digits.split('').forEach((d, i) => {
next[startIndex + i] = d
})
return next
})
setError('')
inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus()
}
useEffect(() => {
if (!phone) {
navigate(loginPath, { replace: true })
}
}, [phone, loginPath, navigate])
useEffect(() => {
inputRefs.current[0]?.focus()
}, [])
// Android/Chrome: WebOTP lets the browser read the code straight off an incoming
// SMS without the user opening the messaging app — only fires if the SMS body ends
// with a line like "@<this site's domain> #<code>", which the backend's SMS
// template doesn't currently send, so this is a no-op until that's added.
useEffect(() => {
if (!('OTPCredential' in window)) return
const abortController = new AbortController()
navigator.credentials
.get({ otp: { transport: ['sms'] }, signal: abortController.signal } as unknown as CredentialRequestOptions)
.then((credential) => {
const code = (credential as OTPCredential | null)?.code
if (code) fillOtp(0, code)
})
.catch(() => {})
return () => abortController.abort()
}, [])
useEffect(() => {
const timer = setInterval(() => {
setSecondsLeft((s) => {
@ -70,14 +101,19 @@ export default function LoginOtp() {
}, [resendCycle])
const handleOtpChange = (index: number, rawValue: string) => {
const digit = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(-1)
const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '')
// a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion
if (digits.length > 1) {
fillOtp(index, digits)
return
}
setOtp((prev) => {
const next = [...prev]
next[index] = digit
next[index] = digits
return next
})
setError('')
if (digit && index < OTP_LENGTH - 1) {
if (digits && index < OTP_LENGTH - 1) {
inputRefs.current[index + 1]?.focus()
}
}
@ -136,12 +172,18 @@ export default function LoginOtp() {
}
return (
<div className="flex min-h-screen flex-col justify-center px-6">
<form
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) handleSubmit()
}}
className="flex min-h-screen flex-col justify-center px-6"
>
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
<div className="mt-2 flex items-center justify-start gap-2">
<span className="text-[#223D5D]">{phone}</span>
<button type="button" onClick={() => navigate(loginPath, { state: { phone } })} aria-label="ویرایش شماره تماس">
<EditIcon className="h-5 w-5 text-[#223D5D]" />
<Icon name="edit" className="h-5 w-5 text-[#223D5D]" />
</button>
</div>
@ -154,7 +196,11 @@ export default function LoginOtp() {
}}
type="text"
inputMode="numeric"
maxLength={1}
autoComplete="one-time-code"
enterKeyHint="go"
// no maxLength — the browser would truncate a pasted/autofilled multi-digit
// code to 1 char before onChange ever sees it; handleOtpChange enforces the
// single-digit display for normal typing instead
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(index, e)}
@ -178,13 +224,12 @@ export default function LoginOtp() {
{error && <p className="mt-2 text-center text-sm text-red-500">{error}</p>}
<button
type="button"
type="submit"
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>
</form>
)
}

View file

@ -1,10 +1,10 @@
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 Icon from '../components/Icon'
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
function formatReservationDateTime(iso: string): string {
return new Intl.DateTimeFormat('fa-IR', {
@ -16,14 +16,6 @@ function formatReservationDateTime(iso: string): string {
}).format(new Date(iso))
}
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
@ -77,7 +69,7 @@ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () =
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" />
<Icon name="success" className="mx-auto h-24 w-24" />
<h2 className="mt-4 text-right font-semibold text-gray-900">جزئیات رزرو</h2>
{reservation && (
@ -93,7 +85,7 @@ function ReservationDetailModal({ uuid, onClose }: { uuid: string; onClose: () =
)}
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<InfoIcon />
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-500">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات ورود و کد دیگری برای دقایقی شما پیامک می‌شود.
</p>
@ -121,10 +113,10 @@ export default function MyReservations() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={GuestBanner} alt="" className="w-full" />
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
<ChevronIcon className="h-5 w-5 -scale-x-100 text-gray-900" />
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
<span className="text-lg font-bold text-gray-900">رزروهای شما</span>
</button>

23
src/pages/NotFound.tsx Normal file
View file

@ -0,0 +1,23 @@
import { useNavigate } from 'react-router-dom'
import { toPersianDigits } from '../utils/formatNumber'
import { getUserRole } from '../utils/role'
export default function NotFound() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white px-6 text-center">
<h1 className="text-6xl font-bold text-[#223D5D]">{toPersianDigits('404')}</h1>
<p className="mt-4 text-lg text-gray-900">صفحه مورد نظر یافت نشد.</p>
<button
type="button"
// owners and customers land on different home screens — mirrors the
// role-based redirect RequireRole already does on an auth mismatch
onClick={() => navigate(getUserRole() === 'cafe_owner' ? '/owner/panel' : '/home')}
className="mt-6 w-full max-w-xs rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
بازگشت به صفحه اصلی
</button>
</div>
)
}

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import InfoIcon from '../components/InfoIcon'
import SuccessIcon from '../components/SuccessIcon'
import Icon from '../components/Icon'
import PaymentFailModal from '../components/PaymentFailModal'
import { createReservation, ReservationResult } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import { fromE164 } from '../utils/phone'
@ -25,60 +25,63 @@ export default function PaymentSuccess() {
const [searchParams] = useSearchParams()
const isSuccess = searchParams.get('state') === SUCCESS_STATE
const [reservation, setReservation] = useState<ReservationResult | null>(null)
const [error, setError] = useState('')
const [hasError, setHasError] = useState(false)
// gateway-reported failure, or a successful payment whose reservation couldn't be created —
// both land on the same fail modal
const isFail = !isSuccess || hasError
useEffect(() => {
if (!isSuccess) return
const pending = takePendingReservation()
if (!pending) {
Promise.resolve().then(() => setError('اطلاعات رزرو یافت نشد.'))
Promise.resolve().then(() => setHasError(true))
return
}
createReservation(pending)
.then(setReservation)
.catch(() => setError('ثبت رزرو با خطا مواجه شد.'))
.catch(() => setHasError(true))
}, [isSuccess])
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 />
<div className="min-h-screen bg-gray-50">
{!isFail && (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm rounded-3xl bg-white p-6 shadow-lg">
<Icon name="success" className="mx-auto h-28 w-28" />
{isSuccess ? (
reservation ? (
<>
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
<DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
<DetailRow label="کد رزرو" value={reservation.reserve_code} />
</div>
{reservation ? (
<>
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
<div className="mt-2">
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
<DetailRow label="به نام" value={reservation.full_name} />
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
<DetailRow label="کد رزرو" value={reservation.reserve_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-600">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود.
</p>
</div>
</>
) : error ? (
<p className="mt-6 text-center text-gray-900">{error}</p>
) : (
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
)
) : (
<p className="mt-6 text-center text-gray-900">پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.</p>
)}
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-600">
کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود.
</p>
</div>
</>
) : (
<p className="mt-6 text-center text-gray-500">در حال ثبت رزرو...</p>
)}
<button
type="button"
onClick={() => navigate('/home')}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
صفحه اصلی
</button>
</div>
<button
type="button"
onClick={() => navigate('/home')}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white"
>
صفحه اصلی
</button>
</div>
</div>
)}
{isFail && <PaymentFailModal onGoHome={() => navigate('/home')} />}
</div>
)
}

View file

@ -61,6 +61,11 @@ export interface GeoPoint {
coordinates: [number, number]
}
export interface CafeMediaItem {
object_key: string
type: 'image' | 'document'
}
export interface RegisterCafePayload {
owner_uuid: string
city_uuid: string
@ -69,6 +74,7 @@ export interface RegisterCafePayload {
location: GeoPoint
address: string
feature_uuids: string[]
media: CafeMediaItem[]
}
export interface CafeResponse extends RegisterCafePayload {
@ -88,6 +94,7 @@ export interface UpdateCafePayload {
location: GeoPoint
address: string
feature_uuids: string[]
media: CafeMediaItem[]
}
export async function updateCafe(cafeUuid: string, payload: UpdateCafePayload): Promise<CafeResponse> {
@ -253,3 +260,13 @@ export async function createReservation(payload: CreateReservationPayload): Prom
const { data } = await api.post<ReservationResult>('/campaign/reservations/', payload)
return data
}
export interface PresignResponse {
upload_url: string
object_key: string
}
export async function presignMedia(filename: string): Promise<PresignResponse> {
const { data } = await api.post<PresignResponse>('/campaign/media/presign/', { filename })
return data
}

68
src/utils/iranDate.ts Normal file
View file

@ -0,0 +1,68 @@
import { toPersianDigits } from './formatNumber'
// the campaign only runs in Iran, and "today" for match scheduling always means the
// Iran calendar day — independent of whatever timezone the visiting browser is in
export const IRAN_TIME_ZONE = 'Asia/Tehran'
const PERSIAN_MONTHS = [
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
]
// ordinal day-of-month words (index 0 = day 1), matching the "هفتم تیر" style of the
// reference banner rather than the numeral "۷ تیر" the Persian Intl calendar gives directly
const PERSIAN_DAY_ORDINALS = [
'یکم', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم', 'هفتم', 'هشتم', 'نهم', 'دهم',
'یازدهم', 'دوازدهم', 'سیزدهم', 'چهاردهم', 'پانزدهم', 'شانزدهم', 'هفدهم', 'هجدهم', 'نوزدهم', 'بیستم',
'بیست و یکم', 'بیست و دوم', 'بیست و سوم', 'بیست و چهارم', 'بیست و پنجم',
'بیست و ششم', 'بیست و هفتم', 'بیست و هشتم', 'بیست و نهم', 'سی‌ام', 'سی و یکم',
]
function getIranJalaliParts(date: Date) {
// `en-US-u-ca-persian` returns the Jalali year/month/day as plain Latin-digit parts —
// unlike `fa-IR`, which mixes in Persian digits and locale month names that would need
// to be parsed back out. Easier to read the numbers here and do our own Persian
// formatting (the ordinal words above) afterwards.
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', {
timeZone: IRAN_TIME_ZONE,
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(date)
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
return { year: get('year'), month: get('month'), day: get('day') }
}
// e.g. "هفتم تیر ۱۴۰۵"
export function formatIranJalaliHeading(date: Date): string {
const { year, month, day } = getIranJalaliParts(date)
const dayWord = PERSIAN_DAY_ORDINALS[day - 1] ?? toPersianDigits(String(day))
const monthName = PERSIAN_MONTHS[month - 1] ?? ''
return `${dayWord} ${monthName} ${toPersianDigits(String(year))}`
}
// Gregorian Y-M-D as seen in Iran's timezone — comparing these strings is the cheapest
// correct way to ask "same calendar day in Tehran", no Jalali conversion needed for that
function getIranCalendarDayKey(date: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: IRAN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
}
export function isSameIranDay(a: Date, b: Date): boolean {
return getIranCalendarDayKey(a) === getIranCalendarDayKey(b)
}
export function formatIranKickoffTime(matchDatetimeIso: string): string {
const time = new Date(matchDatetimeIso).toLocaleTimeString('en-GB', {
timeZone: IRAN_TIME_ZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return toPersianDigits(time)
}

2
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

View file

@ -1,8 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import svgr from 'vite-plugin-svgr'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
// svgr only transforms imports ending in `?react` (e.g. `import Foo from './foo.svg?react'`),
// so plain `?url`/default `.svg` imports elsewhere (favicons, <img src>, etc.) are untouched
plugins: [react(), tailwindcss(), svgr()],
})