diff --git a/package.json b/package.json index 8eded31d..8df492bc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "vite", "build": "tsc -b && vite build", + "build:analyze:staging": "ANALYZE=true tsc -b && ANALYZE=true vite build --mode staging", "lint": "eslint .", "preview": "vite preview" }, @@ -42,6 +43,7 @@ "globals": "^16.5.0", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", + "rollup-plugin-visualizer": "^7.0.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d37a9848..7705b62f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.7.1 version: 0.7.1(prettier@3.6.2) + rollup-plugin-visualizer: + specifier: ^7.0.1 + version: 7.0.1(rollup@4.53.3) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -1077,10 +1080,18 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1160,6 +1171,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1187,6 +1202,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1259,10 +1278,22 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -1289,6 +1320,9 @@ packages: electron-to-chromium@1.5.259: resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -1557,6 +1591,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1701,6 +1743,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1717,6 +1764,15 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -1769,6 +1825,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2021,6 +2081,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2083,6 +2147,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2229,11 +2297,28 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.53.3: resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2314,6 +2399,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -2322,6 +2411,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -2341,6 +2434,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2529,9 +2626,29 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3435,10 +3552,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -3552,6 +3673,10 @@ snapshots: buffer-from@1.1.2: optional: true + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3580,6 +3705,12 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + clsx@2.1.1: {} color-convert@2.0.1: @@ -3642,12 +3773,21 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -3675,6 +3815,8 @@ snapshots: electron-to-chromium@1.5.259: {} + emoji-regex@10.6.0: {} + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -4078,6 +4220,10 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4233,6 +4379,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -4251,6 +4399,12 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -4301,6 +4455,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -4515,6 +4673,15 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4576,6 +4743,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -4665,6 +4834,15 @@ snapshots: reusify@1.1.0: {} + rollup-plugin-visualizer@7.0.1(rollup@4.53.3): + dependencies: + open: 11.0.0 + picomatch: 4.0.3 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rollup: 4.53.3 + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 @@ -4693,6 +4871,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4796,6 +4976,8 @@ snapshots: source-map@0.6.1: optional: true + source-map@0.7.6: {} + stable-hash-x@0.2.0: {} stop-iteration-iterator@1.1.0: @@ -4803,6 +4985,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -4847,6 +5035,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -5074,8 +5266,32 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.1.12): diff --git a/src/App.tsx b/src/App.tsx index a701b871..46417210 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,49 +1,52 @@ +import { lazy, Suspense } from 'react'; import * as Sentry from '@sentry/react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import RouteLoadingFallback from '@/components/common/RouteLoadingFallback'; import AuthGuard from './components/auth/AuthGuard'; import ProtectedRoute from './components/auth/ProtectedRoute'; import PublicRoute from './components/auth/PublicRoute'; import Layout from './components/layout'; import Login from './pages/Auth/Login'; -import ConfirmStep from './pages/Auth/SignUp/ConfirmStep'; -import FinishStep from './pages/Auth/SignUp/FinishStep'; -import NameStep from './pages/Auth/SignUp/NameStep'; -import StudentIdStep from './pages/Auth/SignUp/StudentIdStep'; -import TermStep from './pages/Auth/SignUp/TermStep'; -import UniversityStep from './pages/Auth/SignUp/UniversityStep'; -import ChatListPage from './pages/Chat'; -import ChatRoom from './pages/Chat/ChatRoom'; -import ApplicationPage from './pages/Club/Application'; -import ApplyCompletePage from './pages/Club/Application/applyCompletePage'; -import ClubFeePage from './pages/Club/Application/clubFeePage'; -import ClubDetail from './pages/Club/ClubDetail'; -import ClubList from './pages/Club/ClubList'; -import ClubSearch from './pages/Club/ClubSearch'; -import CouncilDetail from './pages/Council/CouncilDetail'; -import CouncilNotice from './pages/Council/CouncilNotice'; -import GuidePage from './pages/Guide'; import Home from './pages/Home'; -import LicensePage from './pages/legal/LicensePage'; -import MarketingPolicyPage from './pages/legal/MarketingPolicyPage'; -import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage'; -import TermsPage from './pages/legal/TermsPage'; -import ManagedAccount from './pages/Manager/ManagedAccount'; -import ManagedApplicationDetail from './pages/Manager/ManagedApplicationDetail'; -import ManagedApplicationList from './pages/Manager/ManagedApplicationList'; -import ManagedClubDetail from './pages/Manager/ManagedClubDetail'; -import ManagedClubList from './pages/Manager/ManagedClubList'; -import ManagedClubInfo from './pages/Manager/ManagedClubProfile'; -import ManagedMemberApplicationDetail from './pages/Manager/ManagedMemberApplicationDetail'; -import ManagedMemberList from './pages/Manager/ManagedMemberList'; -import ManagedRecruitment from './pages/Manager/ManagedRecruitment'; -import ManagedRecruitmentForm from './pages/Manager/ManagedRecruitmentForm'; -import ManagedRecruitmentWrite from './pages/Manager/ManagedRecruitmentWrite'; import NotFoundPage from './pages/NotFound'; -import Schedule from './pages/Schedule'; import ServerErrorPage from './pages/ServerError'; -import Timer from './pages/Timer'; -import MyPage from './pages/User/MyPage'; -import Profile from './pages/User/Profile'; + +const ConfirmStep = lazy(() => import('./pages/Auth/SignUp/ConfirmStep')); +const FinishStep = lazy(() => import('./pages/Auth/SignUp/FinishStep')); +const NameStep = lazy(() => import('./pages/Auth/SignUp/NameStep')); +const StudentIdStep = lazy(() => import('./pages/Auth/SignUp/StudentIdStep')); +const TermStep = lazy(() => import('./pages/Auth/SignUp/TermStep')); +const UniversityStep = lazy(() => import('./pages/Auth/SignUp/UniversityStep')); +const ChatListPage = lazy(() => import('./pages/Chat')); +const ChatRoom = lazy(() => import('./pages/Chat/ChatRoom')); +const ApplicationPage = lazy(() => import('./pages/Club/Application')); +const ApplyCompletePage = lazy(() => import('./pages/Club/Application/applyCompletePage')); +const ClubFeePage = lazy(() => import('./pages/Club/Application/clubFeePage')); +const ClubDetail = lazy(() => import('./pages/Club/ClubDetail')); +const ClubList = lazy(() => import('./pages/Club/ClubList')); +const ClubSearch = lazy(() => import('./pages/Club/ClubSearch')); +const CouncilDetail = lazy(() => import('./pages/Council/CouncilDetail')); +const CouncilNotice = lazy(() => import('./pages/Council/CouncilNotice')); +const GuidePage = lazy(() => import('./pages/Guide')); +const LicensePage = lazy(() => import('./pages/legal/LicensePage')); +const MarketingPolicyPage = lazy(() => import('./pages/legal/MarketingPolicyPage')); +const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage')); +const TermsPage = lazy(() => import('./pages/legal/TermsPage')); +const ManagedAccount = lazy(() => import('./pages/Manager/ManagedAccount')); +const ManagedApplicationDetail = lazy(() => import('./pages/Manager/ManagedApplicationDetail')); +const ManagedApplicationList = lazy(() => import('./pages/Manager/ManagedApplicationList')); +const ManagedClubDetail = lazy(() => import('./pages/Manager/ManagedClubDetail')); +const ManagedClubInfo = lazy(() => import('./pages/Manager/ManagedClubProfile')); +const ManagedClubList = lazy(() => import('./pages/Manager/ManagedClubList')); +const ManagedMemberApplicationDetail = lazy(() => import('./pages/Manager/ManagedMemberApplicationDetail')); +const ManagedMemberList = lazy(() => import('./pages/Manager/ManagedMemberList')); +const ManagedRecruitment = lazy(() => import('./pages/Manager/ManagedRecruitment')); +const ManagedRecruitmentForm = lazy(() => import('./pages/Manager/ManagedRecruitmentForm')); +const ManagedRecruitmentWrite = lazy(() => import('./pages/Manager/ManagedRecruitmentWrite')); +const Schedule = lazy(() => import('./pages/Schedule')); +const Timer = lazy(() => import('./pages/Timer')); +const MyPage = lazy(() => import('./pages/User/MyPage')); +const Profile = lazy(() => import('./pages/User/Profile')); const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); @@ -51,80 +54,82 @@ function App() { return ( - - }> - }> - } /> - - } /> - } /> - } /> - } /> - } /> + }> + + }> + }> + } /> + + } /> + } /> + } /> + } /> + } /> + - - - }> - } /> - - } /> - }> - - } /> - } /> - } /> - } /> + }> + } /> - + } /> - }> - }> - } /> - - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - } /> - - } /> - } /> + }> + + } /> + } /> + } /> + } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> + + }> + }> + } /> + + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + } /> + } /> + - - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + } /> + } /> + - - } /> - } /> - + } /> + } /> + + ); diff --git a/src/assets/image/chat-cat-header.png b/src/assets/image/chat-cat-header.png new file mode 100644 index 00000000..550a5666 Binary files /dev/null and b/src/assets/image/chat-cat-header.png differ diff --git a/src/assets/image/chat-cat-login.png b/src/assets/image/chat-cat-login.png new file mode 100644 index 00000000..754f9b3b Binary files /dev/null and b/src/assets/image/chat-cat-login.png differ diff --git a/src/assets/svg/chat-cat.svg b/src/assets/svg/chat-cat.svg deleted file mode 100644 index 91adaa5b..00000000 --- a/src/assets/svg/chat-cat.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/components/auth/AuthGuard.tsx b/src/components/auth/AuthGuard.tsx index 2456b8c3..1ee10082 100644 --- a/src/components/auth/AuthGuard.tsx +++ b/src/components/auth/AuthGuard.tsx @@ -7,10 +7,26 @@ interface AuthGuardProps { children: ReactNode; } +const PROTECTED_ROUTE_PREFIXES = [ + '/home', + '/mypage', + '/timer', + '/clubs', + '/schedule', + '/profile', + '/council', + '/chats', +]; + +function isProtectedPath(pathname: string) { + return PROTECTED_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + function AuthGuard({ children }: AuthGuardProps) { const { pathname } = useLocation(); const { isLoading, initialize } = useAuthStore(); const shouldSkipInitialize = pathname === SERVER_ERROR_PATH; + const shouldBlockWhileInitializing = !shouldSkipInitialize && isProtectedPath(pathname); useEffect(() => { if (shouldSkipInitialize) return; @@ -18,7 +34,8 @@ function AuthGuard({ children }: AuthGuardProps) { initialize(); }, [initialize, shouldSkipInitialize]); - if (isLoading && !shouldSkipInitialize) { + // Public routes can paint immediately while auth restore runs in the background. + if (isLoading && shouldBlockWhileInitializing) { return (
로딩 중… diff --git a/src/components/common/RouteLoadingFallback.tsx b/src/components/common/RouteLoadingFallback.tsx new file mode 100644 index 00000000..352f6050 --- /dev/null +++ b/src/components/common/RouteLoadingFallback.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/utils/ts/cn'; + +interface RouteLoadingFallbackProps { + fullScreen?: boolean; +} + +export default function RouteLoadingFallback({ fullScreen = false }: RouteLoadingFallbackProps) { + return ( +
+
+
+ ); +} diff --git a/src/components/layout/Header/components/InfoHeader.tsx b/src/components/layout/Header/components/InfoHeader.tsx index b3937b05..b64b4b71 100644 --- a/src/components/layout/Header/components/InfoHeader.tsx +++ b/src/components/layout/Header/components/InfoHeader.tsx @@ -1,19 +1,28 @@ import { useLocation } from 'react-router-dom'; -import { useMyInfo } from '@/pages/User/Profile/hooks/useMyInfo'; +import { useAuthStore } from '@/stores/authStore'; import NotificationBell from './NotificationBell'; function InfoHeader() { const { pathname } = useLocation(); - const { myInfo } = useMyInfo(); + const user = useAuthStore((state) => state.user); const showChatTooltip = pathname === '/home'; return (
-
{myInfo.universityName}
-
- {myInfo.name} {myInfo.studentNumber} -
+ {user ? ( + <> +
{user.universityName}
+
+ {user.name} {user.studentNumber} +
+ + ) : ( + <> +
+
+ + )}
diff --git a/src/components/layout/Header/components/NotificationBell.tsx b/src/components/layout/Header/components/NotificationBell.tsx index 8e3c1ef5..e352d020 100644 --- a/src/components/layout/Header/components/NotificationBell.tsx +++ b/src/components/layout/Header/components/NotificationBell.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; -import ChatCatIcon from '@/assets/svg/chat-cat.svg'; +import chatCatHeaderImage from '@/assets/image/chat-cat-header.png'; import MegaphoneSmIcon from '@/assets/svg/megaphone-sm.svg'; -import useChat from '@/pages/Chat/hooks/useChat'; +import useUnreadChatCount from '@/pages/Chat/hooks/useUnreadChatCount'; import { cn } from '@/utils/ts/cn'; const CHAT_TOOLTIP_DISMISSED_STORAGE_KEY = 'chat-tooltip-dismissed:v1'; @@ -24,7 +24,7 @@ interface NotificationBellProps { } function NotificationBell({ showTooltip = false }: NotificationBellProps) { - const { totalUnreadCount } = useChat(); + const { totalUnreadCount } = useUnreadChatCount(); const [isTooltipDismissed, setIsTooltipDismissed] = useState(readChatTooltipDismissed); const shouldShowTooltip = showTooltip && !isTooltipDismissed; @@ -51,7 +51,14 @@ function NotificationBell({ showTooltip = false }: NotificationBellProps) { onClick={handleChatButtonClick} className={cn('relative inline-flex', shouldShowTooltip && 'chat-tooltip-anchor')} > - + {totalUnreadCount > 0 ? ( {totalUnreadCount > 99 ? '99+' : totalUnreadCount} diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 62432754..2927ffdc 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -1,5 +1,6 @@ import { Suspense, type CSSProperties } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; +import RouteLoadingFallback from '@/components/common/RouteLoadingFallback'; import { cn } from '@/utils/ts/cn'; import BottomNav from './BottomNav'; import Header from './Header'; @@ -27,18 +28,18 @@ export default function Layout({ showBottomNav = false, contentClassName }: Layo return (
{hasHeader &&
} - -
+
+ }> -
- + +
{showBottomNav && }
); diff --git a/src/pages/Auth/Login/index.tsx b/src/pages/Auth/Login/index.tsx index 6de7fd1a..b42f7f81 100644 --- a/src/pages/Auth/Login/index.tsx +++ b/src/pages/Auth/Login/index.tsx @@ -1,5 +1,5 @@ +import chatCatLoginImage from '@/assets/image/chat-cat-login.png'; import AppleFigmaIcon from '@/assets/svg/apple-figma.svg'; -import ChatCatIcon from '@/assets/svg/chat-cat.svg'; import GoogleIcon from '@/assets/svg/google.svg'; import KakaoIcon from '@/assets/svg/kakao.svg'; import NaverIcon from '@/assets/svg/naver.svg'; @@ -66,7 +66,16 @@ function Login() {
- +
diff --git a/src/pages/Chat/hooks/useUnreadChatCount.ts b/src/pages/Chat/hooks/useUnreadChatCount.ts new file mode 100644 index 00000000..d35e8c8a --- /dev/null +++ b/src/pages/Chat/hooks/useUnreadChatCount.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getChatRooms } from '@/apis/chat'; +import { chatQueryKeys } from '@/pages/Chat/hooks/useChat'; + +const UNREAD_CHAT_COUNT_REFETCH_INTERVAL = 5_000; + +function useUnreadChatCount() { + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const requestIdleCallback = window.requestIdleCallback; + + if (typeof requestIdleCallback === 'function') { + const idleCallbackId = requestIdleCallback(() => { + setIsEnabled(true); + }); + + return () => { + window.cancelIdleCallback(idleCallbackId); + }; + } + + const timeoutId = window.setTimeout(() => { + setIsEnabled(true); + }, 0); + + return () => { + window.clearTimeout(timeoutId); + }; + }, []); + + const { data } = useQuery({ + queryKey: chatQueryKeys.rooms(), + queryFn: getChatRooms, + enabled: isEnabled, + staleTime: UNREAD_CHAT_COUNT_REFETCH_INTERVAL, + refetchInterval: isEnabled ? UNREAD_CHAT_COUNT_REFETCH_INTERVAL : false, + }); + + const totalUnreadCount = data?.rooms.reduce((sum, room) => sum + room.unreadCount, 0) ?? 0; + + return { + totalUnreadCount, + }; +} + +export default useUnreadChatCount; diff --git a/src/pages/Council/CouncilDetail/hooks/useGetCouncilInfo.ts b/src/pages/Council/CouncilDetail/hooks/useGetCouncilInfo.ts index b7140fb8..8b644b4e 100644 --- a/src/pages/Council/CouncilDetail/hooks/useGetCouncilInfo.ts +++ b/src/pages/Council/CouncilDetail/hooks/useGetCouncilInfo.ts @@ -5,6 +5,7 @@ export const councilQueryKeys = { all: ['council'], info: () => [...councilQueryKeys.all, 'info'], notices: (limit: number) => [...councilQueryKeys.all, 'notices', limit], + noticesPreview: (limit: number) => [...councilQueryKeys.all, 'noticesPreview', limit], noticeDetail: (noticeId: number) => [...councilQueryKeys.all, 'noticeDetail', noticeId], }; diff --git a/src/pages/Home/components/CouncilNoticeSection.tsx b/src/pages/Home/components/CouncilNoticeSection.tsx index f3f38f86..d7f57571 100644 --- a/src/pages/Home/components/CouncilNoticeSection.tsx +++ b/src/pages/Home/components/CouncilNoticeSection.tsx @@ -1,8 +1,8 @@ import Card from '@/components/common/Card'; -import { useCouncilNotice } from '@/pages/Club/ClubDetail/hooks/useCouncilNotices'; import CouncilNoticeCard from '@/pages/Home/components/CouncilNoticeCard'; import SectionErrorFallback from '@/pages/Home/components/SectionErrorFallback'; import SectionTitle from '@/pages/Home/components/SectionTitle'; +import { useGetHomeCouncilNotices } from '@/pages/Home/hooks/useGetHomeCouncilNotices'; const COUNCIL_NOTICE_PARAMS = { limit: 3 } as const; @@ -38,8 +38,8 @@ export function CouncilNoticeSectionErrorFallback() { } function CouncilNoticeSection() { - const { data: councilNoticeData } = useCouncilNotice(COUNCIL_NOTICE_PARAMS); - const allNotices = councilNoticeData.pages.flatMap((page) => page.councilNotices); + const { data: councilNoticeData } = useGetHomeCouncilNotices(COUNCIL_NOTICE_PARAMS); + const allNotices = councilNoticeData.councilNotices; return (
diff --git a/src/pages/Home/components/InfiniteClubCarousel.tsx b/src/pages/Home/components/InfiniteClubCarousel.tsx index 9af06721..fde6bba9 100644 --- a/src/pages/Home/components/InfiniteClubCarousel.tsx +++ b/src/pages/Home/components/InfiniteClubCarousel.tsx @@ -9,6 +9,23 @@ interface InfiniteClubCarouselProps { clubs: HomeClubCardItem[]; } +function isPriorityImage(index: number, clubsLength: number, shouldLoop: boolean) { + const visibleCount = 2; + + if (!shouldLoop) { + return index < visibleCount; + } + + const middleSegmentStartIndex = clubsLength; + const middleSegmentVisibleRadius = 1; + const isInInitialVisibleRange = index < visibleCount; + const isInMiddleVisibleRange = + index >= middleSegmentStartIndex - middleSegmentVisibleRadius && + index <= middleSegmentStartIndex + middleSegmentVisibleRadius; + + return isInInitialVisibleRange || isInMiddleVisibleRange; +} + function InfiniteClubCarousel({ clubs }: InfiniteClubCarouselProps) { const { displayClubs, @@ -34,6 +51,7 @@ function InfiniteClubCarousel({ clubs }: InfiniteClubCarouselProps) {
{displayClubs.map(({ club, key }, index) => { const isDuplicate = shouldLoop && (index < clubs.length || index >= clubs.length * 2); + const isPriority = isPriorityImage(index, clubs.length, shouldLoop); return (
diff --git a/src/pages/Home/components/RecommendedClubCard.tsx b/src/pages/Home/components/RecommendedClubCard.tsx index f8551752..e774a055 100644 --- a/src/pages/Home/components/RecommendedClubCard.tsx +++ b/src/pages/Home/components/RecommendedClubCard.tsx @@ -7,9 +7,18 @@ interface RecommendedClubCardProps { className?: string; tabIndex?: number; ariaHidden?: boolean; + imageLoading?: 'eager' | 'lazy'; + imageFetchPriority?: 'auto' | 'high' | 'low'; } -function RecommendedClubCard({ club, className, tabIndex, ariaHidden = false }: RecommendedClubCardProps) { +function RecommendedClubCard({ + club, + className, + tabIndex, + ariaHidden = false, + imageLoading = 'lazy', + imageFetchPriority = 'low', +}: RecommendedClubCardProps) { return (
diff --git a/src/pages/Home/hooks/useGetHomeCouncilNotices.ts b/src/pages/Home/hooks/useGetHomeCouncilNotices.ts new file mode 100644 index 00000000..95ce5401 --- /dev/null +++ b/src/pages/Home/hooks/useGetHomeCouncilNotices.ts @@ -0,0 +1,14 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { getCouncilNotice } from '@/apis/council'; +import { councilQueryKeys } from '@/pages/Council/CouncilDetail/hooks/useGetCouncilInfo'; + +interface UseGetHomeCouncilNoticesParams { + limit?: number; +} + +export const useGetHomeCouncilNotices = ({ limit = 3 }: UseGetHomeCouncilNoticesParams = {}) => { + return useSuspenseQuery({ + queryKey: councilQueryKeys.noticesPreview(limit), + queryFn: () => getCouncilNotice({ page: 1, limit }), + }); +}; diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index fb8b7e38..a21577c1 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -2,6 +2,58 @@ import { create } from 'zustand'; import { getMyInfo, refreshAccessToken } from '@/apis/auth'; import type { MyInfoResponse } from '@/apis/auth/entity'; +let initializePromise: Promise | null = null; +let hydrateUserPromise: Promise | null = null; + +const isAccessTokenExpired = (accessToken: string | null) => { + if (!accessToken) return true; + + const [, payload] = accessToken.split('.'); + if (!payload) return true; + + try { + const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (normalizedPayload.length % 4)) % 4); + const parsedPayload = JSON.parse(atob(`${normalizedPayload}${padding}`)) as { exp?: number }; + + return typeof parsedPayload.exp !== 'number' || parsedPayload.exp * 1000 <= Date.now(); + } catch { + return true; + } +}; + +const hydrateUser = async (nextAccessToken: string) => { + if (hydrateUserPromise) return hydrateUserPromise; + + hydrateUserPromise = (async () => { + try { + const nextUser = await getMyInfo(); + + if (useAuthStore.getState().accessToken !== nextAccessToken) return; + + useAuthStore.setState({ user: nextUser }); + + try { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'LOGIN_COMPLETE', accessToken: nextAccessToken }) + ); + } + } catch { + // 브릿지 전달 실패가 인증 성공 상태를 롤백시키지 않도록 무시 + } + } catch { + if (useAuthStore.getState().accessToken !== nextAccessToken) return; + + useAuthStore.setState({ user: null, accessToken: null, isAuthenticated: false }); + } finally { + hydrateUserPromise = null; + } + })(); + + return hydrateUserPromise; +}; + interface AuthState { user: MyInfoResponse | null; accessToken: string | null; @@ -21,29 +73,41 @@ export const useAuthStore = create((set, get) => ({ isLoading: true, initialize: async () => { - if (get().user) { + const { accessToken, isAuthenticated, user } = get(); + const hasValidAccessToken = !isAccessTokenExpired(accessToken); + + if (user) { + if (hasValidAccessToken) { + set({ isAuthenticated: true, isLoading: false }); + return; + } + } + + if (isAuthenticated && accessToken && hasValidAccessToken) { set({ isLoading: false }); + void hydrateUser(accessToken); return; } - try { - const accessToken = await refreshAccessToken(); - set({ accessToken }); - - const user = await getMyInfo(); - - set({ user, isAuthenticated: true, isLoading: false }); + if (initializePromise) { + return initializePromise; + } + initializePromise = (async () => { try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'LOGIN_COMPLETE', accessToken })); - } + const nextAccessToken = await refreshAccessToken(); + + // Open protected routes as soon as the access token is restored. + set({ accessToken: nextAccessToken, isAuthenticated: true, isLoading: false }); + void hydrateUser(nextAccessToken); } catch { - // 브릿지 전달 실패가 인증 성공 상태를 롤백시키지 않도록 무시 + set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false }); + } finally { + initializePromise = null; } - } catch { - set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false }); - } + })(); + + return initializePromise; }, setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false }), @@ -52,5 +116,9 @@ export const useAuthStore = create((set, get) => ({ getAccessToken: () => get().accessToken, - clearAuth: () => set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false }), + clearAuth: () => { + initializePromise = null; + hydrateUserPromise = null; + set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false }); + }, })); diff --git a/vite.config.ts b/vite.config.ts index 12ef5ec5..9d46bc07 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { sentryVitePlugin } from '@sentry/vite-plugin'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; +import { visualizer } from 'rollup-plugin-visualizer'; import { defineConfig } from 'vite'; import svgr from 'vite-plugin-svgr'; @@ -9,11 +10,25 @@ const sentryProject = process.env.SENTRY_PROJECT; const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; const sentryRelease = process.env.SENTRY_RELEASE; +const shouldAnalyzeBundle = process.env.ANALYZE === 'true'; const shouldUploadSourcemaps = Boolean(sentryOrg && sentryProject && sentryAuthToken); // https://vite.dev/config/ export default defineConfig({ build: { + rollupOptions: { + plugins: shouldAnalyzeBundle + ? [ + visualizer({ + brotliSize: true, + filename: 'docs/perf/assets/bundle-stats.html', + gzipSize: true, + open: false, + template: 'treemap', + }), + ] + : [], + }, sourcemap: shouldUploadSourcemaps ? 'hidden' : false, }, plugins: [ @@ -47,4 +62,7 @@ export default defineConfig({ server: { port: 3000, }, + preview: { + port: 3000, + }, });