diff --git a/package.json b/package.json index 5b8b69e..e133dfe 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f41338..74b5131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/public/main-banner.jpg b/public/main-banner.jpg new file mode 100644 index 0000000..09bf6c2 Binary files /dev/null and b/public/main-banner.jpg differ diff --git a/src/App.tsx b/src/App.tsx index 13e42c8..a36d941 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> } /> + } /> ) diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF new file mode 100644 index 0000000..94780a6 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-BLACK.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF new file mode 100644 index 0000000..b63c8ca Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-BOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF new file mode 100644 index 0000000..9cd41c9 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRABOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF new file mode 100644 index 0000000..9f81e75 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-EXTRALIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF new file mode 100644 index 0000000..41dc12e Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-LIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF new file mode 100644 index 0000000..eda2ccb Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-MEDIUM.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF new file mode 100644 index 0000000..1c3075c Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-REGULAR.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF new file mode 100644 index 0000000..78effde Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-SEMIBOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF b/src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF new file mode 100644 index 0000000..bcc3764 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH(FANUM)-THIN.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-BOLD (1).TTF b/src/assets/banner-fonts/KALAMEH-BOLD (1).TTF new file mode 100644 index 0000000..5478371 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-BOLD (1).TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF b/src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF new file mode 100644 index 0000000..e3b6611 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-EXTRALIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-LIGHT.TTF b/src/assets/banner-fonts/KALAMEH-LIGHT.TTF new file mode 100644 index 0000000..6821c28 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-LIGHT.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-MEDIUM.TTF b/src/assets/banner-fonts/KALAMEH-MEDIUM.TTF new file mode 100644 index 0000000..3ebc195 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-MEDIUM.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF b/src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF new file mode 100644 index 0000000..178d8da Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-REGULAR (1).TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF b/src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF new file mode 100644 index 0000000..52e6a40 Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-SEMIBOLD.TTF differ diff --git a/src/assets/banner-fonts/KALAMEH-THIN (1).TTF b/src/assets/banner-fonts/KALAMEH-THIN (1).TTF new file mode 100644 index 0000000..dc3ff4b Binary files /dev/null and b/src/assets/banner-fonts/KALAMEH-THIN (1).TTF differ diff --git a/src/assets/banner-fonts/banner-fonts.css b/src/assets/banner-fonts/banner-fonts.css new file mode 100644 index 0000000..45e6463 --- /dev/null +++ b/src/assets/banner-fonts/banner-fonts.css @@ -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; +} diff --git a/src/assets/calendar.svg b/src/assets/calendar.svg deleted file mode 100644 index ef1c748..0000000 --- a/src/assets/calendar.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/assets/arrow-right.svg b/src/assets/icons/arrow-right.svg similarity index 52% rename from src/assets/arrow-right.svg rename to src/assets/icons/arrow-right.svg index 2694710..42d747d 100644 --- a/src/assets/arrow-right.svg +++ b/src/assets/icons/arrow-right.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/book.svg b/src/assets/icons/book.svg similarity index 53% rename from src/assets/book.svg rename to src/assets/icons/book.svg index a95cb42..b7272d9 100644 --- a/src/assets/book.svg +++ b/src/assets/icons/book.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/assets/icons/calendar.svg b/src/assets/icons/calendar.svg new file mode 100644 index 0000000..fdd352e --- /dev/null +++ b/src/assets/icons/calendar.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/call.svg b/src/assets/icons/call.svg similarity index 92% rename from src/assets/call.svg rename to src/assets/icons/call.svg index 693c4bc..53f03e6 100644 --- a/src/assets/call.svg +++ b/src/assets/icons/call.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg new file mode 100644 index 0000000..3fb8d58 --- /dev/null +++ b/src/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/chevron-down.svg b/src/assets/icons/chevron-down.svg new file mode 100644 index 0000000..8515a64 --- /dev/null +++ b/src/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/chevron-left.svg b/src/assets/icons/chevron-left.svg new file mode 100644 index 0000000..666a49d --- /dev/null +++ b/src/assets/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/chevron-right.svg b/src/assets/icons/chevron-right.svg new file mode 100644 index 0000000..7077bd9 --- /dev/null +++ b/src/assets/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/edit-2.svg b/src/assets/icons/edit.svg similarity index 56% rename from src/assets/edit-2.svg rename to src/assets/icons/edit.svg index 5ee713b..d331b9d 100644 --- a/src/assets/edit-2.svg +++ b/src/assets/icons/edit.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/src/assets/icons/fail.svg b/src/assets/icons/fail.svg new file mode 100644 index 0000000..831843c --- /dev/null +++ b/src/assets/icons/fail.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/info.svg b/src/assets/icons/info.svg similarity index 67% rename from src/assets/info.svg rename to src/assets/icons/info.svg index 1b66977..3ccb2eb 100644 --- a/src/assets/info.svg +++ b/src/assets/icons/info.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/location.svg b/src/assets/icons/location.svg similarity index 80% rename from src/assets/location.svg rename to src/assets/icons/location.svg index 462a973..d4e558d 100644 --- a/src/assets/location.svg +++ b/src/assets/icons/location.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/assets/icons/minus.svg b/src/assets/icons/minus.svg new file mode 100644 index 0000000..d1f667b --- /dev/null +++ b/src/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/plus.svg b/src/assets/icons/plus.svg similarity index 92% rename from src/assets/plus.svg rename to src/assets/icons/plus.svg index fc2de2c..6cedbc5 100644 --- a/src/assets/plus.svg +++ b/src/assets/icons/plus.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/profile-circle.svg b/src/assets/icons/profile-circle.svg similarity index 72% rename from src/assets/profile-circle.svg rename to src/assets/icons/profile-circle.svg index 8f1f86a..eda34a3 100644 --- a/src/assets/profile-circle.svg +++ b/src/assets/icons/profile-circle.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/src/assets/search-normal.svg b/src/assets/icons/search.svg similarity index 51% rename from src/assets/search-normal.svg rename to src/assets/icons/search.svg index c0c4ec3..09e0d78 100644 --- a/src/assets/search-normal.svg +++ b/src/assets/icons/search.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/assets/success_fallback.svg b/src/assets/icons/success.svg similarity index 92% rename from src/assets/success_fallback.svg rename to src/assets/icons/success.svg index 4528e14..258c10e 100644 --- a/src/assets/success_fallback.svg +++ b/src/assets/icons/success.svg @@ -1,4 +1,4 @@ - + diff --git a/src/assets/images/banners/Guest-banner.jpg b/src/assets/images/banners/Guest-banner.jpg new file mode 100644 index 0000000..ce01af1 Binary files /dev/null and b/src/assets/images/banners/Guest-banner.jpg differ diff --git a/src/assets/images/banners/Host-banner.jpg b/src/assets/images/banners/Host-banner.jpg new file mode 100644 index 0000000..91f01be Binary files /dev/null and b/src/assets/images/banners/Host-banner.jpg differ diff --git a/src/assets/minus.svg b/src/assets/minus.svg deleted file mode 100644 index e1d50eb..0000000 --- a/src/assets/minus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/BookIcon.tsx b/src/components/BookIcon.tsx deleted file mode 100644 index 0115d55..0000000 --- a/src/components/BookIcon.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function BookIcon() { - return ( - - - - - - - ) -} diff --git a/src/components/HomeBanner.tsx b/src/components/HomeBanner.tsx new file mode 100644 index 0000000..0923364 --- /dev/null +++ b/src/components/HomeBanner.tsx @@ -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([]); + 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 ( +
+ {/* darkens the right-hand side only, where the text/match list sits — keeps it + legible no matter what the underlying photo looks like there */} +
+ + {/* 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 */} +
+
+

+ بازی‌های امروز +

+

+ {formatIranJalaliHeading(now)} +

+
+
+ + {todayMatches.length > 0 && ( +
+ {todayMatches.map((match) => ( +
+ + {match.team_home} {" "} {match.team_away} + + + {formatIranKickoffTime(match.match_datetime)} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx new file mode 100644 index 0000000..8d577fd --- /dev/null +++ b/src/components/Icon/Icon.tsx @@ -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 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, '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 ( + 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
+
+
+ +

رزرو با خطا مواجه شد. لطفا دوباره تلاش کنید.

+ +
+
+ , + document.body, + ) +} diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx index eb2fb46..463cf23 100644 --- a/src/components/ReservationModal.tsx +++ b/src/components/ReservationModal.tsx @@ -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 ( - - - - ); -} - -function PlusIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - function PaymentRow({ label, amount, @@ -67,7 +48,7 @@ function PaymentRow({ {label} {infoIconParam && ( - + )}
- + {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]" > - +
@@ -246,7 +227,7 @@ export default function ReservationModal({ />
- +

توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده کاربر است. diff --git a/src/components/SuccessIcon.tsx b/src/components/SuccessIcon.tsx deleted file mode 100644 index 98fecf7..0000000 --- a/src/components/SuccessIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export default function SuccessIcon({ className = 'mx-auto h-28 w-28' }: { className?: string }) { - return ( - - - - - ) -} diff --git a/src/index.css b/src/index.css index 4a553a5..5351747 100644 --- a/src/index.css +++ b/src/index.css @@ -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, diff --git a/src/pages/AddEvent.tsx b/src/pages/AddEvent.tsx index 6201158..b4af12b 100644 --- a/src/pages/AddEvent.tsx +++ b/src/pages/AddEvent.tsx @@ -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 ( - - - - ) -} - -function ChevronDownIcon({ className = '' }: { className?: string }) { - return ( - - - - ) -} - -function MinusIcon({ className = '' }: { className?: string }) { - return ( - - - - ) -} - -function PlusIcon({ className = '' }: { className?: string }) { - return ( - - - - ) -} - 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 (

- {open && ( + {open && hasMatches && ( <>
setOpen(false)} />
@@ -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]'}`} > - + {editable ? ( @@ -164,7 +137,7 @@ function Stepper({ )}
) @@ -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 (
-
+ @@ -241,8 +212,6 @@ export default function AddEvent() {
- - {error &&

{error}

}
@@ -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 && } + {!isSubmitting && }
diff --git a/src/pages/AddEventFail.tsx b/src/pages/AddEventFail.tsx new file mode 100644 index 0000000..e7d7e27 --- /dev/null +++ b/src/pages/AddEventFail.tsx @@ -0,0 +1,33 @@ +import { useNavigate } from 'react-router-dom' +import Icon from '../components/Icon' + +export default function AddEventFail() { + const navigate = useNavigate() + + return ( +
+ +
+ +

ثبت ایونت با خطا مواجه شد. لطفا دوباره تلاش کنید.

+
+ +
+ + +
+
+ ) +} diff --git a/src/pages/AddEventSuccess.tsx b/src/pages/AddEventSuccess.tsx index 014d3b4..406d283 100644 --- a/src/pages/AddEventSuccess.tsx +++ b/src/pages/AddEventSuccess.tsx @@ -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 (
-
- +

ایونت با موفقیت ثبت شد!

diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx index 457de7f..cd79360 100644 --- a/src/pages/CafeDetail.tsx +++ b/src/pages/CafeDetail.tsx @@ -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 ( - - - - ); -} - -function ChevronRightIcon() { - return ( - - - - ); -} - -function PhoneIcon() { - return ( - - - - ); -} - -function MapPinIcon() { - return ( - - - - - ); -} - -function ChevronDownIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - function MatchPicker({ value, options, @@ -115,7 +41,7 @@ function MatchPicker({ className="flex items-center gap-1 text-right text-sm text-gray-500" > {selected ? formatMatchLabel(selected.match_event, now) : "بازی را انتخاب کنید"} - + {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" > - +
{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" > - +
@@ -316,7 +242,7 @@ export default function CafeDetail() {
- +

آدرس

{cafe.address} @@ -342,7 +268,7 @@ export default function CafeDetail() {
- +

توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده کاربر است. diff --git a/src/pages/CafeOwnerPanel.tsx b/src/pages/CafeOwnerPanel.tsx index 48f3927..60d73b0 100644 --- a/src/pages/CafeOwnerPanel.tsx +++ b/src/pages/CafeOwnerPanel.tsx @@ -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 ( - - - - ) -} - -function ProfileCircleIcon() { - return ( - - - - - - ) -} - -function CalendarIcon() { - return ( - - - - - - - - - - - - - ) -} - function PanelRow({ icon, label, onClick }: { icon: ReactNode; label: string; onClick?: () => void }) { return (

- + ) } @@ -69,18 +35,18 @@ export default function CafeOwnerPanel() { return (
-
+

پنل کافه

} + icon={} label="اطلاعات کافه" onClick={() => navigate('/owner/register', { state: { step: 2 } })} /> - } label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} /> + } label="افزودن رویداد" onClick={() => navigate('/owner/events/new')} />

رویدادهای فعال شما

@@ -108,7 +74,7 @@ export default function CafeOwnerPanel() {
} + icon={} label="مشاهده رزروها" onClick={() => navigate(`/owner/events/${schedule.uuid}/reservations`, { diff --git a/src/pages/CafeOwnerRegister.tsx b/src/pages/CafeOwnerRegister.tsx index 9bb250d..066bd72 100644 --- a/src/pages/CafeOwnerRegister.tsx +++ b/src/pages/CafeOwnerRegister.tsx @@ -1,120 +1,43 @@ -import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useLocation, useNavigate } from "react-router-dom"; import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import "../utils/leafletIcons"; -import InfoIcon from "../components/InfoIcon"; +import Icon from "../components/Icon"; import { CafeFeature, + CafeMediaItem, City, fetchCafeFeatures, fetchCities, fetchMyCafes, + presignMedia, registerCafe, - registerCafeOwner, updateCafe, } from "../services/campaignApi"; import { getCafeUuid, setCafeUuid } from "../utils/cafe"; -import { toEnglishDigits } from "../utils/formatNumber"; -import { fromE164, toE164 } from "../utils/phone"; +import { toPersianDigits } from "../utils/formatNumber"; +import HostBanner from "../assets/images/banners/Host-banner.jpg"; -// Tehran — default pin until the owner picks their cafe's actual location const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 }; -function ArrowIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function ChevronDownIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function CheckIcon() { - return ( - - - - ); -} - -function StepCircle({ - active, - children, -}: { - active: boolean; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - -function Stepper({ currentStep }: { currentStep: 1 | 2 }) { - return ( -
- - اطلاعات نماینده - - ۱ - - ۲ - - اطلاعات کافه - -
+function findTehran(cities: City[]): City | undefined { + return cities.find( + (c) => c.city_name === "تهران" || c.city_name.toLowerCase() === "tehran", ); } function FormField({ label, placeholder, + hint, value, onChange, }: { label: string; placeholder: string; + hint?: string; value: string; onChange: (value: string) => void; }) { @@ -130,70 +53,86 @@ function FormField({ placeholder={placeholder} className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]" /> -
- ); -} - -function CitySelectField({ - label, - value, - options, - onChange, -}: { - label: string; - value: string; - options: City[]; - onChange: (uuid: string) => void; -}) { - const [open, setOpen] = useState(false); - const selectedCity = options.find((city) => city.uuid === value); - - return ( -
- - - - {open && ( - <> -
setOpen(false)} /> -
- {options.map((city) => ( - - ))} -
- + {hint && ( +

{hint}

)}
); } -function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) => void }) { - // keep the handlers object reference stable — react-leaflet re-binds map - // listeners (map.off/map.on) whenever this reference changes, and doing - // that mid-drag (e.g. triggered by an unrelated re-render) was silently - // aborting the user's pan gesture. a ref lets onSelect stay fresh without - // recreating the object every render. +function CitySelectField({ + value, + options, + onChange, +}: { + value: string; + options: City[]; + onChange: (uuid: string) => void; +}) { + const [open, setOpen] = useState(false); + const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 }); + const buttonRef = useRef(null); + const selected = options.find((c) => c.uuid === value); + + const handleOpen = () => { + if (!open && buttonRef.current) { + const r = buttonRef.current.getBoundingClientRect(); + setDropPos({ top: r.bottom + 8, left: r.left, width: r.width }); + } + setOpen((o) => !o); + }; + + return ( +
+ + + + {open && + createPortal( + <> +
setOpen(false)} /> +
+ {options.map((city) => ( + + ))} +
+ , + document.body, + )} +
+ ); +} + +function LocationPicker({ + onSelect, +}: { + onSelect: (lat: number, lng: number) => void; +}) { const onSelectRef = useRef(onSelect); useEffect(() => { onSelectRef.current = onSelect; @@ -213,16 +152,18 @@ function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) => } function CafeLocationMap({ + label, location, onChange, }: { + label: string; location: { lat: number; lng: number }; onChange: (lat: number, lng: number) => void; }) { return (
+ + + + + + HEIC +
+ ); + } + return setError(true)} className={className} />; +} + +interface UploadedPhoto { + id: string; + file?: File; + localUrl: string; + status: "uploading" | "done" | "error"; + objectKey?: string; +} + +interface UploadedDocument { + file?: File; + localUrl: string; + status: "uploading" | "done" | "error"; + objectKey?: string; +} + +function PhotoUpload({ + photos, + onAdd, + onRemove, +}: { + photos: UploadedPhoto[]; + onAdd: (files: File[]) => void; + onRemove: (id: string) => void; +}) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const remaining = MAX_PHOTOS - photos.length; + const canAdd = remaining > 0; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files) + .filter((f) => f.type.startsWith("image/")) + .slice(0, remaining); + if (files.length > 0) onAdd(files); + }; + return (
- + + {photos.length > 0 && ( +
+ {photos.map((photo) => ( +
+ + {photo.status === "uploading" && ( +
+ آپلود... +
+ )} + {photo.status === "error" && ( +
+ خطا +
+ )} + +
+ ))} +
+ )} + + {canAdd && ( + + )} + + { + if (!e.target.files) return; + const files = Array.from(e.target.files).slice(0, remaining); + if (files.length > 0) onAdd(files); + e.target.value = ""; + }} + /> +
+ ); +} + +function DocumentUpload({ + doc, + onAdd, + onRemove, +}: { + doc: UploadedDocument | null; + onAdd: (file: File) => void; + onRemove: () => void; +}) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = Array.from(e.dataTransfer.files).find((f) => + f.type.startsWith("image/"), + ); + if (file) onAdd(file); + }; + + return ( +
+ + + {doc ? ( +
+ + {doc.status === "uploading" && ( +
+ آپلود... +
+ )} + {doc.status === "error" && ( +
+ خطا در آپلود +
+ )} + +
+ ) : ( + + )} + + { + const file = e.target.files?.[0]; + if (file) onAdd(file); + e.target.value = ""; + }} + />
); } @@ -289,7 +447,7 @@ function FeatureChip({ checked ? "bg-[#223D5D]" : "bg-transparent" }`} > - {checked && } + {checked && } {label} @@ -299,36 +457,49 @@ function FeatureChip({ export default function CafeOwnerRegister() { const navigate = useNavigate(); const location = useLocation(); - const initialStep = (location.state as { step?: 1 | 2 } | null)?.step ?? 1; - // the panel's "اطلاعات کافه" link is the only place that lands here with step 2 directly — - // treat that as "editing an existing cafe" rather than "continuing a fresh registration" - const isEditMode = initialStep === 2; - const [step, setStep] = useState<1 | 2>(initialStep); - - const [fullName, setFullName] = useState(""); - const [phone, setPhone] = useState(""); - const [nationalId, setNationalId] = useState(""); - const [cardNumber, setCardNumber] = useState(""); + // panel's "اطلاعات کافه" link navigates here with state:{step:2} to signal edit mode + const isEditMode = + (location.state as { step?: number } | null)?.step === 2; const [cafeName, setCafeName] = useState(""); - const [selectedCityUuid, setSelectedCityUuid] = useState(""); const [address, setAddress] = useState(""); const [cafePhone, setCafePhone] = useState(""); const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION); const [selectedFeatures, setSelectedFeatures] = useState([]); - const [cities, setCities] = useState([]); + // explicitly selected in edit mode (pre-filled from existing cafe); empty = auto-select Tehran below + const [selectedCityUuid, setSelectedCityUuid] = useState(""); const [cafeFeatures, setCafeFeatures] = useState([]); - // returned by /campaign/cafe-owners/ — sent as owner_uuid in the final /campaign/cafes/ request - const [ownerUuid, setOwnerUuid] = useState(""); + const [photos, setPhotos] = useState([]); + const [licenseDoc, setLicenseDoc] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(""); + // keep refs so the unmount cleanup can revoke any remaining object URLs + const photosRef = useRef(photos); useEffect(() => { - if (initialStep === 1 && getCafeUuid()) { + photosRef.current = photos; + }, [photos]); + const licenseDocRef = useRef(licenseDoc); + useEffect(() => { + licenseDocRef.current = licenseDoc; + }, [licenseDoc]); + useEffect(() => { + return () => { + photosRef.current + .filter((p) => p.localUrl.startsWith("blob:")) + .forEach((p) => URL.revokeObjectURL(p.localUrl)); + const doc = licenseDocRef.current; + if (doc?.localUrl.startsWith("blob:")) URL.revokeObjectURL(doc.localUrl); + }; + }, []); + + // redirect owners who already have a cafe away from fresh registration + useEffect(() => { + if (!isEditMode && getCafeUuid()) { navigate("/owner/panel", { replace: true }); } - }, [initialStep, navigate]); + }, [isEditMode, navigate]); useEffect(() => { fetchCities() @@ -339,6 +510,13 @@ export default function CafeOwnerRegister() { .catch(() => {}); }, []); + // fall back to Tehran when no city has been explicitly selected yet + const effectiveCityUuid = + selectedCityUuid || + findTehran(cities)?.uuid || + cities[0]?.uuid || + ""; + useEffect(() => { if (!isEditMode) return; fetchMyCafes() @@ -348,60 +526,178 @@ export default function CafeOwnerRegister() { setCafeName(cafe.name); setSelectedCityUuid(cafe.city.uuid); setAddress(cafe.address); - setCafePhone(fromE164(cafe.phone_number)); - setCafeLocation({ lat: cafe.location.coordinates[1], lng: cafe.location.coordinates[0] }); - setSelectedFeatures(cafe.features.map((feature) => feature.uuid)); + setCafePhone(cafe.phone_number); + setCafeLocation({ + lat: cafe.location.coordinates[1], + lng: cafe.location.coordinates[0], + }); + setSelectedFeatures(cafe.features.map((f) => f.uuid)); + + const imageMedia = cafe.media.filter((m) => m.type === "image"); + const docMedia = cafe.media.find((m) => m.type === "document"); + + if (imageMedia.length > 0) { + setPhotos( + imageMedia.map((m) => ({ + id: m.uuid, + localUrl: m.url, + status: "done", + objectKey: m.url, + })), + ); + } + if (docMedia) { + setLicenseDoc({ + localUrl: docMedia.url, + status: "done", + objectKey: docMedia.url, + }); + } }) .catch(() => {}); }, [isEditMode]); - const toggleFeature = (feature: string) => { + const toggleFeature = (uuid: string) => { setSelectedFeatures((prev) => - prev.includes(feature) - ? prev.filter((f) => f !== feature) - : [...prev, feature], + prev.includes(uuid) ? prev.filter((f) => f !== uuid) : [...prev, uuid], ); }; + const handleAddPhotos = async (files: File[]) => { + const newPhotos: UploadedPhoto[] = files.map((file) => ({ + id: crypto.randomUUID(), + file, + localUrl: URL.createObjectURL(file), + status: "uploading", + })); + setPhotos((prev) => [...prev, ...newPhotos]); + + await Promise.all( + newPhotos.map(async (photo) => { + try { + const { upload_url, object_key } = await presignMedia(photo.file!.name); + const res = await fetch(upload_url, { + method: "PUT", + body: photo.file!, + headers: { "Content-Type": photo.file!.type }, + }); + if (!res.ok) throw new Error("upload failed"); + setPhotos((prev) => + prev.map((p) => + p.id === photo.id ? { ...p, status: "done", objectKey: object_key } : p, + ), + ); + } catch { + setPhotos((prev) => + prev.map((p) => + p.id === photo.id ? { ...p, status: "error" } : p, + ), + ); + } + }), + ); + }; + + const handleRemovePhoto = (id: string) => { + setPhotos((prev) => { + const photo = prev.find((p) => p.id === id); + if (photo) URL.revokeObjectURL(photo.localUrl); + return prev.filter((p) => p.id !== id); + }); + }; + + const handleAddLicense = async (file: File) => { + const newDoc: UploadedDocument = { + file, + localUrl: URL.createObjectURL(file), + status: "uploading", + }; + setLicenseDoc(newDoc); + try { + const { upload_url, object_key } = await presignMedia(file.name); + const res = await fetch(upload_url, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }); + if (!res.ok) throw new Error("upload failed"); + setLicenseDoc((prev) => + prev ? { ...prev, status: "done", objectKey: object_key } : null, + ); + } catch { + setLicenseDoc((prev) => + prev ? { ...prev, status: "error" } : null, + ); + } + }; + + const handleRemoveLicense = () => { + setLicenseDoc((prev) => { + if (prev) URL.revokeObjectURL(prev.localUrl); + return null; + }); + }; + const handleContinue = async () => { + const stillUploading = + photos.some((p) => p.status === "uploading") || + licenseDoc?.status === "uploading"; + if (stillUploading) { + setError("لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود."); + return; + } + if (!isEditMode) { + if (!photos.some((p) => p.status === "done")) { + setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید."); + return; + } + if (!licenseDoc || licenseDoc.status !== "done") { + setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید."); + return; + } + } setError(""); setIsSubmitting(true); try { - if (step === 1) { - const owner = await registerCafeOwner({ - full_name: fullName, - phone_number: toE164(phone), - national_id: toEnglishDigits(nationalId), - pan_number: toEnglishDigits(cardNumber), - }); - setOwnerUuid(owner.uuid); - setStep(2); - } else if (isEditMode) { + const mediaItems: CafeMediaItem[] = [ + ...photos + .filter((p) => p.status === "done" && p.objectKey) + .map((p) => ({ object_key: p.objectKey!, type: "image" as const })), + ...(licenseDoc?.status === "done" && licenseDoc.objectKey + ? [{ object_key: licenseDoc.objectKey, type: "document" as const }] + : []), + ]; + + const payload = { + city_uuid: effectiveCityUuid, + name: cafeName, + phone_number: cafePhone, + location: { + type: "Point" as const, + coordinates: [cafeLocation.lng, cafeLocation.lat] as [ + number, + number, + ], + }, + address, + feature_uuids: selectedFeatures, + media: mediaItems, + }; + if (isEditMode) { const cafeUuid = getCafeUuid() ?? ""; - await updateCafe(cafeUuid, { - city_uuid: selectedCityUuid, - name: cafeName, - phone_number: cafePhone ? toE164(cafePhone) : "", - location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] }, - address, - feature_uuids: selectedFeatures, - }); + await updateCafe(cafeUuid, payload); navigate("/owner/panel"); } else { - const cafe = await registerCafe({ - owner_uuid: ownerUuid, - city_uuid: selectedCityUuid, - name: cafeName, - phone_number: cafePhone ? toE164(cafePhone) : "", - location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] }, - address, - feature_uuids: selectedFeatures, - }); + const cafe = await registerCafe({ owner_uuid: "", ...payload }); setCafeUuid(cafe.uuid); navigate("/owner/register/success"); } } catch { - setError("ثبت اطلاعات با خطا مواجه شد. لطفاً دوباره تلاش کنید."); + if (!isEditMode) { + navigate("/owner/register/fail"); + } else { + setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید."); + } } finally { setIsSubmitting(false); } @@ -409,102 +705,68 @@ export default function CafeOwnerRegister() { return (
-
- - +
- {step === 1 ? ( - <> -
- -

- لطفاً یک{" "} - نماینده{" "} - برای کافه معرفی کنید. نماینده باید در اپلیکیشن - ثبت‌نام کند و پرداخت‌ها به کیف پول او واریز می‌شود. شماره - موبایل، کد ملی و کارت بانکی باید متعلق به یک نفر باشند. -

-
+ + + + setCafeLocation({ lat, lng })} + /> + + + - - - - - - ) : ( - <> - - - - - setCafeLocation({ lat, lng })} - /> +
+ +
+ {cafeFeatures.map((feature) => ( + toggleFeature(feature.uuid)} + /> + ))} +
+
- - - -
- -
- {cafeFeatures.map((feature) => ( - toggleFeature(feature.uuid)} - /> - ))} -
-
- + {error && ( +

{error}

)} - - {error &&

{error}

}
@@ -520,8 +782,14 @@ export default function CafeOwnerRegister() { onClick={handleContinue} className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50" > - {isSubmitting ? "در حال ارسال..." : isEditMode ? "ذخیره تغییرات" : "ادامه"} - {!isSubmitting && !isEditMode && } + {isSubmitting + ? "در حال ارسال..." + : isEditMode + ? "ذخیره تغییرات" + : "ادامه"} + {!isSubmitting && !isEditMode && ( + + )}
diff --git a/src/pages/CafeRegisterFail.tsx b/src/pages/CafeRegisterFail.tsx new file mode 100644 index 0000000..76ab9fa --- /dev/null +++ b/src/pages/CafeRegisterFail.tsx @@ -0,0 +1,28 @@ +import { useNavigate } from 'react-router-dom' +import Icon from '../components/Icon' + +export default function CafeRegisterFail() { + const navigate = useNavigate() + + return ( +
+ +
+ +

ثبت کافه با خطا مواجه شد. لطفا دوباره تلاش کنید.

+
+ +
+ {/* 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 */} + +
+
+ ) +} diff --git a/src/pages/CafeRegisterSuccess.tsx b/src/pages/CafeRegisterSuccess.tsx index fb5846c..38bd250 100644 --- a/src/pages/CafeRegisterSuccess.tsx +++ b/src/pages/CafeRegisterSuccess.tsx @@ -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 (
-
- +

کافه شما با موفقیت ثبت شد!

با اضافه کردن ایونت بازی‌ها، کافه شما در کمپین نمایش داده می‌شود و کاربران می‌توانند برای تماشای هر مسابقه در کافه شما، میز رزرو کنند. diff --git a/src/pages/EventReservations.tsx b/src/pages/EventReservations.tsx index 75390cb..2c69733 100644 --- a/src/pages/EventReservations.tsx +++ b/src/pages/EventReservations.tsx @@ -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 ( - - - - ); -} - -function SearchIcon({ className = "" }: { className?: string }) { - return ( - - - - - ); -} - -function CheckIcon() { - return ( - - - - ); -} - 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 && } + {selected && }

); @@ -172,14 +125,14 @@ export default function EventReservations() { return (
-
+
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4f0a956..6226168 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 ( - - - - ) -} - -function ChevronDownIcon({ className = '' }: { className?: string }) { - return ( - - - - ) -} - // 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 ( -
- بنر -
- ) -} +// 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 ( +//
+// بنر +//
+// ) +// } 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" > {selectedCity?.city_name ?? ''} - + {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 (

{label}

- {open && ( + {open && hasMatches && ( <>
setOpen(false)} />
@@ -182,10 +173,10 @@ function MyReservationsRow() { className="flex w-full items-center justify-between px-4 py-6" >
- + رزروهای شما
- + ) } @@ -246,7 +237,7 @@ export default function Home() { return (
- + diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 0c2ffbc..7542bab 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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 ( - - - - ); -} - export default function Login() { const navigate = useNavigate(); const location = useLocation(); @@ -52,7 +37,13 @@ export default function Login() { }; return ( -
+
{ + e.preventDefault(); + if (canContinue) handleContinue(); + }} + className="flex min-h-screen flex-col justify-center px-6" + >

ورود به وینسو

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

{error}

}

@@ -101,6 +92,6 @@ export default function Login() { را می‌پذیرم.

-
+ ); } diff --git a/src/pages/LoginOtp.tsx b/src/pages/LoginOtp.tsx index 0d0a6c5..8943688 100644 --- a/src/pages/LoginOtp.tsx +++ b/src/pages/LoginOtp.tsx @@ -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 ( - - - - - - ) +// 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 "@ #", 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 ( -
+
{ + e.preventDefault() + if (canSubmit) handleSubmit() + }} + className="flex min-h-screen flex-col justify-center px-6" + >

کد ارسال شده را وارد کنید:

{phone}
@@ -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 &&

{error}

} -
+ ) } diff --git a/src/pages/MyReservations.tsx b/src/pages/MyReservations.tsx index 643b2e0..f8975d5 100644 --- a/src/pages/MyReservations.tsx +++ b/src/pages/MyReservations.tsx @@ -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 ( - - - - ) -} - function MyReservationCard({ reservation, onClick }: { reservation: MyReservation; onClick: () => void }) { return ( diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..0c389f7 --- /dev/null +++ b/src/pages/NotFound.tsx @@ -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 ( +
+

{toPersianDigits('404')}

+

صفحه مورد نظر یافت نشد.

+ +
+ ) +} diff --git a/src/pages/PaymentSuccess.tsx b/src/pages/PaymentSuccess.tsx index 26fdb78..20e7467 100644 --- a/src/pages/PaymentSuccess.tsx +++ b/src/pages/PaymentSuccess.tsx @@ -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(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 ( -
-
- +
+ {!isFail && ( +
+
+ - {isSuccess ? ( - reservation ? ( - <> -

جزئیات رزرو

-
- - - - -
+ {reservation ? ( + <> +

جزئیات رزرو

+
+ + + + +
-
- -

- کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود. -

-
- - ) : error ? ( -

{error}

- ) : ( -

در حال ثبت رزرو...

- ) - ) : ( -

پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.

- )} +
+ +

+ کد رزرو را برای ورود نزد خود نگه دارید. اطلاعات رزرو و کد ورود تا دقایقی دیگر برای شما پیامک می‌شود. +

+
+ + ) : ( +

در حال ثبت رزرو...

+ )} - -
+ +
+
+ )} + + {isFail && navigate('/home')} />}
) } diff --git a/src/services/campaignApi.ts b/src/services/campaignApi.ts index 634796d..3d60eab 100644 --- a/src/services/campaignApi.ts +++ b/src/services/campaignApi.ts @@ -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 { @@ -253,3 +260,13 @@ export async function createReservation(payload: CreateReservationPayload): Prom const { data } = await api.post('/campaign/reservations/', payload) return data } + +export interface PresignResponse { + upload_url: string + object_key: string +} + +export async function presignMedia(filename: string): Promise { + const { data } = await api.post('/campaign/media/presign/', { filename }) + return data +} diff --git a/src/utils/iranDate.ts b/src/utils/iranDate.ts new file mode 100644 index 0000000..318d51a --- /dev/null +++ b/src/utils/iranDate.ts @@ -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) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..b1f45c7 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/vite.config.js b/vite.config.js index c4069b7..1ea70d6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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, , etc.) are untouched + plugins: [react(), tailwindcss(), svgr()], })