From 2f3f4466b75b1b5b6db1ddc2803e180eaf413791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sat, 18 Jul 2026 23:13:03 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20WebView=20=EC=8A=A4=ED=83=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=84=ED=99=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 238 ++++++++++++++++++ web/package.json | 6 + .../components/Header/MobileDetailHeader.tsx | 6 + .../libs/stackflow/StackflowBackContext.tsx | 20 ++ web/src/libs/stackflow/navigation.ts | 82 ++++++ web/src/libs/stackflow/stackflow.config.ts | 61 +++++ web/src/libs/stackflow/stackflow.tsx | 178 +++++++++++++ web/src/main.tsx | 1 + .../cards/ChallengeCardBeforeStart.tsx | 2 + .../cards/ChallengeCardComingSoon.tsx | 2 + .../cards/ChallengeCardCompleted.tsx | 2 + .../cards/ChallengeCardOngoing.tsx | 10 + .../index/hooks/useChallengeDetailTabs.ts | 2 + .../components/PreviousArticles.tsx | 8 +- .../components/SimilarNewsletterCard.tsx | 2 + .../TrendySection/TrendySection.tsx | 2 + .../components/ArticleCard/ArticleCard.tsx | 12 +- web/src/routes/__root.tsx | 31 ++- web/src/routes/_bombom/_main.tsx | 17 +- .../_bombom/_main/challenge/$challengeId.tsx | 4 +- .../routes/_bombom/_main/challenge/index.tsx | 4 +- web/src/routes/_bombom/_main/index.tsx | 4 +- web/src/routes/_bombom/_main/today.tsx | 4 +- .../_bombom/articles.previous.$articleId.tsx | 4 +- .../routes/challenge/$challengeId/landing.tsx | 4 +- web/src/routes/newsletters.$newsletterId.tsx | 4 +- 26 files changed, 689 insertions(+), 21 deletions(-) create mode 100644 web/src/libs/stackflow/StackflowBackContext.tsx create mode 100644 web/src/libs/stackflow/navigation.ts create mode 100644 web/src/libs/stackflow/stackflow.config.ts create mode 100644 web/src/libs/stackflow/stackflow.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bae2d7a8f..d441e07da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,6 +469,24 @@ importers: '@sentry/react': specifier: ^10.0.0 version: 10.17.0(react@19.1.0) + '@stackflow/config': + specifier: ^2.0.0 + version: 2.0.0 + '@stackflow/core': + specifier: ^3.0.0 + version: 3.0.0 + '@stackflow/plugin-basic-ui': + specifier: ^1.18.3 + version: 1.18.3(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(babel-plugin-macros@3.1.0)(react@19.1.0) + '@stackflow/plugin-history-sync': + specifier: ^1.12.0 + version: 1.12.0(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0) + '@stackflow/plugin-renderer-basic': + specifier: ^1.1.15 + version: 1.1.15(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0) + '@stackflow/react': + specifier: ^2.1.2 + version: 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) '@suspensive/react': specifier: ^3.20.3 version: 3.20.3(react@19.1.0) @@ -2971,36 +2989,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -3077,56 +3101,67 @@ packages: resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.4': resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.4': resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.4': resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.4': resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.4': resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.4': resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.4': resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.4': resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.4': resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.4': resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.4': resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} @@ -3267,6 +3302,53 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@stackflow/config@2.0.0': + resolution: {integrity: sha512-EmRyxf124SCuRxx1WhL0leq6WwV7yloejr8WbLq+QhoGFWWRfRY5SyMnvk2zWOuLsUDIDaQWbmFhO+D+xbQorA==, tarball: https://registry.npmjs.org/@stackflow/config/-/config-2.0.0.tgz} + + '@stackflow/core@3.0.0': + resolution: {integrity: sha512-xK9sl1KTKAVoSrrPtFG8jFlcKJX+L65yII/8cibzQh0JQws+zi8TPOo66Ped9QhWdkx7N7AV+kOsPZ/PliCWtg==, tarball: https://registry.npmjs.org/@stackflow/core/-/core-3.0.0.tgz} + + '@stackflow/plugin-basic-ui@1.18.3': + resolution: {integrity: sha512-hTKqXwee+p4JkkbobwQob+tCoN1sZOZyGTxfwSFhsBgiGdulVq5hzU+spafnsEv7kQILUnY4nljtMBIFpqLLWQ==, tarball: https://registry.npmjs.org/@stackflow/plugin-basic-ui/-/plugin-basic-ui-1.18.3.tgz} + peerDependencies: + '@stackflow/core': ^3.0.0 + '@stackflow/react': ^2.0.0 + '@types/react': '>=16.8.0' + react: '>=16.8.0' + + '@stackflow/plugin-history-sync@1.12.0': + resolution: {integrity: sha512-RiqtpY+/7rvQFSJE8yov7G5g7RXKEua7VKVDylQsj1y2skjiUxP1CFDKvK1Hjg6ggOjo4qADjSCTFv6wG+rlkQ==, tarball: https://registry.npmjs.org/@stackflow/plugin-history-sync/-/plugin-history-sync-1.12.0.tgz} + peerDependencies: + '@stackflow/config': ^1.1.0 || ^2.0.0 + '@stackflow/core': ^3.0.0 + '@stackflow/react': ^1.0.0 || ^2.0.0 + '@types/react': '>=16.8.0' + react: '>=16.8.0' + + '@stackflow/plugin-renderer-basic@1.1.15': + resolution: {integrity: sha512-0O8sqM6WyOc65YLYD0i99bWt5xWm1yERHFBSuIvqrphnI/YJj1oCIJ788jBuRKHWVX2AS1AreNgGDqGxRATDeg==, tarball: https://registry.npmjs.org/@stackflow/plugin-renderer-basic/-/plugin-renderer-basic-1.1.15.tgz} + peerDependencies: + '@stackflow/core': ^3.0.0 + '@stackflow/react': ^2.0.0 + '@types/react': '>=16.8.0' + react: '>=16.8.0' + + '@stackflow/react-ui-core@1.3.6': + resolution: {integrity: sha512-RFDR0uICh2bRqRoyjLToyhcNM3P/84hXbYBQqFQpsb/BG5HmThrEcMY19EUZXwcgBuzepsMWk5HZ1Sfl0ZeCZw==, tarball: https://registry.npmjs.org/@stackflow/react-ui-core/-/react-ui-core-1.3.6.tgz} + peerDependencies: + '@stackflow/core': ^3.0.0 + '@stackflow/react': ^2.0.0 + '@types/react': '>=16.8.0' + react: '>=16.8.0' + + '@stackflow/react@2.1.2': + resolution: {integrity: sha512-6aXJLnENrR34Ko6xz1HGSJ4D4Fm9mnmDLjHIExROgC8kg518HR8cxnXVMJZ9I0wSsj8L6Tq/Como1kVYbRr+vA==, tarball: https://registry.npmjs.org/@stackflow/react/-/react-2.1.2.tgz} + peerDependencies: + '@stackflow/config': ^2.0.0 + '@stackflow/core': ^3.0.0 + '@types/react': '>=16.8.0' + react: '>=16.8.0' + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3493,24 +3575,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.14.0': resolution: {integrity: sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.14.0': resolution: {integrity: sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.14.0': resolution: {integrity: sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.14.0': resolution: {integrity: sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==} @@ -4189,6 +4275,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -4229,41 +4316,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4293,6 +4388,20 @@ packages: peerDependencies: '@urql/core': ^5.0.0 + '@vanilla-extract/css@1.21.1': + resolution: {integrity: sha512-T6z6oeT+o0OwqukE6kLs9w5ooB75b8NPRadyU9pPTml83l40TPN+mscPt2OQ9P9cYfwEBMfsfivwiJujs2mzmg==} + + '@vanilla-extract/dynamic@2.1.5': + resolution: {integrity: sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw==} + + '@vanilla-extract/private@1.0.9': + resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} + + '@vanilla-extract/recipes@0.5.7': + resolution: {integrity: sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A==} + peerDependencies: + '@vanilla-extract/css': ^1.0.0 + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5366,6 +5475,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -6544,6 +6656,9 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + history@5.3.0: + resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -7344,48 +7459,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -7529,6 +7652,9 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -7763,6 +7889,9 @@ packages: engines: {node: '>=10'} hasBin: true + modern-ahocorasick@1.1.0: + resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -8515,6 +8644,11 @@ packages: '@types/react': optional: true + react18-use@0.4.1: + resolution: {integrity: sha512-NY59qMAOxmpjg3XJGfSotwmH9BVAD1kB4kZqjfB28yr6jQKaGh1F5i7X1N9OTn6dD5r8yqSH3X4SslcqxAxQCw==} + peerDependencies: + react: '>=18.0.0' + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -9353,6 +9487,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -9523,6 +9658,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-pattern@1.0.3: + resolution: {integrity: sha512-uQcEj/2puA4aq1R3A2+VNVBgaWYR24FdWjl7VNW83rnWftlhyzOZ/tBjezRiC2UkIzuxC8Top3IekN3vUf1WxA==} + engines: {node: '>=0.12.0'} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -13305,6 +13444,61 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@stackflow/config@2.0.0': {} + + '@stackflow/core@3.0.0': + dependencies: + react-fast-compare: 3.2.2 + + '@stackflow/plugin-basic-ui@1.18.3(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(babel-plugin-macros@3.1.0)(react@19.1.0)': + dependencies: + '@stackflow/core': 3.0.0 + '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) + '@stackflow/react-ui-core': 1.3.6(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0) + '@types/react': 19.1.17 + '@vanilla-extract/css': 1.21.1(babel-plugin-macros@3.1.0) + '@vanilla-extract/dynamic': 2.1.5 + '@vanilla-extract/private': 1.0.9 + '@vanilla-extract/recipes': 0.5.7(@vanilla-extract/css@1.21.1(babel-plugin-macros@3.1.0)) + clsx: 2.1.1 + react: 19.1.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@stackflow/plugin-history-sync@1.12.0(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@stackflow/config': 2.0.0 + '@stackflow/core': 3.0.0 + '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) + '@types/react': 19.1.17 + flatted: 3.3.3 + history: 5.3.0 + react: 19.1.0 + react18-use: 0.4.1(react@19.1.0) + url-pattern: 1.0.3 + + '@stackflow/plugin-renderer-basic@1.1.15(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@stackflow/core': 3.0.0 + '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) + '@types/react': 19.1.17 + react: 19.1.0 + + '@stackflow/react-ui-core@1.3.6(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@stackflow/core': 3.0.0 + '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) + '@types/react': 19.1.17 + react: 19.1.0 + + '@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@stackflow/config': 2.0.0 + '@stackflow/core': 3.0.0 + '@types/react': 19.1.17 + react: 19.1.0 + react-fast-compare: 3.2.2 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -14657,6 +14851,32 @@ snapshots: '@urql/core': 5.2.0(graphql@16.11.0) wonka: 6.3.5 + '@vanilla-extract/css@1.21.1(babel-plugin-macros@3.1.0)': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.9 + css-what: 6.2.2 + csstype: 3.2.3 + dedent: 1.7.0(babel-plugin-macros@3.1.0) + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + + '@vanilla-extract/dynamic@2.1.5': + dependencies: + '@vanilla-extract/private': 1.0.9 + + '@vanilla-extract/private@1.0.9': {} + + '@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.21.1(babel-plugin-macros@3.1.0))': + dependencies: + '@vanilla-extract/css': 1.21.1(babel-plugin-macros@3.1.0) + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@babel/core': 7.28.4 @@ -15835,6 +16055,8 @@ snapshots: deep-is@0.1.4: {} + deep-object-diff@1.1.9: {} + deepmerge@4.3.1: {} default-browser-id@5.0.0: {} @@ -17415,6 +17637,10 @@ snapshots: dependencies: hermes-estree: 0.32.0 + history@5.3.0: + dependencies: + '@babel/runtime': 7.28.4 + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -18606,6 +18832,10 @@ snapshots: mdn-data@2.12.2: {} + media-query-parser@2.0.2: + dependencies: + '@babel/runtime': 7.28.4 + media-typer@0.3.0: {} memfs@3.5.3: @@ -19048,6 +19278,8 @@ snapshots: mkdirp@1.0.4: {} + modern-ahocorasick@1.1.0: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -19918,6 +20150,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 + react18-use@0.4.1(react@19.1.0): + dependencies: + react: 19.1.0 + react@19.1.0: {} readable-stream@2.3.8: @@ -21116,6 +21352,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-pattern@1.0.3: {} + use-callback-ref@1.3.3(@types/react@19.1.17)(react@19.1.0): dependencies: react: 19.1.0 diff --git a/web/package.json b/web/package.json index e6385eb89..ed132cfe7 100644 --- a/web/package.json +++ b/web/package.json @@ -90,6 +90,12 @@ "@lottiefiles/dotlottie-react": "^0.18.3", "@microsoft/clarity": "^1.0.0", "@sentry/react": "^10.0.0", + "@stackflow/config": "^2.0.0", + "@stackflow/core": "^3.0.0", + "@stackflow/plugin-basic-ui": "^1.18.3", + "@stackflow/plugin-history-sync": "^1.12.0", + "@stackflow/plugin-renderer-basic": "^1.1.15", + "@stackflow/react": "^2.1.2", "@suspensive/react": "^3.20.3", "@tanstack/react-query": "^5.82.0", "@tanstack/react-router": "^1.128.3", diff --git a/web/src/components/Header/MobileDetailHeader.tsx b/web/src/components/Header/MobileDetailHeader.tsx index a3d6d46f9..d16c6e663 100644 --- a/web/src/components/Header/MobileDetailHeader.tsx +++ b/web/src/components/Header/MobileDetailHeader.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { useRouter } from '@tanstack/react-router'; import ChevronIcon from '@/components/icons/ChevronIcon'; +import { useStackflowBack } from '@/libs/stackflow/StackflowBackContext'; import type { ReactNode } from 'react'; interface MobileDetailHeaderProps { @@ -10,8 +11,13 @@ interface MobileDetailHeaderProps { const MobileDetailHeader = ({ left, right }: MobileDetailHeaderProps) => { const router = useRouter(); + const stackflowBack = useStackflowBack(); const handleBackClick = () => { + if (stackflowBack) { + stackflowBack(); + return; + } router.history.back(); }; diff --git a/web/src/libs/stackflow/StackflowBackContext.tsx b/web/src/libs/stackflow/StackflowBackContext.tsx new file mode 100644 index 000000000..c08595529 --- /dev/null +++ b/web/src/libs/stackflow/StackflowBackContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +const StackflowBackContext = createContext<(() => void) | null>(null); + +interface StackflowBackProviderProps { + children: ReactNode; + onBack: () => void; +} + +export const StackflowBackProvider = ({ + children, + onBack, +}: StackflowBackProviderProps) => ( + + {children} + +); + +export const useStackflowBack = () => useContext(StackflowBackContext); diff --git a/web/src/libs/stackflow/navigation.ts b/web/src/libs/stackflow/navigation.ts new file mode 100644 index 000000000..1d1bd1815 --- /dev/null +++ b/web/src/libs/stackflow/navigation.ts @@ -0,0 +1,82 @@ +import { isWebView } from '@/utils/device'; +import type { Actions } from '@stackflow/react'; + +const withStackflowActions = async (callback: (actions: Actions) => void) => { + const { webViewStackActions } = await import('./stackflow.tsx'); + callback(webViewStackActions); +}; + +export const pushWebViewArticle = (articleId: string) => { + if (!isWebView()) return false; + void withStackflowActions((actions) => { + actions.push('ArticleActivity', { articleId }); + }); + return true; +}; + +export const pushWebViewPreviousArticle = (articleId: string) => { + if (!isWebView()) return false; + void withStackflowActions((actions) => { + actions.push('PreviousArticleActivity', { articleId }); + }); + return true; +}; + +const CHALLENGE_TAB_ACTIVITY = { + dashboard: 'ChallengeDashboardActivity', + daily: 'ChallengeDailyActivity', + comments: 'ChallengeCommentsActivity', + review: 'ChallengeReviewActivity', + certification: 'ChallengeCertificationActivity', +} as const; + +export const replaceWebViewChallengeTab = ( + challengeId: string, + tab: keyof typeof CHALLENGE_TAB_ACTIVITY, +) => { + if (!isWebView()) return false; + void withStackflowActions((actions) => { + actions.replace( + CHALLENGE_TAB_ACTIVITY[tab], + { challengeId }, + { + animate: false, + }, + ); + }); + return true; +}; + +export const pushWebViewNewsletter = (newsletterId: string) => { + if (!isWebView()) return false; + void withStackflowActions((actions) => { + actions.push('NewsletterActivity', { newsletterId }); + }); + return true; +}; + +export const pushWebViewChallengeLanding = (challengeId: string) => { + if (!isWebView()) return false; + void withStackflowActions((actions) => { + actions.push('ChallengeLandingActivity', { challengeId }); + }); + return true; +}; + +export const pushWebViewChallengeDetail = ( + challengeId: string, + tab: 'certification' | 'daily', +) => { + if (!isWebView()) return false; + + if (tab === 'certification') { + void withStackflowActions((actions) => { + actions.push('ChallengeCertificationActivity', { challengeId }); + }); + } else { + void withStackflowActions((actions) => { + actions.push('ChallengeDailyActivity', { challengeId }); + }); + } + return true; +}; diff --git a/web/src/libs/stackflow/stackflow.config.ts b/web/src/libs/stackflow/stackflow.config.ts new file mode 100644 index 000000000..864b66a07 --- /dev/null +++ b/web/src/libs/stackflow/stackflow.config.ts @@ -0,0 +1,61 @@ +import { defineConfig } from '@stackflow/config'; + +declare module '@stackflow/config' { + interface Register { + RecommendActivity: Record; + TodayActivity: Record; + BookmarkActivity: Record; + MemoActivity: Record; + ArticleActivity: { articleId: string }; + PreviousArticleActivity: { articleId: string }; + NewsletterActivity: { newsletterId: string }; + ChallengeListActivity: Record; + ChallengeLandingActivity: { challengeId: string }; + ChallengeDashboardActivity: { challengeId: string }; + ChallengeDailyActivity: { challengeId: string }; + ChallengeCommentsActivity: { challengeId: string }; + ChallengeReviewActivity: { challengeId: string }; + ChallengeCertificationActivity: { challengeId: string }; + } +} + +export const stackflowConfig = defineConfig({ + activities: [ + { name: 'RecommendActivity', route: '/' }, + { name: 'TodayActivity', route: '/today' }, + { name: 'BookmarkActivity', route: '/bookmark' }, + { name: 'MemoActivity', route: '/memo' }, + { name: 'ArticleActivity', route: '/articles/:articleId' }, + { + name: 'PreviousArticleActivity', + route: '/articles/previous/:articleId', + }, + { name: 'NewsletterActivity', route: '/newsletters/:newsletterId' }, + { name: 'ChallengeListActivity', route: '/challenge' }, + { + name: 'ChallengeLandingActivity', + route: '/challenge/:challengeId/landing', + }, + { + name: 'ChallengeDashboardActivity', + route: '/challenge/:challengeId/dashboard', + }, + { + name: 'ChallengeDailyActivity', + route: '/challenge/:challengeId/daily', + }, + { + name: 'ChallengeCommentsActivity', + route: '/challenge/:challengeId/comments', + }, + { + name: 'ChallengeReviewActivity', + route: '/challenge/:challengeId/review', + }, + { + name: 'ChallengeCertificationActivity', + route: '/challenge/:challengeId/certification', + }, + ], + transitionDuration: 320, +}); diff --git a/web/src/libs/stackflow/stackflow.tsx b/web/src/libs/stackflow/stackflow.tsx new file mode 100644 index 000000000..eb9fd62d1 --- /dev/null +++ b/web/src/libs/stackflow/stackflow.tsx @@ -0,0 +1,178 @@ +import { basicUIPlugin, AppScreen } from '@stackflow/plugin-basic-ui'; +import { historySyncPlugin } from '@stackflow/plugin-history-sync'; +import { basicRendererPlugin } from '@stackflow/plugin-renderer-basic'; +import { + stackflow, + useActivity, + useFlow, + type ActivityComponentType, +} from '@stackflow/react'; +import { stackflowConfig } from './stackflow.config'; +import { StackflowBackProvider } from './StackflowBackContext'; +import AppInstallPromptModal from '@/components/AppInstallPromptModal/AppInstallPromptModal'; +import BomBomPageLayout from '@/components/PageLayout/BomBomPageLayout'; +import { MainPageLayout } from '@/routes/_bombom/_main'; +import { Route as BookmarkRoute } from '@/routes/_bombom/_main/bookmark'; +import { Route as ChallengeDetailRoute } from '@/routes/_bombom/_main/challenge/$challengeId'; +import { Route as ChallengeListRoute } from '@/routes/_bombom/_main/challenge/index'; +import { Route as RecommendRoute } from '@/routes/_bombom/_main/index'; +import { Route as MemoRoute } from '@/routes/_bombom/_main/memo'; +import { Route as TodayRoute } from '@/routes/_bombom/_main/today'; +import { Route as ArticleDetailRoute } from '@/routes/_bombom/articles.$articleId'; +import { Route as PreviousArticleRoute } from '@/routes/_bombom/articles.previous.$articleId'; +import { Route as ChallengeLandingRoute } from '@/routes/challenge/$challengeId/landing'; +import { Route as NewsletterDetailRoute } from '@/routes/newsletters.$newsletterId'; +import type { ComponentType, ReactNode } from 'react'; + +const RecommendPage = RecommendRoute.options.component as ComponentType; +const TodayPage = TodayRoute.options.component as ComponentType; +const BookmarkPage = BookmarkRoute.options.component as ComponentType; +const MemoPage = MemoRoute.options.component as ComponentType; +const ChallengeListPage = ChallengeListRoute.options.component as ComponentType; +const ChallengeDetailPage = ChallengeDetailRoute.options + .component as ComponentType; +const ArticleDetailPage = ArticleDetailRoute.options.component as ComponentType; +const PreviousArticlePage = PreviousArticleRoute.options + .component as ComponentType; +const ChallengeLandingPage = ChallengeLandingRoute.options + .component as ComponentType; +const NewsletterDetailPage = NewsletterDetailRoute.options + .component as ComponentType; + +interface ActivityScreenProps { + children: ReactNode; + isMain?: boolean; + withBomBomLayout?: boolean; +} + +const ActivityScreen = ({ + children, + isMain = false, + withBomBomLayout = true, +}: ActivityScreenProps) => { + const activity = useActivity(); + const { pop } = useFlow(); + + const handleBack = () => { + if (activity.isRoot) { + window.history.back(); + return; + } + pop(); + }; + + const content = isMain ? ( + {children} + ) : ( + children + ); + + return ( + + + {withBomBomLayout ? ( + + {content} + + + ) : ( + content + )} + + + ); +}; + +const RecommendActivity: ActivityComponentType<'RecommendActivity'> = () => ( + + + +); + +const TodayActivity: ActivityComponentType<'TodayActivity'> = () => ( + + + +); + +const BookmarkActivity: ActivityComponentType<'BookmarkActivity'> = () => ( + + + +); + +const MemoActivity: ActivityComponentType<'MemoActivity'> = () => ( + + + +); + +const ArticleActivity: ActivityComponentType<'ArticleActivity'> = () => ( + + + +); + +const PreviousArticleActivity: ActivityComponentType< + 'PreviousArticleActivity' +> = () => ( + + + +); + +const NewsletterActivity: ActivityComponentType<'NewsletterActivity'> = () => ( + + + +); + +const ChallengeListActivity: ActivityComponentType< + 'ChallengeListActivity' +> = () => ( + + + +); + +const ChallengeLandingActivity: ActivityComponentType< + 'ChallengeLandingActivity' +> = () => ( + + + +); + +const ChallengeDetailActivity = () => ( + + + +); + +export const { Stack: WebViewStack, actions: webViewStackActions } = stackflow({ + config: stackflowConfig, + components: { + RecommendActivity, + TodayActivity, + BookmarkActivity, + MemoActivity, + ArticleActivity, + PreviousArticleActivity, + NewsletterActivity, + ChallengeListActivity, + ChallengeLandingActivity, + ChallengeDashboardActivity: ChallengeDetailActivity, + ChallengeDailyActivity: ChallengeDetailActivity, + ChallengeCommentsActivity: ChallengeDetailActivity, + ChallengeReviewActivity: ChallengeDetailActivity, + ChallengeCertificationActivity: ChallengeDetailActivity, + }, + plugins: [ + basicRendererPlugin(), + basicUIPlugin({ theme: 'cupertino' }), + historySyncPlugin({ + config: stackflowConfig, + fallbackActivity: () => 'RecommendActivity', + }), + ], +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index a319c7bab..3def9989b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -24,6 +24,7 @@ import NotFound from './pages/system/components/NotFound'; import { routeTree } from './routeTree.gen'; import reset from './styles/reset'; import { isProduction } from './utils/environment'; +import '@stackflow/plugin-basic-ui/index.css'; if (isProduction) Clarity.init(ENV.clarityProjectId); diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx index e01010560..7ed707212 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx @@ -12,6 +12,7 @@ import Flex from '@/components/Flex'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { pushWebViewChallengeLanding } from '@/libs/stackflow/navigation'; import useChallengeApplyMutation from '@/pages/challenge/index/hooks/useChallengeApplyMutation'; import useChallengeCancelMutation from '@/pages/challenge/index/hooks/useChallengeCancelMutation'; import { getDatesDiff } from '@/utils/date'; @@ -53,6 +54,7 @@ const ChallengeCardBeforeStart = (props: ChallengeCardProps) => { 'https://maroon-geranium-880.notion.site/1-2fb03dcf20568089a20ad05cd3de78fe?pvs=74', ); } else { + if (pushWebViewChallengeLanding(String(id))) return; navigate({ to: '/challenge/$challengeId/landing', params: { challengeId: id.toString() }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx index a2af622e6..f8e8f87ec 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx @@ -5,6 +5,7 @@ import CardFooter from '../CardFooter'; import CardHeader from '../CardHeader'; import Text from '@/components/Text'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { pushWebViewChallengeLanding } from '@/libs/stackflow/navigation'; import { getDatesDiff } from '@/utils/date'; import { openExternalLink } from '@/utils/externalLink'; import type { ChallengeCardProps } from '../ChallengeCard'; @@ -28,6 +29,7 @@ const ChallengeCardComingSoon = (props: ChallengeCardProps) => { 'https://maroon-geranium-880.notion.site/1-2fb03dcf20568089a20ad05cd3de78fe?pvs=74', ); } else { + if (pushWebViewChallengeLanding(String(id))) return; navigate({ to: '/challenge/$challengeId/landing', params: { challengeId: id.toString() }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx index 3041aaaf1..3c73e8e2e 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx @@ -5,6 +5,7 @@ import { CardDetailButton, Tag, Title } from '../CardElements'; import CardFooter from '../CardFooter'; import CardHeader from '../CardHeader'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { pushWebViewChallengeDetail } from '@/libs/stackflow/navigation'; import type { ChallengeCardProps } from '../ChallengeCard'; type GradeType = 'GOLD' | 'SILVER' | 'BRONZE'; @@ -30,6 +31,7 @@ const ChallengeCardCompleted = (props: ChallengeCardProps) => { label: title, }); + if (pushWebViewChallengeDetail(String(id), 'certification')) return; navigate({ to: '/challenge/$challengeId/certification', params: { challengeId: String(id) }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx index 816e988ff..88c0c2cff 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx @@ -13,6 +13,10 @@ import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; import ProgressBar from '@/components/ProgressBar/ProgressBar'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { + pushWebViewChallengeDetail, + pushWebViewChallengeLanding, +} from '@/libs/stackflow/navigation'; import useChallengeApplyMutation from '@/pages/challenge/index/hooks/useChallengeApplyMutation'; import type { ChallengeCardProps } from '../ChallengeCard'; @@ -41,6 +45,12 @@ const ChallengeCardOngoing = (props: ChallengeCardProps) => { label: title, }); + const challengeId = String(id); + const handled = participationInfo?.isJoined + ? pushWebViewChallengeDetail(challengeId, 'daily') + : pushWebViewChallengeLanding(challengeId); + if (handled) return; + navigate({ to: participationInfo?.isJoined ? '/challenge/$challengeId' diff --git a/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts b/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts index 6a1ba9875..a3a14b56a 100644 --- a/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts +++ b/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts @@ -1,5 +1,6 @@ import { useNavigate, useRouterState } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; +import { replaceWebViewChallengeTab } from '@/libs/stackflow/navigation'; const CHALLENGE_TABS = [ { id: 'review', label: '리뷰', path: 'review' }, @@ -49,6 +50,7 @@ export const useChallengeDetailTabs = ({ const goToTab = useCallback( (tabPath: ChallengeTabPath) => { + if (replaceWebViewChallengeTab(challengeId, tabPath)) return; navigate({ to: `/challenge/$challengeId/${tabPath}`, params: { challengeId }, diff --git a/web/src/pages/newsletter-detail/components/PreviousArticles.tsx b/web/src/pages/newsletter-detail/components/PreviousArticles.tsx index 79f485147..4233a3320 100644 --- a/web/src/pages/newsletter-detail/components/PreviousArticles.tsx +++ b/web/src/pages/newsletter-detail/components/PreviousArticles.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import { useNavigate } from '@tanstack/react-router'; import PreviousArticleListItem from './PreviousArticleListItem'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { pushWebViewPreviousArticle } from '@/libs/stackflow/navigation'; import { openExternalLink } from '@/utils/externalLink'; import type { GetPreviousArticlesResponse } from '@/apis/previousArticles/previousArticles.api'; import sadBom from '#/assets/avif/sad-bom.avif'; @@ -43,15 +44,16 @@ const PreviousArticles = ({ title={article.title} contentsSummary={article.contentsSummary} expectedReadTime={article.expectedReadTime} - onClick={() => + onClick={() => { + if (pushWebViewPreviousArticle(String(article.articleId))) return; navigate({ to: '/articles/previous/$articleId', params: { articleId: String(article.articleId) }, state: { subscribeUrl: newsletterSubscribeUrl, }, - }) - } + }); + }} /> )); } diff --git a/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx b/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx index 9779b8b96..f88bf0f26 100644 --- a/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx +++ b/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; import { useNavigate } from '@tanstack/react-router'; import ImageWithFallback from '@/components/ImageWithFallback/ImageWithFallback'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { pushWebViewNewsletter } from '@/libs/stackflow/navigation'; import type { Newsletter } from '@/types/newsletter'; interface SimilarNewsletterCardProps { @@ -17,6 +18,7 @@ const SimilarNewsletterCard = ({ newsletter }: SimilarNewsletterCardProps) => { action: '비슷한 뉴스레터 카드 클릭', label: newsletter.name, }); + if (pushWebViewNewsletter(String(newsletter.newsletterId))) return; navigate({ to: '/newsletters/$newsletterId', params: { newsletterId: String(newsletter.newsletterId) }, diff --git a/web/src/pages/recommend/components/TrendySection/TrendySection.tsx b/web/src/pages/recommend/components/TrendySection/TrendySection.tsx index e3cf18375..ce1897293 100644 --- a/web/src/pages/recommend/components/TrendySection/TrendySection.tsx +++ b/web/src/pages/recommend/components/TrendySection/TrendySection.tsx @@ -10,6 +10,7 @@ import SearchInput from '@/components/SearchInput/SearchInput'; import { NEWSLETTER_COUNT } from '@/constants/newsletter'; import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; +import { pushWebViewNewsletter } from '@/libs/stackflow/navigation'; import type { Device } from '@/hooks/useDevice'; import type { Newsletter } from '@/types/newsletter'; import type { ChangeEvent } from 'react'; @@ -65,6 +66,7 @@ const TrendySection = () => { action: '뉴스레터 카드 클릭', label: newsletter.name ?? 'Unknown Newsletter', }); + if (pushWebViewNewsletter(String(newsletter.newsletterId))) return; navigate({ to: '/newsletters/$newsletterId', params: { newsletterId: String(newsletter.newsletterId) }, diff --git a/web/src/pages/today/components/ArticleCard/ArticleCard.tsx b/web/src/pages/today/components/ArticleCard/ArticleCard.tsx index 7031586e5..d00c33843 100644 --- a/web/src/pages/today/components/ArticleCard/ArticleCard.tsx +++ b/web/src/pages/today/components/ArticleCard/ArticleCard.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'; import Badge from '@/components/Badge/Badge'; import ImageWithFallback from '@/components/ImageWithFallback/ImageWithFallback'; import { useDevice } from '@/hooks/useDevice'; +import { pushWebViewArticle } from '@/libs/stackflow/navigation'; import { formatDate } from '@/utils/date'; import type { components } from '@/types/openapi'; import type { ComponentProps } from 'react'; @@ -36,14 +37,23 @@ const ArticleCard = ({ } = data; const device = useDevice(); const isMobile = device === 'mobile'; + const articlePath = to ?? `/articles/${articleId}`; + + const handleClick: ArticleCardProps['onClick'] = (event) => { + props.onClick?.(event); + if (event.defaultPrevented || to || !pushWebViewArticle(String(articleId))) + return; + event.preventDefault(); + }; return ( {title} diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 759fc3989..da27737ff 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -5,8 +5,10 @@ import { createRootRouteWithContext, HeadContent, Outlet, + useRouterState, } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; +import { lazy, Suspense } from 'react'; import Toast from '@/components/Toast/Toast'; import { AuthProvider } from '@/contexts/AuthContext'; import { useChannelTalk } from '@/hooks/useChannelTalk'; @@ -15,6 +17,7 @@ import usePageTracking from '@/libs/googleAnalytics/usePageTracking'; import { useWebViewAuth } from '@/libs/webview/useWebViewAuth'; import { useWebViewRouting } from '@/libs/webview/useWebViewRouting'; import { queryClient } from '@/main'; +import { isWebView } from '@/utils/device'; import type { QueryClient } from '@tanstack/react-query'; import type { redirect } from '@tanstack/react-router'; @@ -26,8 +29,26 @@ interface BomBomRouterContext { // 채널톡 버튼은 PC에서만 노출되므로 PC에서만 간격을 띄운다. const CHANNEL_TALK_TOAST_OFFSET = 96; +const WebViewStack = lazy(async () => { + const stackflowModule = await import('../libs/stackflow/stackflow.tsx'); + return { default: stackflowModule.WebViewStack }; +}); + +const STACKFLOW_PATHS = [ + /^\/$/, + /^\/today$/, + /^\/(bookmark|memo)$/, + /^\/articles\/(previous\/)?[^/]+$/, + /^\/newsletters\/[^/]+$/, + /^\/challenge$/, + /^\/challenge\/[^/]+\/(landing|dashboard|daily|comments|review|certification)$/, +]; + const RootComponent = () => { const device = useDevice(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); usePageTracking(); useWebViewAuth(); @@ -35,13 +56,21 @@ const RootComponent = () => { useChannelTalk(); const toastOffset = device === 'pc' ? CHANNEL_TALK_TOAST_OFFSET : undefined; + const shouldUseStackflow = + isWebView() && STACKFLOW_PATHS.some((pattern) => pattern.test(pathname)); return ( <> - + {shouldUseStackflow ? ( + + + + ) : ( + + )} diff --git a/web/src/routes/_bombom/_main.tsx b/web/src/routes/_bombom/_main.tsx index d67f690b3..0a985e959 100644 --- a/web/src/routes/_bombom/_main.tsx +++ b/web/src/routes/_bombom/_main.tsx @@ -4,12 +4,25 @@ import HeaderNavButtons from '@/components/Header/HeaderNavButtons'; import MobileMainHeader from '@/components/Header/MobileMainHeader'; import { useActiveNav } from '@/hooks/useActiveNav'; import { useDevice } from '@/hooks/useDevice'; +import type { ReactNode } from 'react'; export const Route = createFileRoute('/_bombom/_main')({ component: RouteComponent, }); function RouteComponent() { + return ( + + + + ); +} + +interface MainPageLayoutProps { + children: ReactNode; +} + +export const MainPageLayout = ({ children }: MainPageLayoutProps) => { const activeNav = useActiveNav(); const device = useDevice(); const isPC = device === 'pc'; @@ -17,7 +30,7 @@ function RouteComponent() { return ( <> {!isPC && } - + {children} {!isPC && ( <> @@ -28,7 +41,7 @@ function RouteComponent() { )} ); -} +}; const BottomNavSafeArea = styled.div` width: 100%; diff --git a/web/src/routes/_bombom/_main/challenge/$challengeId.tsx b/web/src/routes/_bombom/_main/challenge/$challengeId.tsx index 138ebb343..5017bda9d 100644 --- a/web/src/routes/_bombom/_main/challenge/$challengeId.tsx +++ b/web/src/routes/_bombom/_main/challenge/$challengeId.tsx @@ -23,10 +23,10 @@ export const Route = createFileRoute('/_bombom/_main/challenge/$challengeId')({ }, ], }), - component: ChallengeDetail, + component: ChallengeDetailPage, }); -function ChallengeDetail() { +function ChallengeDetailPage() { const { challengeId } = Route.useParams(); const device = useDevice(); diff --git a/web/src/routes/_bombom/_main/challenge/index.tsx b/web/src/routes/_bombom/_main/challenge/index.tsx index 0b2f89967..687efb1c1 100644 --- a/web/src/routes/_bombom/_main/challenge/index.tsx +++ b/web/src/routes/_bombom/_main/challenge/index.tsx @@ -15,10 +15,10 @@ export const Route = createFileRoute('/_bombom/_main/challenge/')({ }, ], }), - component: RouteComponent, + component: ChallengeListPage, }); -function RouteComponent() { +function ChallengeListPage() { const device = useDevice(); const { data: challenges } = useQuery(queries.challenges()); diff --git a/web/src/routes/_bombom/_main/index.tsx b/web/src/routes/_bombom/_main/index.tsx index 846d401c2..97fc807df 100644 --- a/web/src/routes/_bombom/_main/index.tsx +++ b/web/src/routes/_bombom/_main/index.tsx @@ -20,10 +20,10 @@ export const Route = createFileRoute('/_bombom/_main/')({ }, ], }), - component: Index, + component: RecommendPage, }); -function Index() { +function RecommendPage() { const device = useDevice(); const { data: notices } = useQuery(queries.notices()); diff --git a/web/src/routes/_bombom/_main/today.tsx b/web/src/routes/_bombom/_main/today.tsx index 3e3c808b1..151e046d3 100644 --- a/web/src/routes/_bombom/_main/today.tsx +++ b/web/src/routes/_bombom/_main/today.tsx @@ -37,12 +37,12 @@ export const Route = createFileRoute('/_bombom/_main/today')({ }), component: () => ( - + ), }); -function Index() { +function TodayPage() { const today = useMemo(() => new Date(), []); const todayDateStr = useMemo(() => formatDate(today, '-'), [today]); diff --git a/web/src/routes/_bombom/articles.previous.$articleId.tsx b/web/src/routes/_bombom/articles.previous.$articleId.tsx index 77ff59fc3..2f313ec49 100644 --- a/web/src/routes/_bombom/articles.previous.$articleId.tsx +++ b/web/src/routes/_bombom/articles.previous.$articleId.tsx @@ -27,10 +27,10 @@ export const Route = createFileRoute('/_bombom/articles/previous/$articleId')({ }, ], }), - component: RouteComponent, + component: PreviousArticlePage, }); -function RouteComponent() { +function PreviousArticlePage() { const device = useDevice(); const { userProfile, isLoggedIn } = useAuth(); const { articleId } = Route.useParams(); diff --git a/web/src/routes/challenge/$challengeId/landing.tsx b/web/src/routes/challenge/$challengeId/landing.tsx index 274714dc2..6bf112b7c 100644 --- a/web/src/routes/challenge/$challengeId/landing.tsx +++ b/web/src/routes/challenge/$challengeId/landing.tsx @@ -35,10 +35,10 @@ export const Route = createFileRoute('/challenge/$challengeId/landing')({ }, ], }), - component: ChallengeLanding, + component: ChallengeLandingPage, }); -function ChallengeLanding() { +function ChallengeLandingPage() { const device = useDevice(); const { challengeId: stringChallengeId } = useParams({ from: '/challenge/$challengeId/landing', diff --git a/web/src/routes/newsletters.$newsletterId.tsx b/web/src/routes/newsletters.$newsletterId.tsx index 746c32a44..f4f5b10d6 100644 --- a/web/src/routes/newsletters.$newsletterId.tsx +++ b/web/src/routes/newsletters.$newsletterId.tsx @@ -63,7 +63,7 @@ export const Route = createFileRoute('/newsletters/$newsletterId')({ links: [{ rel: 'canonical', href: url }], }; }, - component: NewsletterDetailRoute, + component: NewsletterDetailPage, pendingComponent: NewsletterDetailPending, validateSearch: (search: NewsletterDetailSearch & SearchSchemaInput) => ({ tab: search?.tab, @@ -79,7 +79,7 @@ function NewsletterDetailPending() { ); } -function NewsletterDetailRoute() { +function NewsletterDetailPage() { const { newsletterId } = Route.useParams(); const id = Number(newsletterId); const device = useDevice(); From 430708a215a9672c6bf4a173495ce01c1271fd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sat, 18 Jul 2026 23:13:11 +0900 Subject: [PATCH 2/8] =?UTF-8?q?style:=20=EC=9B=B9=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=90=EB=8F=99=20=EC=88=98=EC=A0=95=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyReadingCalendar.tsx | 4 ---- .../ReadingActivityStats/MonthlyReportStats.tsx | 14 ++++++-------- web/src/pages/system/components/NotFoundCard.tsx | 2 +- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx index b63f62201..ffe9f5195 100644 --- a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx +++ b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx @@ -157,7 +157,6 @@ const Weekday = styled(Text)` const Divider = styled.div` height: 1px; - background-color: ${COLORS.divider}; `; @@ -168,7 +167,6 @@ const Grid = styled.div` const DayCell = styled.div` position: relative; - height: 28px; display: flex; @@ -186,7 +184,6 @@ const MostReadCircle = styled(Text)` position: absolute; top: 50%; left: 50%; - width: 32px; height: 32px; border-radius: 50%; @@ -204,7 +201,6 @@ const ReadUnderline = styled.span` position: absolute; bottom: 0; left: 50%; - width: 26px; height: 4px; border-radius: 2px; diff --git a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx index 1d97af5ca..59f2bba63 100644 --- a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx +++ b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx @@ -146,9 +146,8 @@ const IconBox = styled.div` height: 40px; border-radius: 10px; - flex-shrink: 0; - display: flex; + flex-shrink: 0; align-items: center; justify-content: center; @@ -191,11 +190,12 @@ const StatUnit = styled(Text)` `; const ChangePill = styled(Text)` - align-self: flex-start; padding: 6px 12px; border: 1px solid ${COLORS.pillBorder}; border-radius: 12px; + align-self: flex-start; + && { color: ${COLORS.pillText}; } @@ -249,9 +249,8 @@ const RankBadge = styled(Text)` height: 18px; border-radius: 50%; - flex-shrink: 0; - display: flex; + flex-shrink: 0; align-items: center; justify-content: center; @@ -259,12 +258,12 @@ const RankBadge = styled(Text)` `; const NewsletterName = styled(Text)` + overflow: hidden; min-width: 0; - overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; - white-space: nowrap; && { color: ${COLORS.newsletterText}; @@ -273,7 +272,6 @@ const NewsletterName = styled(Text)` const NewsletterCount = styled(Text)` margin-left: auto; - flex-shrink: 0; && { diff --git a/web/src/pages/system/components/NotFoundCard.tsx b/web/src/pages/system/components/NotFoundCard.tsx index f5958af54..9edba29f7 100644 --- a/web/src/pages/system/components/NotFoundCard.tsx +++ b/web/src/pages/system/components/NotFoundCard.tsx @@ -67,8 +67,8 @@ const DescriptionWrapper = styled.div` const Description = styled.p``; const GoHomeButton = styled(Button)` - margin-top: 12px; max-width: 380px; + margin-top: 12px; font: ${({ theme }) => theme.fonts.t7Bold}; `; From 3e642f9bda707d0d030cb5a034c1f7c4884a3b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sat, 18 Jul 2026 23:27:23 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9B=B9=20=EC=8A=A4=ED=83=9D=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/libs/stackflow/navigation.ts | 18 ++++++++++++------ web/src/routes/__root.tsx | 3 ++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/web/src/libs/stackflow/navigation.ts b/web/src/libs/stackflow/navigation.ts index 1d1bd1815..79ac7eb8e 100644 --- a/web/src/libs/stackflow/navigation.ts +++ b/web/src/libs/stackflow/navigation.ts @@ -1,13 +1,19 @@ import { isWebView } from '@/utils/device'; import type { Actions } from '@stackflow/react'; +const MOBILE_MAX_WIDTH = 768; + +const isStackflowEnabled = () => + isWebView() || + window.matchMedia(`(max-width: ${MOBILE_MAX_WIDTH}px)`).matches; + const withStackflowActions = async (callback: (actions: Actions) => void) => { const { webViewStackActions } = await import('./stackflow.tsx'); callback(webViewStackActions); }; export const pushWebViewArticle = (articleId: string) => { - if (!isWebView()) return false; + if (!isStackflowEnabled()) return false; void withStackflowActions((actions) => { actions.push('ArticleActivity', { articleId }); }); @@ -15,7 +21,7 @@ export const pushWebViewArticle = (articleId: string) => { }; export const pushWebViewPreviousArticle = (articleId: string) => { - if (!isWebView()) return false; + if (!isStackflowEnabled()) return false; void withStackflowActions((actions) => { actions.push('PreviousArticleActivity', { articleId }); }); @@ -34,7 +40,7 @@ export const replaceWebViewChallengeTab = ( challengeId: string, tab: keyof typeof CHALLENGE_TAB_ACTIVITY, ) => { - if (!isWebView()) return false; + if (!isStackflowEnabled()) return false; void withStackflowActions((actions) => { actions.replace( CHALLENGE_TAB_ACTIVITY[tab], @@ -48,7 +54,7 @@ export const replaceWebViewChallengeTab = ( }; export const pushWebViewNewsletter = (newsletterId: string) => { - if (!isWebView()) return false; + if (!isStackflowEnabled()) return false; void withStackflowActions((actions) => { actions.push('NewsletterActivity', { newsletterId }); }); @@ -56,7 +62,7 @@ export const pushWebViewNewsletter = (newsletterId: string) => { }; export const pushWebViewChallengeLanding = (challengeId: string) => { - if (!isWebView()) return false; + if (!isStackflowEnabled()) return false; void withStackflowActions((actions) => { actions.push('ChallengeLandingActivity', { challengeId }); }); @@ -67,7 +73,7 @@ export const pushWebViewChallengeDetail = ( challengeId: string, tab: 'certification' | 'daily', ) => { - if (!isWebView()) return false; + if (!isStackflowEnabled()) return false; if (tab === 'certification') { void withStackflowActions((actions) => { diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index da27737ff..2966e014c 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -57,7 +57,8 @@ const RootComponent = () => { const toastOffset = device === 'pc' ? CHANNEL_TALK_TOAST_OFFSET : undefined; const shouldUseStackflow = - isWebView() && STACKFLOW_PATHS.some((pattern) => pattern.test(pathname)); + (isWebView() || device === 'mobile') && + STACKFLOW_PATHS.some((pattern) => pattern.test(pathname)); return ( <> From 9fcc87a3fb9d1e893e2a1e682cbb19612abf60ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sat, 18 Jul 2026 23:32:48 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/libs/stackflow/navigation.ts | 44 +++++++++++++------ .../cards/ChallengeCardBeforeStart.tsx | 4 +- .../cards/ChallengeCardComingSoon.tsx | 4 +- .../cards/ChallengeCardCompleted.tsx | 11 ++++- .../cards/ChallengeCardOngoing.tsx | 6 ++- .../index/hooks/useChallengeDetailTabs.ts | 7 ++- .../components/PreviousArticles.tsx | 3 +- .../components/SimilarNewsletterCard.tsx | 10 ++++- .../TrendySection/TrendySection.tsx | 8 +++- .../components/ArticleCard/ArticleCard.tsx | 6 ++- 10 files changed, 78 insertions(+), 25 deletions(-) diff --git a/web/src/libs/stackflow/navigation.ts b/web/src/libs/stackflow/navigation.ts index 79ac7eb8e..62050a188 100644 --- a/web/src/libs/stackflow/navigation.ts +++ b/web/src/libs/stackflow/navigation.ts @@ -1,27 +1,35 @@ import { isWebView } from '@/utils/device'; import type { Actions } from '@stackflow/react'; -const MOBILE_MAX_WIDTH = 768; +const isStackflowEnabled = (isMobile: boolean) => isWebView() || isMobile; -const isStackflowEnabled = () => - isWebView() || - window.matchMedia(`(max-width: ${MOBILE_MAX_WIDTH}px)`).matches; +const syncTanStackRouter = () => { + requestAnimationFrame(() => { + window.dispatchEvent( + new PopStateEvent('popstate', { state: window.history.state }), + ); + }); +}; const withStackflowActions = async (callback: (actions: Actions) => void) => { const { webViewStackActions } = await import('./stackflow.tsx'); callback(webViewStackActions); + syncTanStackRouter(); }; -export const pushWebViewArticle = (articleId: string) => { - if (!isStackflowEnabled()) return false; +export const pushWebViewArticle = (articleId: string, isMobile: boolean) => { + if (!isStackflowEnabled(isMobile)) return false; void withStackflowActions((actions) => { actions.push('ArticleActivity', { articleId }); }); return true; }; -export const pushWebViewPreviousArticle = (articleId: string) => { - if (!isStackflowEnabled()) return false; +export const pushWebViewPreviousArticle = ( + articleId: string, + isMobile: boolean, +) => { + if (!isStackflowEnabled(isMobile)) return false; void withStackflowActions((actions) => { actions.push('PreviousArticleActivity', { articleId }); }); @@ -39,8 +47,9 @@ const CHALLENGE_TAB_ACTIVITY = { export const replaceWebViewChallengeTab = ( challengeId: string, tab: keyof typeof CHALLENGE_TAB_ACTIVITY, + isMobile: boolean, ) => { - if (!isStackflowEnabled()) return false; + if (!isStackflowEnabled(isMobile)) return false; void withStackflowActions((actions) => { actions.replace( CHALLENGE_TAB_ACTIVITY[tab], @@ -53,16 +62,22 @@ export const replaceWebViewChallengeTab = ( return true; }; -export const pushWebViewNewsletter = (newsletterId: string) => { - if (!isStackflowEnabled()) return false; +export const pushWebViewNewsletter = ( + newsletterId: string, + isMobile: boolean, +) => { + if (!isStackflowEnabled(isMobile)) return false; void withStackflowActions((actions) => { actions.push('NewsletterActivity', { newsletterId }); }); return true; }; -export const pushWebViewChallengeLanding = (challengeId: string) => { - if (!isStackflowEnabled()) return false; +export const pushWebViewChallengeLanding = ( + challengeId: string, + isMobile: boolean, +) => { + if (!isStackflowEnabled(isMobile)) return false; void withStackflowActions((actions) => { actions.push('ChallengeLandingActivity', { challengeId }); }); @@ -72,8 +87,9 @@ export const pushWebViewChallengeLanding = (challengeId: string) => { export const pushWebViewChallengeDetail = ( challengeId: string, tab: 'certification' | 'daily', + isMobile: boolean, ) => { - if (!isStackflowEnabled()) return false; + if (!isStackflowEnabled(isMobile)) return false; if (tab === 'certification') { void withStackflowActions((actions) => { diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx index 7ed707212..ba88ea107 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx @@ -11,6 +11,7 @@ import Button from '@/components/Button/Button'; import Flex from '@/components/Flex'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; +import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; import { pushWebViewChallengeLanding } from '@/libs/stackflow/navigation'; import useChallengeApplyMutation from '@/pages/challenge/index/hooks/useChallengeApplyMutation'; @@ -30,6 +31,7 @@ const ChallengeCardBeforeStart = (props: ChallengeCardProps) => { id, } = props; const navigate = useNavigate(); + const device = useDevice(); const { modalRef, openModal, closeModal, isOpen } = useModal(); const { mutate: applyChallenge } = useChallengeApplyMutation({ @@ -54,7 +56,7 @@ const ChallengeCardBeforeStart = (props: ChallengeCardProps) => { 'https://maroon-geranium-880.notion.site/1-2fb03dcf20568089a20ad05cd3de78fe?pvs=74', ); } else { - if (pushWebViewChallengeLanding(String(id))) return; + if (pushWebViewChallengeLanding(String(id), device === 'mobile')) return; navigate({ to: '/challenge/$challengeId/landing', params: { challengeId: id.toString() }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx index f8e8f87ec..dbfc76e00 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx @@ -4,6 +4,7 @@ import { CardDetailButton, Tag, Title } from '../CardElements'; import CardFooter from '../CardFooter'; import CardHeader from '../CardHeader'; import Text from '@/components/Text'; +import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; import { pushWebViewChallengeLanding } from '@/libs/stackflow/navigation'; import { getDatesDiff } from '@/utils/date'; @@ -13,6 +14,7 @@ import type { ChallengeCardProps } from '../ChallengeCard'; const ChallengeCardComingSoon = (props: ChallengeCardProps) => { const { generation, title, startDate, endDate, id } = props; const navigate = useNavigate(); + const device = useDevice(); const isWeeklyChallenge = getDatesDiff(new Date(startDate), new Date(endDate)) <= 7; @@ -29,7 +31,7 @@ const ChallengeCardComingSoon = (props: ChallengeCardProps) => { 'https://maroon-geranium-880.notion.site/1-2fb03dcf20568089a20ad05cd3de78fe?pvs=74', ); } else { - if (pushWebViewChallengeLanding(String(id))) return; + if (pushWebViewChallengeLanding(String(id), device === 'mobile')) return; navigate({ to: '/challenge/$challengeId/landing', params: { challengeId: id.toString() }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx index 3c73e8e2e..2b09e8390 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx @@ -4,6 +4,7 @@ import CardContainer from '../CardContainer'; import { CardDetailButton, Tag, Title } from '../CardElements'; import CardFooter from '../CardFooter'; import CardHeader from '../CardHeader'; +import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; import { pushWebViewChallengeDetail } from '@/libs/stackflow/navigation'; import type { ChallengeCardProps } from '../ChallengeCard'; @@ -18,6 +19,7 @@ const GRADE_CONFIG: Record = { const ChallengeCardCompleted = (props: ChallengeCardProps) => { const navigate = useNavigate(); + const device = useDevice(); const { participationInfo, id, generation, title } = props; @@ -31,7 +33,14 @@ const ChallengeCardCompleted = (props: ChallengeCardProps) => { label: title, }); - if (pushWebViewChallengeDetail(String(id), 'certification')) return; + if ( + pushWebViewChallengeDetail( + String(id), + 'certification', + device === 'mobile', + ) + ) + return; navigate({ to: '/challenge/$challengeId/certification', params: { challengeId: String(id) }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx index 88c0c2cff..cd65bc92d 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx @@ -12,6 +12,7 @@ import Flex from '@/components/Flex'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; import ProgressBar from '@/components/ProgressBar/ProgressBar'; +import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; import { pushWebViewChallengeDetail, @@ -22,6 +23,7 @@ import type { ChallengeCardProps } from '../ChallengeCard'; const ChallengeCardOngoing = (props: ChallengeCardProps) => { const navigate = useNavigate(); + const device = useDevice(); const { modalRef, openModal, closeModal, isOpen } = useModal(); const { @@ -47,8 +49,8 @@ const ChallengeCardOngoing = (props: ChallengeCardProps) => { const challengeId = String(id); const handled = participationInfo?.isJoined - ? pushWebViewChallengeDetail(challengeId, 'daily') - : pushWebViewChallengeLanding(challengeId); + ? pushWebViewChallengeDetail(challengeId, 'daily', device === 'mobile') + : pushWebViewChallengeLanding(challengeId, device === 'mobile'); if (handled) return; navigate({ diff --git a/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts b/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts index a3a14b56a..75b26a846 100644 --- a/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts +++ b/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts @@ -1,5 +1,6 @@ import { useNavigate, useRouterState } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; +import { useDevice } from '@/hooks/useDevice'; import { replaceWebViewChallengeTab } from '@/libs/stackflow/navigation'; const CHALLENGE_TABS = [ @@ -25,6 +26,7 @@ export const useChallengeDetailTabs = ({ isLastDayOrAfter, }: UseChallengeDetailTabsProps) => { const navigate = useNavigate(); + const device = useDevice(); const routerState = useRouterState(); const currentPath = routerState.location.pathname; @@ -50,13 +52,14 @@ export const useChallengeDetailTabs = ({ const goToTab = useCallback( (tabPath: ChallengeTabPath) => { - if (replaceWebViewChallengeTab(challengeId, tabPath)) return; + if (replaceWebViewChallengeTab(challengeId, tabPath, device === 'mobile')) + return; navigate({ to: `/challenge/$challengeId/${tabPath}`, params: { challengeId }, }); }, - [challengeId, navigate], + [challengeId, device, navigate], ); return { diff --git a/web/src/pages/newsletter-detail/components/PreviousArticles.tsx b/web/src/pages/newsletter-detail/components/PreviousArticles.tsx index 4233a3320..5265c4056 100644 --- a/web/src/pages/newsletter-detail/components/PreviousArticles.tsx +++ b/web/src/pages/newsletter-detail/components/PreviousArticles.tsx @@ -45,7 +45,8 @@ const PreviousArticles = ({ contentsSummary={article.contentsSummary} expectedReadTime={article.expectedReadTime} onClick={() => { - if (pushWebViewPreviousArticle(String(article.articleId))) return; + if (pushWebViewPreviousArticle(String(article.articleId), isMobile)) + return; navigate({ to: '/articles/previous/$articleId', params: { articleId: String(article.articleId) }, diff --git a/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx b/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx index f88bf0f26..a22568bab 100644 --- a/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx +++ b/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { useNavigate } from '@tanstack/react-router'; import ImageWithFallback from '@/components/ImageWithFallback/ImageWithFallback'; +import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; import { pushWebViewNewsletter } from '@/libs/stackflow/navigation'; import type { Newsletter } from '@/types/newsletter'; @@ -11,6 +12,7 @@ interface SimilarNewsletterCardProps { const SimilarNewsletterCard = ({ newsletter }: SimilarNewsletterCardProps) => { const navigate = useNavigate(); + const device = useDevice(); const handleClick = () => { trackEvent({ @@ -18,7 +20,13 @@ const SimilarNewsletterCard = ({ newsletter }: SimilarNewsletterCardProps) => { action: '비슷한 뉴스레터 카드 클릭', label: newsletter.name, }); - if (pushWebViewNewsletter(String(newsletter.newsletterId))) return; + if ( + pushWebViewNewsletter( + String(newsletter.newsletterId), + device === 'mobile', + ) + ) + return; navigate({ to: '/newsletters/$newsletterId', params: { newsletterId: String(newsletter.newsletterId) }, diff --git a/web/src/pages/recommend/components/TrendySection/TrendySection.tsx b/web/src/pages/recommend/components/TrendySection/TrendySection.tsx index ce1897293..9f68ed3b1 100644 --- a/web/src/pages/recommend/components/TrendySection/TrendySection.tsx +++ b/web/src/pages/recommend/components/TrendySection/TrendySection.tsx @@ -66,7 +66,13 @@ const TrendySection = () => { action: '뉴스레터 카드 클릭', label: newsletter.name ?? 'Unknown Newsletter', }); - if (pushWebViewNewsletter(String(newsletter.newsletterId))) return; + if ( + pushWebViewNewsletter( + String(newsletter.newsletterId), + device === 'mobile', + ) + ) + return; navigate({ to: '/newsletters/$newsletterId', params: { newsletterId: String(newsletter.newsletterId) }, diff --git a/web/src/pages/today/components/ArticleCard/ArticleCard.tsx b/web/src/pages/today/components/ArticleCard/ArticleCard.tsx index d00c33843..a582cb09e 100644 --- a/web/src/pages/today/components/ArticleCard/ArticleCard.tsx +++ b/web/src/pages/today/components/ArticleCard/ArticleCard.tsx @@ -41,7 +41,11 @@ const ArticleCard = ({ const handleClick: ArticleCardProps['onClick'] = (event) => { props.onClick?.(event); - if (event.defaultPrevented || to || !pushWebViewArticle(String(articleId))) + if ( + event.defaultPrevented || + to || + !pushWebViewArticle(String(articleId), isMobile) + ) return; event.preventDefault(); }; From 38f6c09d78cf3c446051297630714a605796b5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sat, 18 Jul 2026 23:47:48 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B2=BD=EB=9F=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 234 +++--------------- web/package.json | 7 +- .../components/Header/MobileDetailHeader.tsx | 6 - .../RouteTransition/RouteTransition.tsx | 91 +++++++ .../libs/stackflow/StackflowBackContext.tsx | 20 -- web/src/libs/stackflow/navigation.ts | 104 -------- web/src/libs/stackflow/stackflow.config.ts | 61 ----- web/src/libs/stackflow/stackflow.tsx | 178 ------------- web/src/main.tsx | 1 - .../cards/ChallengeCardBeforeStart.tsx | 4 - .../cards/ChallengeCardComingSoon.tsx | 4 - .../cards/ChallengeCardCompleted.tsx | 11 - .../cards/ChallengeCardOngoing.tsx | 12 - .../index/hooks/useChallengeDetailTabs.ts | 7 +- .../components/PreviousArticles.tsx | 9 +- .../components/SimilarNewsletterCard.tsx | 10 - .../TrendySection/TrendySection.tsx | 8 - .../components/ArticleCard/ArticleCard.tsx | 16 +- web/src/routes/__root.tsx | 33 +-- web/src/routes/_bombom/_main.tsx | 17 +- .../_bombom/_main/challenge/$challengeId.tsx | 4 +- .../routes/_bombom/_main/challenge/index.tsx | 4 +- web/src/routes/_bombom/_main/index.tsx | 4 +- web/src/routes/_bombom/_main/today.tsx | 4 +- .../_bombom/articles.previous.$articleId.tsx | 4 +- .../routes/challenge/$challengeId/landing.tsx | 4 +- web/src/routes/newsletters.$newsletterId.tsx | 4 +- 27 files changed, 153 insertions(+), 708 deletions(-) create mode 100644 web/src/components/RouteTransition/RouteTransition.tsx delete mode 100644 web/src/libs/stackflow/StackflowBackContext.tsx delete mode 100644 web/src/libs/stackflow/navigation.ts delete mode 100644 web/src/libs/stackflow/stackflow.config.ts delete mode 100644 web/src/libs/stackflow/stackflow.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d441e07da..96e2ea55e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,24 +469,6 @@ importers: '@sentry/react': specifier: ^10.0.0 version: 10.17.0(react@19.1.0) - '@stackflow/config': - specifier: ^2.0.0 - version: 2.0.0 - '@stackflow/core': - specifier: ^3.0.0 - version: 3.0.0 - '@stackflow/plugin-basic-ui': - specifier: ^1.18.3 - version: 1.18.3(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(babel-plugin-macros@3.1.0)(react@19.1.0) - '@stackflow/plugin-history-sync': - specifier: ^1.12.0 - version: 1.12.0(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0) - '@stackflow/plugin-renderer-basic': - specifier: ^1.1.15 - version: 1.1.15(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0) - '@stackflow/react': - specifier: ^2.1.2 - version: 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) '@suspensive/react': specifier: ^3.20.3 version: 3.20.3(react@19.1.0) @@ -502,6 +484,9 @@ importers: dotenv: specifier: ^17.2.0 version: 17.2.3 + framer-motion: + specifier: ^12.42.2 + version: 12.42.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) html2canvas: specifier: ^1.4.1 version: 1.4.1 @@ -3302,53 +3287,6 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} - '@stackflow/config@2.0.0': - resolution: {integrity: sha512-EmRyxf124SCuRxx1WhL0leq6WwV7yloejr8WbLq+QhoGFWWRfRY5SyMnvk2zWOuLsUDIDaQWbmFhO+D+xbQorA==, tarball: https://registry.npmjs.org/@stackflow/config/-/config-2.0.0.tgz} - - '@stackflow/core@3.0.0': - resolution: {integrity: sha512-xK9sl1KTKAVoSrrPtFG8jFlcKJX+L65yII/8cibzQh0JQws+zi8TPOo66Ped9QhWdkx7N7AV+kOsPZ/PliCWtg==, tarball: https://registry.npmjs.org/@stackflow/core/-/core-3.0.0.tgz} - - '@stackflow/plugin-basic-ui@1.18.3': - resolution: {integrity: sha512-hTKqXwee+p4JkkbobwQob+tCoN1sZOZyGTxfwSFhsBgiGdulVq5hzU+spafnsEv7kQILUnY4nljtMBIFpqLLWQ==, tarball: https://registry.npmjs.org/@stackflow/plugin-basic-ui/-/plugin-basic-ui-1.18.3.tgz} - peerDependencies: - '@stackflow/core': ^3.0.0 - '@stackflow/react': ^2.0.0 - '@types/react': '>=16.8.0' - react: '>=16.8.0' - - '@stackflow/plugin-history-sync@1.12.0': - resolution: {integrity: sha512-RiqtpY+/7rvQFSJE8yov7G5g7RXKEua7VKVDylQsj1y2skjiUxP1CFDKvK1Hjg6ggOjo4qADjSCTFv6wG+rlkQ==, tarball: https://registry.npmjs.org/@stackflow/plugin-history-sync/-/plugin-history-sync-1.12.0.tgz} - peerDependencies: - '@stackflow/config': ^1.1.0 || ^2.0.0 - '@stackflow/core': ^3.0.0 - '@stackflow/react': ^1.0.0 || ^2.0.0 - '@types/react': '>=16.8.0' - react: '>=16.8.0' - - '@stackflow/plugin-renderer-basic@1.1.15': - resolution: {integrity: sha512-0O8sqM6WyOc65YLYD0i99bWt5xWm1yERHFBSuIvqrphnI/YJj1oCIJ788jBuRKHWVX2AS1AreNgGDqGxRATDeg==, tarball: https://registry.npmjs.org/@stackflow/plugin-renderer-basic/-/plugin-renderer-basic-1.1.15.tgz} - peerDependencies: - '@stackflow/core': ^3.0.0 - '@stackflow/react': ^2.0.0 - '@types/react': '>=16.8.0' - react: '>=16.8.0' - - '@stackflow/react-ui-core@1.3.6': - resolution: {integrity: sha512-RFDR0uICh2bRqRoyjLToyhcNM3P/84hXbYBQqFQpsb/BG5HmThrEcMY19EUZXwcgBuzepsMWk5HZ1Sfl0ZeCZw==, tarball: https://registry.npmjs.org/@stackflow/react-ui-core/-/react-ui-core-1.3.6.tgz} - peerDependencies: - '@stackflow/core': ^3.0.0 - '@stackflow/react': ^2.0.0 - '@types/react': '>=16.8.0' - react: '>=16.8.0' - - '@stackflow/react@2.1.2': - resolution: {integrity: sha512-6aXJLnENrR34Ko6xz1HGSJ4D4Fm9mnmDLjHIExROgC8kg518HR8cxnXVMJZ9I0wSsj8L6Tq/Como1kVYbRr+vA==, tarball: https://registry.npmjs.org/@stackflow/react/-/react-2.1.2.tgz} - peerDependencies: - '@stackflow/config': ^2.0.0 - '@stackflow/core': ^3.0.0 - '@types/react': '>=16.8.0' - react: '>=16.8.0' - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4388,20 +4326,6 @@ packages: peerDependencies: '@urql/core': ^5.0.0 - '@vanilla-extract/css@1.21.1': - resolution: {integrity: sha512-T6z6oeT+o0OwqukE6kLs9w5ooB75b8NPRadyU9pPTml83l40TPN+mscPt2OQ9P9cYfwEBMfsfivwiJujs2mzmg==} - - '@vanilla-extract/dynamic@2.1.5': - resolution: {integrity: sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw==} - - '@vanilla-extract/private@1.0.9': - resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} - - '@vanilla-extract/recipes@0.5.7': - resolution: {integrity: sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A==} - peerDependencies: - '@vanilla-extract/css': ^1.0.0 - '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5475,9 +5399,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deep-object-diff@1.1.9: - resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -6424,6 +6345,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + freeport-async@2.0.0: resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} engines: {node: '>=8'} @@ -6656,9 +6591,6 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} - history@5.3.0: - resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==} - hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -7652,9 +7584,6 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - media-query-parser@2.0.2: - resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} - media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -7889,8 +7818,11 @@ packages: engines: {node: '>=10'} hasBin: true - modern-ahocorasick@1.1.0: - resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -8644,11 +8576,6 @@ packages: '@types/react': optional: true - react18-use@0.4.1: - resolution: {integrity: sha512-NY59qMAOxmpjg3XJGfSotwmH9BVAD1kB4kZqjfB28yr6jQKaGh1F5i7X1N9OTn6dD5r8yqSH3X4SslcqxAxQCw==} - peerDependencies: - react: '>=18.0.0' - react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -9658,10 +9585,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-pattern@1.0.3: - resolution: {integrity: sha512-uQcEj/2puA4aq1R3A2+VNVBgaWYR24FdWjl7VNW83rnWftlhyzOZ/tBjezRiC2UkIzuxC8Top3IekN3vUf1WxA==} - engines: {node: '>=0.12.0'} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -13444,61 +13367,6 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@stackflow/config@2.0.0': {} - - '@stackflow/core@3.0.0': - dependencies: - react-fast-compare: 3.2.2 - - '@stackflow/plugin-basic-ui@1.18.3(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(babel-plugin-macros@3.1.0)(react@19.1.0)': - dependencies: - '@stackflow/core': 3.0.0 - '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) - '@stackflow/react-ui-core': 1.3.6(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0) - '@types/react': 19.1.17 - '@vanilla-extract/css': 1.21.1(babel-plugin-macros@3.1.0) - '@vanilla-extract/dynamic': 2.1.5 - '@vanilla-extract/private': 1.0.9 - '@vanilla-extract/recipes': 0.5.7(@vanilla-extract/css@1.21.1(babel-plugin-macros@3.1.0)) - clsx: 2.1.1 - react: 19.1.0 - transitivePeerDependencies: - - babel-plugin-macros - - '@stackflow/plugin-history-sync@1.12.0(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0)': - dependencies: - '@stackflow/config': 2.0.0 - '@stackflow/core': 3.0.0 - '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) - '@types/react': 19.1.17 - flatted: 3.3.3 - history: 5.3.0 - react: 19.1.0 - react18-use: 0.4.1(react@19.1.0) - url-pattern: 1.0.3 - - '@stackflow/plugin-renderer-basic@1.1.15(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0)': - dependencies: - '@stackflow/core': 3.0.0 - '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) - '@types/react': 19.1.17 - react: 19.1.0 - - '@stackflow/react-ui-core@1.3.6(@stackflow/core@3.0.0)(@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0))(@types/react@19.1.17)(react@19.1.0)': - dependencies: - '@stackflow/core': 3.0.0 - '@stackflow/react': 2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0) - '@types/react': 19.1.17 - react: 19.1.0 - - '@stackflow/react@2.1.2(@stackflow/config@2.0.0)(@stackflow/core@3.0.0)(@types/react@19.1.17)(react@19.1.0)': - dependencies: - '@stackflow/config': 2.0.0 - '@stackflow/core': 3.0.0 - '@types/react': 19.1.17 - react: 19.1.0 - react-fast-compare: 3.2.2 - '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -14851,32 +14719,6 @@ snapshots: '@urql/core': 5.2.0(graphql@16.11.0) wonka: 6.3.5 - '@vanilla-extract/css@1.21.1(babel-plugin-macros@3.1.0)': - dependencies: - '@emotion/hash': 0.9.2 - '@vanilla-extract/private': 1.0.9 - css-what: 6.2.2 - csstype: 3.2.3 - dedent: 1.7.0(babel-plugin-macros@3.1.0) - deep-object-diff: 1.1.9 - deepmerge: 4.3.1 - lru-cache: 10.4.3 - media-query-parser: 2.0.2 - modern-ahocorasick: 1.1.0 - picocolors: 1.1.1 - transitivePeerDependencies: - - babel-plugin-macros - - '@vanilla-extract/dynamic@2.1.5': - dependencies: - '@vanilla-extract/private': 1.0.9 - - '@vanilla-extract/private@1.0.9': {} - - '@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.21.1(babel-plugin-macros@3.1.0))': - dependencies: - '@vanilla-extract/css': 1.21.1(babel-plugin-macros@3.1.0) - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@babel/core': 7.28.4 @@ -16055,8 +15897,6 @@ snapshots: deep-is@0.1.4: {} - deep-object-diff@1.1.9: {} - deepmerge@4.3.1: {} default-browser-id@5.0.0: {} @@ -17421,6 +17261,16 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.42.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + freeport-async@2.0.0: {} fresh@0.5.2: {} @@ -17637,10 +17487,6 @@ snapshots: dependencies: hermes-estree: 0.32.0 - history@5.3.0: - dependencies: - '@babel/runtime': 7.28.4 - hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -18832,10 +18678,6 @@ snapshots: mdn-data@2.12.2: {} - media-query-parser@2.0.2: - dependencies: - '@babel/runtime': 7.28.4 - media-typer@0.3.0: {} memfs@3.5.3: @@ -19278,7 +19120,11 @@ snapshots: mkdirp@1.0.4: {} - modern-ahocorasick@1.1.0: {} + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} ms@2.0.0: {} @@ -20150,10 +19996,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - react18-use@0.4.1(react@19.1.0): - dependencies: - react: 19.1.0 - react@19.1.0: {} readable-stream@2.3.8: @@ -21352,8 +21194,6 @@ snapshots: dependencies: punycode: 2.3.1 - url-pattern@1.0.3: {} - use-callback-ref@1.3.3(@types/react@19.1.17)(react@19.1.0): dependencies: react: 19.1.0 diff --git a/web/package.json b/web/package.json index ed132cfe7..ebbe49721 100644 --- a/web/package.json +++ b/web/package.json @@ -90,17 +90,12 @@ "@lottiefiles/dotlottie-react": "^0.18.3", "@microsoft/clarity": "^1.0.0", "@sentry/react": "^10.0.0", - "@stackflow/config": "^2.0.0", - "@stackflow/core": "^3.0.0", - "@stackflow/plugin-basic-ui": "^1.18.3", - "@stackflow/plugin-history-sync": "^1.12.0", - "@stackflow/plugin-renderer-basic": "^1.1.15", - "@stackflow/react": "^2.1.2", "@suspensive/react": "^3.20.3", "@tanstack/react-query": "^5.82.0", "@tanstack/react-router": "^1.128.3", "@tanstack/react-router-devtools": "^1.128.3", "dotenv": "^17.2.0", + "framer-motion": "^12.42.2", "html2canvas": "^1.4.1", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/web/src/components/Header/MobileDetailHeader.tsx b/web/src/components/Header/MobileDetailHeader.tsx index d16c6e663..a3d6d46f9 100644 --- a/web/src/components/Header/MobileDetailHeader.tsx +++ b/web/src/components/Header/MobileDetailHeader.tsx @@ -1,7 +1,6 @@ import styled from '@emotion/styled'; import { useRouter } from '@tanstack/react-router'; import ChevronIcon from '@/components/icons/ChevronIcon'; -import { useStackflowBack } from '@/libs/stackflow/StackflowBackContext'; import type { ReactNode } from 'react'; interface MobileDetailHeaderProps { @@ -11,13 +10,8 @@ interface MobileDetailHeaderProps { const MobileDetailHeader = ({ left, right }: MobileDetailHeaderProps) => { const router = useRouter(); - const stackflowBack = useStackflowBack(); const handleBackClick = () => { - if (stackflowBack) { - stackflowBack(); - return; - } router.history.back(); }; diff --git a/web/src/components/RouteTransition/RouteTransition.tsx b/web/src/components/RouteTransition/RouteTransition.tsx new file mode 100644 index 000000000..9ca262937 --- /dev/null +++ b/web/src/components/RouteTransition/RouteTransition.tsx @@ -0,0 +1,91 @@ +import styled from '@emotion/styled'; +import { useRouterState } from '@tanstack/react-router'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { useEffect, useRef } from 'react'; +import { useDevice } from '@/hooks/useDevice'; +import type { ReactNode } from 'react'; + +type NavigationDirection = 'push' | 'pop'; + +const DETAIL_PATH_PATTERNS = [ + /^\/articles\/(previous\/)?[^/]+$/, + /^\/newsletters\/[^/]+$/, + /^\/challenge\/[^/]+\/(landing|dashboard|daily|comments|review|certification)$/, +]; + +const getChallengeId = (pathname: string) => + pathname.match(/^\/challenge\/([^/]+)\//)?.[1]; + +const isDetailPath = (pathname: string) => + DETAIL_PATH_PATTERNS.some((pattern) => pattern.test(pathname)); + +interface RouteTransitionProps { + children: ReactNode; +} + +const RouteTransition = ({ children }: RouteTransitionProps) => { + const device = useDevice(); + const shouldReduceMotion = useReducedMotion(); + const location = useRouterState({ select: (state) => state.location }); + const previousLocationRef = useRef(location); + + const previousLocation = previousLocationRef.current; + const currentIndex = location.state.__TSR_index; + const previousIndex = previousLocation.state.__TSR_index; + const direction: NavigationDirection = + currentIndex < previousIndex ? 'pop' : 'push'; + + const currentChallengeId = getChallengeId(location.pathname); + const previousChallengeId = getChallengeId(previousLocation.pathname); + const isSameChallenge = + !!currentChallengeId && currentChallengeId === previousChallengeId; + const shouldAnimate = + device === 'mobile' && + !shouldReduceMotion && + !isSameChallenge && + (isDetailPath(location.pathname) || + isDetailPath(previousLocation.pathname)); + + useEffect(() => { + previousLocationRef.current = location; + }, [location]); + + if (device !== 'mobile') return children; + + return ( + + ({ + x: navigationDirection === 'push' ? '100%' : '-20%', + }), + visible: { x: 0 }, + exit: (navigationDirection: NavigationDirection) => ({ + x: navigationDirection === 'pop' ? '100%' : '-20%', + }), + }} + transition={{ + duration: 0.28, + ease: [0.32, 0.72, 0, 1], + }} + > + {children} + + + ); +}; + +export default RouteTransition; + +const AnimatedPage = styled(motion.div)` + position: relative; + width: 100%; + min-height: 100dvh; + + background-color: ${({ theme }) => theme.colors.white}; +`; diff --git a/web/src/libs/stackflow/StackflowBackContext.tsx b/web/src/libs/stackflow/StackflowBackContext.tsx deleted file mode 100644 index c08595529..000000000 --- a/web/src/libs/stackflow/StackflowBackContext.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { createContext, useContext } from 'react'; -import type { ReactNode } from 'react'; - -const StackflowBackContext = createContext<(() => void) | null>(null); - -interface StackflowBackProviderProps { - children: ReactNode; - onBack: () => void; -} - -export const StackflowBackProvider = ({ - children, - onBack, -}: StackflowBackProviderProps) => ( - - {children} - -); - -export const useStackflowBack = () => useContext(StackflowBackContext); diff --git a/web/src/libs/stackflow/navigation.ts b/web/src/libs/stackflow/navigation.ts deleted file mode 100644 index 62050a188..000000000 --- a/web/src/libs/stackflow/navigation.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { isWebView } from '@/utils/device'; -import type { Actions } from '@stackflow/react'; - -const isStackflowEnabled = (isMobile: boolean) => isWebView() || isMobile; - -const syncTanStackRouter = () => { - requestAnimationFrame(() => { - window.dispatchEvent( - new PopStateEvent('popstate', { state: window.history.state }), - ); - }); -}; - -const withStackflowActions = async (callback: (actions: Actions) => void) => { - const { webViewStackActions } = await import('./stackflow.tsx'); - callback(webViewStackActions); - syncTanStackRouter(); -}; - -export const pushWebViewArticle = (articleId: string, isMobile: boolean) => { - if (!isStackflowEnabled(isMobile)) return false; - void withStackflowActions((actions) => { - actions.push('ArticleActivity', { articleId }); - }); - return true; -}; - -export const pushWebViewPreviousArticle = ( - articleId: string, - isMobile: boolean, -) => { - if (!isStackflowEnabled(isMobile)) return false; - void withStackflowActions((actions) => { - actions.push('PreviousArticleActivity', { articleId }); - }); - return true; -}; - -const CHALLENGE_TAB_ACTIVITY = { - dashboard: 'ChallengeDashboardActivity', - daily: 'ChallengeDailyActivity', - comments: 'ChallengeCommentsActivity', - review: 'ChallengeReviewActivity', - certification: 'ChallengeCertificationActivity', -} as const; - -export const replaceWebViewChallengeTab = ( - challengeId: string, - tab: keyof typeof CHALLENGE_TAB_ACTIVITY, - isMobile: boolean, -) => { - if (!isStackflowEnabled(isMobile)) return false; - void withStackflowActions((actions) => { - actions.replace( - CHALLENGE_TAB_ACTIVITY[tab], - { challengeId }, - { - animate: false, - }, - ); - }); - return true; -}; - -export const pushWebViewNewsletter = ( - newsletterId: string, - isMobile: boolean, -) => { - if (!isStackflowEnabled(isMobile)) return false; - void withStackflowActions((actions) => { - actions.push('NewsletterActivity', { newsletterId }); - }); - return true; -}; - -export const pushWebViewChallengeLanding = ( - challengeId: string, - isMobile: boolean, -) => { - if (!isStackflowEnabled(isMobile)) return false; - void withStackflowActions((actions) => { - actions.push('ChallengeLandingActivity', { challengeId }); - }); - return true; -}; - -export const pushWebViewChallengeDetail = ( - challengeId: string, - tab: 'certification' | 'daily', - isMobile: boolean, -) => { - if (!isStackflowEnabled(isMobile)) return false; - - if (tab === 'certification') { - void withStackflowActions((actions) => { - actions.push('ChallengeCertificationActivity', { challengeId }); - }); - } else { - void withStackflowActions((actions) => { - actions.push('ChallengeDailyActivity', { challengeId }); - }); - } - return true; -}; diff --git a/web/src/libs/stackflow/stackflow.config.ts b/web/src/libs/stackflow/stackflow.config.ts deleted file mode 100644 index 864b66a07..000000000 --- a/web/src/libs/stackflow/stackflow.config.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { defineConfig } from '@stackflow/config'; - -declare module '@stackflow/config' { - interface Register { - RecommendActivity: Record; - TodayActivity: Record; - BookmarkActivity: Record; - MemoActivity: Record; - ArticleActivity: { articleId: string }; - PreviousArticleActivity: { articleId: string }; - NewsletterActivity: { newsletterId: string }; - ChallengeListActivity: Record; - ChallengeLandingActivity: { challengeId: string }; - ChallengeDashboardActivity: { challengeId: string }; - ChallengeDailyActivity: { challengeId: string }; - ChallengeCommentsActivity: { challengeId: string }; - ChallengeReviewActivity: { challengeId: string }; - ChallengeCertificationActivity: { challengeId: string }; - } -} - -export const stackflowConfig = defineConfig({ - activities: [ - { name: 'RecommendActivity', route: '/' }, - { name: 'TodayActivity', route: '/today' }, - { name: 'BookmarkActivity', route: '/bookmark' }, - { name: 'MemoActivity', route: '/memo' }, - { name: 'ArticleActivity', route: '/articles/:articleId' }, - { - name: 'PreviousArticleActivity', - route: '/articles/previous/:articleId', - }, - { name: 'NewsletterActivity', route: '/newsletters/:newsletterId' }, - { name: 'ChallengeListActivity', route: '/challenge' }, - { - name: 'ChallengeLandingActivity', - route: '/challenge/:challengeId/landing', - }, - { - name: 'ChallengeDashboardActivity', - route: '/challenge/:challengeId/dashboard', - }, - { - name: 'ChallengeDailyActivity', - route: '/challenge/:challengeId/daily', - }, - { - name: 'ChallengeCommentsActivity', - route: '/challenge/:challengeId/comments', - }, - { - name: 'ChallengeReviewActivity', - route: '/challenge/:challengeId/review', - }, - { - name: 'ChallengeCertificationActivity', - route: '/challenge/:challengeId/certification', - }, - ], - transitionDuration: 320, -}); diff --git a/web/src/libs/stackflow/stackflow.tsx b/web/src/libs/stackflow/stackflow.tsx deleted file mode 100644 index eb9fd62d1..000000000 --- a/web/src/libs/stackflow/stackflow.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { basicUIPlugin, AppScreen } from '@stackflow/plugin-basic-ui'; -import { historySyncPlugin } from '@stackflow/plugin-history-sync'; -import { basicRendererPlugin } from '@stackflow/plugin-renderer-basic'; -import { - stackflow, - useActivity, - useFlow, - type ActivityComponentType, -} from '@stackflow/react'; -import { stackflowConfig } from './stackflow.config'; -import { StackflowBackProvider } from './StackflowBackContext'; -import AppInstallPromptModal from '@/components/AppInstallPromptModal/AppInstallPromptModal'; -import BomBomPageLayout from '@/components/PageLayout/BomBomPageLayout'; -import { MainPageLayout } from '@/routes/_bombom/_main'; -import { Route as BookmarkRoute } from '@/routes/_bombom/_main/bookmark'; -import { Route as ChallengeDetailRoute } from '@/routes/_bombom/_main/challenge/$challengeId'; -import { Route as ChallengeListRoute } from '@/routes/_bombom/_main/challenge/index'; -import { Route as RecommendRoute } from '@/routes/_bombom/_main/index'; -import { Route as MemoRoute } from '@/routes/_bombom/_main/memo'; -import { Route as TodayRoute } from '@/routes/_bombom/_main/today'; -import { Route as ArticleDetailRoute } from '@/routes/_bombom/articles.$articleId'; -import { Route as PreviousArticleRoute } from '@/routes/_bombom/articles.previous.$articleId'; -import { Route as ChallengeLandingRoute } from '@/routes/challenge/$challengeId/landing'; -import { Route as NewsletterDetailRoute } from '@/routes/newsletters.$newsletterId'; -import type { ComponentType, ReactNode } from 'react'; - -const RecommendPage = RecommendRoute.options.component as ComponentType; -const TodayPage = TodayRoute.options.component as ComponentType; -const BookmarkPage = BookmarkRoute.options.component as ComponentType; -const MemoPage = MemoRoute.options.component as ComponentType; -const ChallengeListPage = ChallengeListRoute.options.component as ComponentType; -const ChallengeDetailPage = ChallengeDetailRoute.options - .component as ComponentType; -const ArticleDetailPage = ArticleDetailRoute.options.component as ComponentType; -const PreviousArticlePage = PreviousArticleRoute.options - .component as ComponentType; -const ChallengeLandingPage = ChallengeLandingRoute.options - .component as ComponentType; -const NewsletterDetailPage = NewsletterDetailRoute.options - .component as ComponentType; - -interface ActivityScreenProps { - children: ReactNode; - isMain?: boolean; - withBomBomLayout?: boolean; -} - -const ActivityScreen = ({ - children, - isMain = false, - withBomBomLayout = true, -}: ActivityScreenProps) => { - const activity = useActivity(); - const { pop } = useFlow(); - - const handleBack = () => { - if (activity.isRoot) { - window.history.back(); - return; - } - pop(); - }; - - const content = isMain ? ( - {children} - ) : ( - children - ); - - return ( - - - {withBomBomLayout ? ( - - {content} - - - ) : ( - content - )} - - - ); -}; - -const RecommendActivity: ActivityComponentType<'RecommendActivity'> = () => ( - - - -); - -const TodayActivity: ActivityComponentType<'TodayActivity'> = () => ( - - - -); - -const BookmarkActivity: ActivityComponentType<'BookmarkActivity'> = () => ( - - - -); - -const MemoActivity: ActivityComponentType<'MemoActivity'> = () => ( - - - -); - -const ArticleActivity: ActivityComponentType<'ArticleActivity'> = () => ( - - - -); - -const PreviousArticleActivity: ActivityComponentType< - 'PreviousArticleActivity' -> = () => ( - - - -); - -const NewsletterActivity: ActivityComponentType<'NewsletterActivity'> = () => ( - - - -); - -const ChallengeListActivity: ActivityComponentType< - 'ChallengeListActivity' -> = () => ( - - - -); - -const ChallengeLandingActivity: ActivityComponentType< - 'ChallengeLandingActivity' -> = () => ( - - - -); - -const ChallengeDetailActivity = () => ( - - - -); - -export const { Stack: WebViewStack, actions: webViewStackActions } = stackflow({ - config: stackflowConfig, - components: { - RecommendActivity, - TodayActivity, - BookmarkActivity, - MemoActivity, - ArticleActivity, - PreviousArticleActivity, - NewsletterActivity, - ChallengeListActivity, - ChallengeLandingActivity, - ChallengeDashboardActivity: ChallengeDetailActivity, - ChallengeDailyActivity: ChallengeDetailActivity, - ChallengeCommentsActivity: ChallengeDetailActivity, - ChallengeReviewActivity: ChallengeDetailActivity, - ChallengeCertificationActivity: ChallengeDetailActivity, - }, - plugins: [ - basicRendererPlugin(), - basicUIPlugin({ theme: 'cupertino' }), - historySyncPlugin({ - config: stackflowConfig, - fallbackActivity: () => 'RecommendActivity', - }), - ], -}); diff --git a/web/src/main.tsx b/web/src/main.tsx index 3def9989b..a319c7bab 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -24,7 +24,6 @@ import NotFound from './pages/system/components/NotFound'; import { routeTree } from './routeTree.gen'; import reset from './styles/reset'; import { isProduction } from './utils/environment'; -import '@stackflow/plugin-basic-ui/index.css'; if (isProduction) Clarity.init(ENV.clarityProjectId); diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx index ba88ea107..e01010560 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardBeforeStart.tsx @@ -11,9 +11,7 @@ import Button from '@/components/Button/Button'; import Flex from '@/components/Flex'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; -import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { pushWebViewChallengeLanding } from '@/libs/stackflow/navigation'; import useChallengeApplyMutation from '@/pages/challenge/index/hooks/useChallengeApplyMutation'; import useChallengeCancelMutation from '@/pages/challenge/index/hooks/useChallengeCancelMutation'; import { getDatesDiff } from '@/utils/date'; @@ -31,7 +29,6 @@ const ChallengeCardBeforeStart = (props: ChallengeCardProps) => { id, } = props; const navigate = useNavigate(); - const device = useDevice(); const { modalRef, openModal, closeModal, isOpen } = useModal(); const { mutate: applyChallenge } = useChallengeApplyMutation({ @@ -56,7 +53,6 @@ const ChallengeCardBeforeStart = (props: ChallengeCardProps) => { 'https://maroon-geranium-880.notion.site/1-2fb03dcf20568089a20ad05cd3de78fe?pvs=74', ); } else { - if (pushWebViewChallengeLanding(String(id), device === 'mobile')) return; navigate({ to: '/challenge/$challengeId/landing', params: { challengeId: id.toString() }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx index dbfc76e00..a2af622e6 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardComingSoon.tsx @@ -4,9 +4,7 @@ import { CardDetailButton, Tag, Title } from '../CardElements'; import CardFooter from '../CardFooter'; import CardHeader from '../CardHeader'; import Text from '@/components/Text'; -import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { pushWebViewChallengeLanding } from '@/libs/stackflow/navigation'; import { getDatesDiff } from '@/utils/date'; import { openExternalLink } from '@/utils/externalLink'; import type { ChallengeCardProps } from '../ChallengeCard'; @@ -14,7 +12,6 @@ import type { ChallengeCardProps } from '../ChallengeCard'; const ChallengeCardComingSoon = (props: ChallengeCardProps) => { const { generation, title, startDate, endDate, id } = props; const navigate = useNavigate(); - const device = useDevice(); const isWeeklyChallenge = getDatesDiff(new Date(startDate), new Date(endDate)) <= 7; @@ -31,7 +28,6 @@ const ChallengeCardComingSoon = (props: ChallengeCardProps) => { 'https://maroon-geranium-880.notion.site/1-2fb03dcf20568089a20ad05cd3de78fe?pvs=74', ); } else { - if (pushWebViewChallengeLanding(String(id), device === 'mobile')) return; navigate({ to: '/challenge/$challengeId/landing', params: { challengeId: id.toString() }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx index 2b09e8390..3041aaaf1 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardCompleted.tsx @@ -4,9 +4,7 @@ import CardContainer from '../CardContainer'; import { CardDetailButton, Tag, Title } from '../CardElements'; import CardFooter from '../CardFooter'; import CardHeader from '../CardHeader'; -import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { pushWebViewChallengeDetail } from '@/libs/stackflow/navigation'; import type { ChallengeCardProps } from '../ChallengeCard'; type GradeType = 'GOLD' | 'SILVER' | 'BRONZE'; @@ -19,7 +17,6 @@ const GRADE_CONFIG: Record = { const ChallengeCardCompleted = (props: ChallengeCardProps) => { const navigate = useNavigate(); - const device = useDevice(); const { participationInfo, id, generation, title } = props; @@ -33,14 +30,6 @@ const ChallengeCardCompleted = (props: ChallengeCardProps) => { label: title, }); - if ( - pushWebViewChallengeDetail( - String(id), - 'certification', - device === 'mobile', - ) - ) - return; navigate({ to: '/challenge/$challengeId/certification', params: { challengeId: String(id) }, diff --git a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx index cd65bc92d..816e988ff 100644 --- a/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx +++ b/web/src/pages/challenge/index/components/ChallengeCard/cards/ChallengeCardOngoing.tsx @@ -12,18 +12,12 @@ import Flex from '@/components/Flex'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; import ProgressBar from '@/components/ProgressBar/ProgressBar'; -import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { - pushWebViewChallengeDetail, - pushWebViewChallengeLanding, -} from '@/libs/stackflow/navigation'; import useChallengeApplyMutation from '@/pages/challenge/index/hooks/useChallengeApplyMutation'; import type { ChallengeCardProps } from '../ChallengeCard'; const ChallengeCardOngoing = (props: ChallengeCardProps) => { const navigate = useNavigate(); - const device = useDevice(); const { modalRef, openModal, closeModal, isOpen } = useModal(); const { @@ -47,12 +41,6 @@ const ChallengeCardOngoing = (props: ChallengeCardProps) => { label: title, }); - const challengeId = String(id); - const handled = participationInfo?.isJoined - ? pushWebViewChallengeDetail(challengeId, 'daily', device === 'mobile') - : pushWebViewChallengeLanding(challengeId, device === 'mobile'); - if (handled) return; - navigate({ to: participationInfo?.isJoined ? '/challenge/$challengeId' diff --git a/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts b/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts index 75b26a846..6a1ba9875 100644 --- a/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts +++ b/web/src/pages/challenge/index/hooks/useChallengeDetailTabs.ts @@ -1,7 +1,5 @@ import { useNavigate, useRouterState } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; -import { useDevice } from '@/hooks/useDevice'; -import { replaceWebViewChallengeTab } from '@/libs/stackflow/navigation'; const CHALLENGE_TABS = [ { id: 'review', label: '리뷰', path: 'review' }, @@ -26,7 +24,6 @@ export const useChallengeDetailTabs = ({ isLastDayOrAfter, }: UseChallengeDetailTabsProps) => { const navigate = useNavigate(); - const device = useDevice(); const routerState = useRouterState(); const currentPath = routerState.location.pathname; @@ -52,14 +49,12 @@ export const useChallengeDetailTabs = ({ const goToTab = useCallback( (tabPath: ChallengeTabPath) => { - if (replaceWebViewChallengeTab(challengeId, tabPath, device === 'mobile')) - return; navigate({ to: `/challenge/$challengeId/${tabPath}`, params: { challengeId }, }); }, - [challengeId, device, navigate], + [challengeId, navigate], ); return { diff --git a/web/src/pages/newsletter-detail/components/PreviousArticles.tsx b/web/src/pages/newsletter-detail/components/PreviousArticles.tsx index 5265c4056..79f485147 100644 --- a/web/src/pages/newsletter-detail/components/PreviousArticles.tsx +++ b/web/src/pages/newsletter-detail/components/PreviousArticles.tsx @@ -3,7 +3,6 @@ import styled from '@emotion/styled'; import { useNavigate } from '@tanstack/react-router'; import PreviousArticleListItem from './PreviousArticleListItem'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { pushWebViewPreviousArticle } from '@/libs/stackflow/navigation'; import { openExternalLink } from '@/utils/externalLink'; import type { GetPreviousArticlesResponse } from '@/apis/previousArticles/previousArticles.api'; import sadBom from '#/assets/avif/sad-bom.avif'; @@ -44,17 +43,15 @@ const PreviousArticles = ({ title={article.title} contentsSummary={article.contentsSummary} expectedReadTime={article.expectedReadTime} - onClick={() => { - if (pushWebViewPreviousArticle(String(article.articleId), isMobile)) - return; + onClick={() => navigate({ to: '/articles/previous/$articleId', params: { articleId: String(article.articleId) }, state: { subscribeUrl: newsletterSubscribeUrl, }, - }); - }} + }) + } /> )); } diff --git a/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx b/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx index a22568bab..9779b8b96 100644 --- a/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx +++ b/web/src/pages/newsletter-detail/components/SimilarNewsletterCard.tsx @@ -1,9 +1,7 @@ import styled from '@emotion/styled'; import { useNavigate } from '@tanstack/react-router'; import ImageWithFallback from '@/components/ImageWithFallback/ImageWithFallback'; -import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { pushWebViewNewsletter } from '@/libs/stackflow/navigation'; import type { Newsletter } from '@/types/newsletter'; interface SimilarNewsletterCardProps { @@ -12,7 +10,6 @@ interface SimilarNewsletterCardProps { const SimilarNewsletterCard = ({ newsletter }: SimilarNewsletterCardProps) => { const navigate = useNavigate(); - const device = useDevice(); const handleClick = () => { trackEvent({ @@ -20,13 +17,6 @@ const SimilarNewsletterCard = ({ newsletter }: SimilarNewsletterCardProps) => { action: '비슷한 뉴스레터 카드 클릭', label: newsletter.name, }); - if ( - pushWebViewNewsletter( - String(newsletter.newsletterId), - device === 'mobile', - ) - ) - return; navigate({ to: '/newsletters/$newsletterId', params: { newsletterId: String(newsletter.newsletterId) }, diff --git a/web/src/pages/recommend/components/TrendySection/TrendySection.tsx b/web/src/pages/recommend/components/TrendySection/TrendySection.tsx index 9f68ed3b1..e3cf18375 100644 --- a/web/src/pages/recommend/components/TrendySection/TrendySection.tsx +++ b/web/src/pages/recommend/components/TrendySection/TrendySection.tsx @@ -10,7 +10,6 @@ import SearchInput from '@/components/SearchInput/SearchInput'; import { NEWSLETTER_COUNT } from '@/constants/newsletter'; import { useDevice } from '@/hooks/useDevice'; import { trackEvent } from '@/libs/googleAnalytics/gaEvents'; -import { pushWebViewNewsletter } from '@/libs/stackflow/navigation'; import type { Device } from '@/hooks/useDevice'; import type { Newsletter } from '@/types/newsletter'; import type { ChangeEvent } from 'react'; @@ -66,13 +65,6 @@ const TrendySection = () => { action: '뉴스레터 카드 클릭', label: newsletter.name ?? 'Unknown Newsletter', }); - if ( - pushWebViewNewsletter( - String(newsletter.newsletterId), - device === 'mobile', - ) - ) - return; navigate({ to: '/newsletters/$newsletterId', params: { newsletterId: String(newsletter.newsletterId) }, diff --git a/web/src/pages/today/components/ArticleCard/ArticleCard.tsx b/web/src/pages/today/components/ArticleCard/ArticleCard.tsx index a582cb09e..7031586e5 100644 --- a/web/src/pages/today/components/ArticleCard/ArticleCard.tsx +++ b/web/src/pages/today/components/ArticleCard/ArticleCard.tsx @@ -3,7 +3,6 @@ import { Link } from '@tanstack/react-router'; import Badge from '@/components/Badge/Badge'; import ImageWithFallback from '@/components/ImageWithFallback/ImageWithFallback'; import { useDevice } from '@/hooks/useDevice'; -import { pushWebViewArticle } from '@/libs/stackflow/navigation'; import { formatDate } from '@/utils/date'; import type { components } from '@/types/openapi'; import type { ComponentProps } from 'react'; @@ -37,27 +36,14 @@ const ArticleCard = ({ } = data; const device = useDevice(); const isMobile = device === 'mobile'; - const articlePath = to ?? `/articles/${articleId}`; - - const handleClick: ArticleCardProps['onClick'] = (event) => { - props.onClick?.(event); - if ( - event.defaultPrevented || - to || - !pushWebViewArticle(String(articleId), isMobile) - ) - return; - event.preventDefault(); - }; return ( {title} diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 2966e014c..8240d1912 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -5,10 +5,9 @@ import { createRootRouteWithContext, HeadContent, Outlet, - useRouterState, } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; -import { lazy, Suspense } from 'react'; +import RouteTransition from '@/components/RouteTransition/RouteTransition'; import Toast from '@/components/Toast/Toast'; import { AuthProvider } from '@/contexts/AuthContext'; import { useChannelTalk } from '@/hooks/useChannelTalk'; @@ -17,7 +16,6 @@ import usePageTracking from '@/libs/googleAnalytics/usePageTracking'; import { useWebViewAuth } from '@/libs/webview/useWebViewAuth'; import { useWebViewRouting } from '@/libs/webview/useWebViewRouting'; import { queryClient } from '@/main'; -import { isWebView } from '@/utils/device'; import type { QueryClient } from '@tanstack/react-query'; import type { redirect } from '@tanstack/react-router'; @@ -29,26 +27,8 @@ interface BomBomRouterContext { // 채널톡 버튼은 PC에서만 노출되므로 PC에서만 간격을 띄운다. const CHANNEL_TALK_TOAST_OFFSET = 96; -const WebViewStack = lazy(async () => { - const stackflowModule = await import('../libs/stackflow/stackflow.tsx'); - return { default: stackflowModule.WebViewStack }; -}); - -const STACKFLOW_PATHS = [ - /^\/$/, - /^\/today$/, - /^\/(bookmark|memo)$/, - /^\/articles\/(previous\/)?[^/]+$/, - /^\/newsletters\/[^/]+$/, - /^\/challenge$/, - /^\/challenge\/[^/]+\/(landing|dashboard|daily|comments|review|certification)$/, -]; - const RootComponent = () => { const device = useDevice(); - const pathname = useRouterState({ - select: (state) => state.location.pathname, - }); usePageTracking(); useWebViewAuth(); @@ -56,22 +36,15 @@ const RootComponent = () => { useChannelTalk(); const toastOffset = device === 'pc' ? CHANNEL_TALK_TOAST_OFFSET : undefined; - const shouldUseStackflow = - (isWebView() || device === 'mobile') && - STACKFLOW_PATHS.some((pattern) => pattern.test(pathname)); return ( <> - {shouldUseStackflow ? ( - - - - ) : ( + - )} + diff --git a/web/src/routes/_bombom/_main.tsx b/web/src/routes/_bombom/_main.tsx index 0a985e959..d67f690b3 100644 --- a/web/src/routes/_bombom/_main.tsx +++ b/web/src/routes/_bombom/_main.tsx @@ -4,25 +4,12 @@ import HeaderNavButtons from '@/components/Header/HeaderNavButtons'; import MobileMainHeader from '@/components/Header/MobileMainHeader'; import { useActiveNav } from '@/hooks/useActiveNav'; import { useDevice } from '@/hooks/useDevice'; -import type { ReactNode } from 'react'; export const Route = createFileRoute('/_bombom/_main')({ component: RouteComponent, }); function RouteComponent() { - return ( - - - - ); -} - -interface MainPageLayoutProps { - children: ReactNode; -} - -export const MainPageLayout = ({ children }: MainPageLayoutProps) => { const activeNav = useActiveNav(); const device = useDevice(); const isPC = device === 'pc'; @@ -30,7 +17,7 @@ export const MainPageLayout = ({ children }: MainPageLayoutProps) => { return ( <> {!isPC && } - {children} + {!isPC && ( <> @@ -41,7 +28,7 @@ export const MainPageLayout = ({ children }: MainPageLayoutProps) => { )} ); -}; +} const BottomNavSafeArea = styled.div` width: 100%; diff --git a/web/src/routes/_bombom/_main/challenge/$challengeId.tsx b/web/src/routes/_bombom/_main/challenge/$challengeId.tsx index 5017bda9d..138ebb343 100644 --- a/web/src/routes/_bombom/_main/challenge/$challengeId.tsx +++ b/web/src/routes/_bombom/_main/challenge/$challengeId.tsx @@ -23,10 +23,10 @@ export const Route = createFileRoute('/_bombom/_main/challenge/$challengeId')({ }, ], }), - component: ChallengeDetailPage, + component: ChallengeDetail, }); -function ChallengeDetailPage() { +function ChallengeDetail() { const { challengeId } = Route.useParams(); const device = useDevice(); diff --git a/web/src/routes/_bombom/_main/challenge/index.tsx b/web/src/routes/_bombom/_main/challenge/index.tsx index 687efb1c1..0b2f89967 100644 --- a/web/src/routes/_bombom/_main/challenge/index.tsx +++ b/web/src/routes/_bombom/_main/challenge/index.tsx @@ -15,10 +15,10 @@ export const Route = createFileRoute('/_bombom/_main/challenge/')({ }, ], }), - component: ChallengeListPage, + component: RouteComponent, }); -function ChallengeListPage() { +function RouteComponent() { const device = useDevice(); const { data: challenges } = useQuery(queries.challenges()); diff --git a/web/src/routes/_bombom/_main/index.tsx b/web/src/routes/_bombom/_main/index.tsx index 97fc807df..846d401c2 100644 --- a/web/src/routes/_bombom/_main/index.tsx +++ b/web/src/routes/_bombom/_main/index.tsx @@ -20,10 +20,10 @@ export const Route = createFileRoute('/_bombom/_main/')({ }, ], }), - component: RecommendPage, + component: Index, }); -function RecommendPage() { +function Index() { const device = useDevice(); const { data: notices } = useQuery(queries.notices()); diff --git a/web/src/routes/_bombom/_main/today.tsx b/web/src/routes/_bombom/_main/today.tsx index 151e046d3..3e3c808b1 100644 --- a/web/src/routes/_bombom/_main/today.tsx +++ b/web/src/routes/_bombom/_main/today.tsx @@ -37,12 +37,12 @@ export const Route = createFileRoute('/_bombom/_main/today')({ }), component: () => ( - + ), }); -function TodayPage() { +function Index() { const today = useMemo(() => new Date(), []); const todayDateStr = useMemo(() => formatDate(today, '-'), [today]); diff --git a/web/src/routes/_bombom/articles.previous.$articleId.tsx b/web/src/routes/_bombom/articles.previous.$articleId.tsx index 2f313ec49..77ff59fc3 100644 --- a/web/src/routes/_bombom/articles.previous.$articleId.tsx +++ b/web/src/routes/_bombom/articles.previous.$articleId.tsx @@ -27,10 +27,10 @@ export const Route = createFileRoute('/_bombom/articles/previous/$articleId')({ }, ], }), - component: PreviousArticlePage, + component: RouteComponent, }); -function PreviousArticlePage() { +function RouteComponent() { const device = useDevice(); const { userProfile, isLoggedIn } = useAuth(); const { articleId } = Route.useParams(); diff --git a/web/src/routes/challenge/$challengeId/landing.tsx b/web/src/routes/challenge/$challengeId/landing.tsx index 6bf112b7c..274714dc2 100644 --- a/web/src/routes/challenge/$challengeId/landing.tsx +++ b/web/src/routes/challenge/$challengeId/landing.tsx @@ -35,10 +35,10 @@ export const Route = createFileRoute('/challenge/$challengeId/landing')({ }, ], }), - component: ChallengeLandingPage, + component: ChallengeLanding, }); -function ChallengeLandingPage() { +function ChallengeLanding() { const device = useDevice(); const { challengeId: stringChallengeId } = useParams({ from: '/challenge/$challengeId/landing', diff --git a/web/src/routes/newsletters.$newsletterId.tsx b/web/src/routes/newsletters.$newsletterId.tsx index f4f5b10d6..746c32a44 100644 --- a/web/src/routes/newsletters.$newsletterId.tsx +++ b/web/src/routes/newsletters.$newsletterId.tsx @@ -63,7 +63,7 @@ export const Route = createFileRoute('/newsletters/$newsletterId')({ links: [{ rel: 'canonical', href: url }], }; }, - component: NewsletterDetailPage, + component: NewsletterDetailRoute, pendingComponent: NewsletterDetailPending, validateSearch: (search: NewsletterDetailSearch & SearchSchemaInput) => ({ tab: search?.tab, @@ -79,7 +79,7 @@ function NewsletterDetailPending() { ); } -function NewsletterDetailPage() { +function NewsletterDetailRoute() { const { newsletterId } = Route.useParams(); const id = Number(newsletterId); const device = useDevice(); From 0fcaa7e916becb91497677273133b3089f475f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sun, 19 Jul 2026 00:04:30 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=84=ED=99=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteTransition/RouteTransition.tsx | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/web/src/components/RouteTransition/RouteTransition.tsx b/web/src/components/RouteTransition/RouteTransition.tsx index 9ca262937..ff9cf00db 100644 --- a/web/src/components/RouteTransition/RouteTransition.tsx +++ b/web/src/components/RouteTransition/RouteTransition.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; import { useRouterState } from '@tanstack/react-router'; -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { motion, useReducedMotion } from 'framer-motion'; import { useEffect, useRef } from 'react'; import { useDevice } from '@/hooks/useDevice'; import type { ReactNode } from 'react'; @@ -53,30 +53,19 @@ const RouteTransition = ({ children }: RouteTransitionProps) => { if (device !== 'mobile') return children; return ( - - ({ - x: navigationDirection === 'push' ? '100%' : '-20%', - }), - visible: { x: 0 }, - exit: (navigationDirection: NavigationDirection) => ({ - x: navigationDirection === 'pop' ? '100%' : '-20%', - }), - }} - transition={{ - duration: 0.28, - ease: [0.32, 0.72, 0, 1], - }} - > - {children} - - + + {children} + ); }; From ab6c90ecf86cd3d5f36773b98e3ced1f66906d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sun, 19 Jul 2026 00:08:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteTransition/RouteTransition.tsx | 167 ++++++++++++++---- 1 file changed, 132 insertions(+), 35 deletions(-) diff --git a/web/src/components/RouteTransition/RouteTransition.tsx b/web/src/components/RouteTransition/RouteTransition.tsx index ff9cf00db..d7a976e3e 100644 --- a/web/src/components/RouteTransition/RouteTransition.tsx +++ b/web/src/components/RouteTransition/RouteTransition.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; -import { useRouterState } from '@tanstack/react-router'; +import { useRouter, useRouterState } from '@tanstack/react-router'; import { motion, useReducedMotion } from 'framer-motion'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useDevice } from '@/hooks/useDevice'; import type { ReactNode } from 'react'; @@ -23,58 +23,155 @@ interface RouteTransitionProps { children: ReactNode; } +interface TransitionSnapshot { + direction: NavigationDirection; + fromKey: string; + html: string; + scrollY: number; +} + const RouteTransition = ({ children }: RouteTransitionProps) => { const device = useDevice(); + const router = useRouter(); const shouldReduceMotion = useReducedMotion(); const location = useRouterState({ select: (state) => state.location }); - const previousLocationRef = useRef(location); - - const previousLocation = previousLocationRef.current; - const currentIndex = location.state.__TSR_index; - const previousIndex = previousLocation.state.__TSR_index; - const direction: NavigationDirection = - currentIndex < previousIndex ? 'pop' : 'push'; - - const currentChallengeId = getChallengeId(location.pathname); - const previousChallengeId = getChallengeId(previousLocation.pathname); - const isSameChallenge = - !!currentChallengeId && currentChallengeId === previousChallengeId; - const shouldAnimate = - device === 'mobile' && - !shouldReduceMotion && - !isSameChallenge && - (isDetailPath(location.pathname) || - isDetailPath(previousLocation.pathname)); + const pageRef = useRef(null); + const [snapshot, setSnapshot] = useState(null); + const locationKey = location.state.__TSR_key ?? location.href; + const isTransitioning = !!snapshot && snapshot.fromKey !== locationKey; useEffect(() => { - previousLocationRef.current = location; - }, [location]); + return router.subscribe( + 'onBeforeNavigate', + ({ fromLocation, toLocation, pathChanged }) => { + const fromIndex = fromLocation?.state.__TSR_index; + const toIndex = toLocation.state.__TSR_index; + const currentPage = pageRef.current; + + if ( + device !== 'mobile' || + shouldReduceMotion || + !pathChanged || + !fromLocation || + !currentPage + ) { + setSnapshot(null); + return; + } + + const currentChallengeId = getChallengeId(fromLocation.pathname); + const nextChallengeId = getChallengeId(toLocation.pathname); + const isSameChallenge = + !!currentChallengeId && currentChallengeId === nextChallengeId; + const shouldAnimate = + !isSameChallenge && + (isDetailPath(fromLocation.pathname) || + isDetailPath(toLocation.pathname)); + + if (!shouldAnimate) { + setSnapshot(null); + return; + } + + setSnapshot({ + direction: + fromIndex !== undefined && toIndex < fromIndex ? 'pop' : 'push', + fromKey: fromLocation.state.__TSR_key ?? fromLocation.href, + html: currentPage.innerHTML, + scrollY: window.scrollY, + }); + }, + ); + }, [device, router, shouldReduceMotion]); if (device !== 'mobile') return children; return ( - - {children} - + + {snapshot?.direction === 'push' && ( + + )} + { + if (isTransitioning && snapshot.direction === 'push') { + setSnapshot(null); + } + }} + > + {children} + + {snapshot?.direction === 'pop' && ( + { + if (isTransitioning) setSnapshot(null); + }} + dangerouslySetInnerHTML={{ __html: snapshot.html }} + /> + )} + ); }; export default RouteTransition; +const Container = styled.div` + position: relative; + width: 100%; + min-height: 100dvh; + + background-color: ${({ theme }) => theme.colors.white}; + + overflow-x: clip; +`; + const AnimatedPage = styled(motion.div)` position: relative; + z-index: 1; width: 100%; min-height: 100dvh; background-color: ${({ theme }) => theme.colors.white}; `; + +const SnapshotPage = styled(motion.div)<{ + $scrollY: number; + $isOverlay: boolean; +}>` + position: fixed; + top: ${({ $scrollY }) => `-${$scrollY}px`}; + right: 0; + left: 0; + z-index: ${({ $isOverlay }) => ($isOverlay ? 2 : 0)}; + min-height: 100dvh; + + background-color: ${({ theme }) => theme.colors.white}; + + pointer-events: none; +`; From 27a7a4b9467db6cab5c711eef9eee0cc25ae536b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sun, 19 Jul 2026 00:11:29 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteTransition/RouteTransition.tsx | 114 ++++++++---------- 1 file changed, 49 insertions(+), 65 deletions(-) diff --git a/web/src/components/RouteTransition/RouteTransition.tsx b/web/src/components/RouteTransition/RouteTransition.tsx index d7a976e3e..8893d494b 100644 --- a/web/src/components/RouteTransition/RouteTransition.tsx +++ b/web/src/components/RouteTransition/RouteTransition.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; -import { useRouter, useRouterState } from '@tanstack/react-router'; +import { useRouterState } from '@tanstack/react-router'; import { motion, useReducedMotion } from 'framer-motion'; -import { useEffect, useRef, useState } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import { useDevice } from '@/hooks/useDevice'; import type { ReactNode } from 'react'; @@ -23,72 +23,58 @@ interface RouteTransitionProps { children: ReactNode; } -interface TransitionSnapshot { - direction: NavigationDirection; - fromKey: string; +interface PageSnapshot { html: string; scrollY: number; } const RouteTransition = ({ children }: RouteTransitionProps) => { const device = useDevice(); - const router = useRouter(); const shouldReduceMotion = useReducedMotion(); const location = useRouterState({ select: (state) => state.location }); const pageRef = useRef(null); - const [snapshot, setSnapshot] = useState(null); + const previousLocationRef = useRef(location); + const previousPageRef = useRef({ html: '', scrollY: 0 }); + const [, setTransitionFinishedKey] = useState(); const locationKey = location.state.__TSR_key ?? location.href; - const isTransitioning = !!snapshot && snapshot.fromKey !== locationKey; - - useEffect(() => { - return router.subscribe( - 'onBeforeNavigate', - ({ fromLocation, toLocation, pathChanged }) => { - const fromIndex = fromLocation?.state.__TSR_index; - const toIndex = toLocation.state.__TSR_index; - const currentPage = pageRef.current; - - if ( - device !== 'mobile' || - shouldReduceMotion || - !pathChanged || - !fromLocation || - !currentPage - ) { - setSnapshot(null); - return; - } - - const currentChallengeId = getChallengeId(fromLocation.pathname); - const nextChallengeId = getChallengeId(toLocation.pathname); - const isSameChallenge = - !!currentChallengeId && currentChallengeId === nextChallengeId; - const shouldAnimate = - !isSameChallenge && - (isDetailPath(fromLocation.pathname) || - isDetailPath(toLocation.pathname)); - - if (!shouldAnimate) { - setSnapshot(null); - return; - } - - setSnapshot({ - direction: - fromIndex !== undefined && toIndex < fromIndex ? 'pop' : 'push', - fromKey: fromLocation.state.__TSR_key ?? fromLocation.href, - html: currentPage.innerHTML, - scrollY: window.scrollY, - }); - }, - ); - }, [device, router, shouldReduceMotion]); + const previousLocation = previousLocationRef.current; + const previousLocationKey = + previousLocation.state.__TSR_key ?? previousLocation.href; + const hasLocationChanged = previousLocationKey !== locationKey; + const direction: NavigationDirection = + location.state.__TSR_index < previousLocation.state.__TSR_index + ? 'pop' + : 'push'; + const currentChallengeId = getChallengeId(location.pathname); + const previousChallengeId = getChallengeId(previousLocation.pathname); + const isSameChallenge = + !!currentChallengeId && currentChallengeId === previousChallengeId; + const isTransitioning = + hasLocationChanged && + device === 'mobile' && + !shouldReduceMotion && + !isSameChallenge && + (isDetailPath(location.pathname) || + isDetailPath(previousLocation.pathname)); + const snapshot = previousPageRef.current; + + useLayoutEffect(() => { + const currentPage = pageRef.current; + + if (currentPage) { + previousPageRef.current = { + html: currentPage.innerHTML, + scrollY: window.scrollY, + }; + } + previousLocationRef.current = location; + }, [location]); if (device !== 'mobile') return children; return ( - {snapshot?.direction === 'push' && ( + {isTransitioning && direction === 'push' && ( { ref={pageRef} key={locationKey} initial={ - isTransitioning && snapshot.direction === 'push' - ? { x: '100%' } - : false + isTransitioning && direction === 'push' ? { x: '100%' } : false } animate={{ x: 0 }} transition={{ duration: 0.28, ease: [0.32, 0.72, 0, 1], }} - onAnimationComplete={() => { - if (isTransitioning && snapshot.direction === 'push') { - setSnapshot(null); - } - }} + onAnimationComplete={ + isTransitioning && direction === 'push' + ? () => setTransitionFinishedKey(locationKey) + : undefined + } > {children} - {snapshot?.direction === 'pop' && ( + {isTransitioning && direction === 'pop' && ( { - if (isTransitioning) setSnapshot(null); + setTransitionFinishedKey(locationKey); }} dangerouslySetInnerHTML={{ __html: snapshot.html }} />